diff --git a/package.json b/package.json index f6dcd5e88..e385255c0 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "agent:interactive": "tsx --env-file=.env src/agent/interactive-cli.ts", "agent:profile": "tsx --env-file=.env src/agent/profile-cli.ts", "skills:cli": "tsx --env-file=.env src/agent/skills-cli.ts", + "tools:cli": "tsx --env-file=.env src/agent/tools-cli.ts", "dev:gateway": "tsx --env-file=.env --watch src/gateway/main.ts", "dev:console": "tsx --env-file=.env --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", diff --git a/src/agent/cli.ts b/src/agent/cli.ts index 0cbd6662a..ab4d14177 100644 --- a/src/agent/cli.ts +++ b/src/agent/cli.ts @@ -13,6 +13,10 @@ type CliOptions = { session?: string | undefined; debug?: boolean | undefined; help?: boolean | undefined; + // Tools configuration + toolsProfile?: string | undefined; + toolsAllow?: string[] | undefined; + toolsDeny?: string[] | undefined; }; function printUsage() { @@ -31,6 +35,16 @@ function printUsage() { console.log(" --session ID Session ID for conversation persistence"); console.log(" --debug Enable debug logging"); console.log(" --help, -h Show this help"); + console.log(""); + console.log("Tools Configuration:"); + console.log(" --tools-profile PROFILE Tool profile (minimal, coding, web, full)"); + console.log(" --tools-allow TOOLS Allow specific tools (comma-separated, supports group:*)"); + console.log(" --tools-deny TOOLS Deny specific tools (comma-separated)"); + console.log(""); + console.log("Examples:"); + console.log(' pnpm agent:cli --tools-profile coding "list files"'); + console.log(' pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls"'); + console.log(' pnpm agent:cli --tools-deny exec,process "read file.txt"'); } function parseArgs(argv: string[]) { @@ -85,6 +99,20 @@ function parseArgs(argv: string[]) { opts.debug = true; continue; } + if (arg === "--tools-profile") { + opts.toolsProfile = args.shift(); + continue; + } + if (arg === "--tools-allow") { + const value = args.shift(); + opts.toolsAllow = value?.split(",").map((s) => s.trim()) ?? []; + continue; + } + if (arg === "--tools-deny") { + const value = args.shift(); + opts.toolsDeny = value?.split(",").map((s) => s.trim()) ?? []; + continue; + } if (arg === "--") { promptParts.push(...args); break; @@ -120,6 +148,21 @@ async function main() { process.exit(1); } + // Build tools config if any tools options are set + let toolsConfig: import("./tools/policy.js").ToolsConfig | undefined; + if (opts.toolsProfile || opts.toolsAllow || opts.toolsDeny) { + toolsConfig = {}; + if (opts.toolsProfile) { + toolsConfig.profile = opts.toolsProfile as any; + } + if (opts.toolsAllow) { + toolsConfig.allow = opts.toolsAllow; + } + if (opts.toolsDeny) { + toolsConfig.deny = opts.toolsDeny; + } + } + const agent = new Agent({ profileId: opts.profile, provider: opts.provider, @@ -131,6 +174,7 @@ async function main() { cwd: opts.cwd, sessionId: opts.session, debug: opts.debug, + tools: toolsConfig, }); // If it's a newly created session, notify user of sessionId diff --git a/src/agent/profile/README.md b/src/agent/profile/README.md new file mode 100644 index 000000000..41037a29f --- /dev/null +++ b/src/agent/profile/README.md @@ -0,0 +1,197 @@ +# Agent Profile System + +The Agent Profile system allows you to define and manage agent personalities, capabilities, and configurations. Each profile is a collection of markdown files and a JSON configuration file stored in a directory. + +## Directory Structure + +``` +~/.super-multica/agent-profiles/ +└── / + ├── soul.md # Personality constraints and behavior style + ├── identity.md # Agent's name and self-awareness + ├── tools.md # Custom tool usage instructions + ├── memory.md # Persistent knowledge base + ├── bootstrap.md # Guidance for each conversation start + └── config.json # Profile configuration (tools, provider, model) +``` + +## Profile Files + +### soul.md +Defines the agent's personality constraints and behavior boundaries. + +```markdown +# Soul + +You are a helpful AI assistant. Follow these guidelines: + +- Be concise and direct in your responses +- Ask clarifying questions when requirements are ambiguous +- Admit when you don't know something +``` + +### identity.md +Contains the agent's identity information. + +```markdown +# Identity + +- Name: CodeBot +- Role: Software development assistant +``` + +### tools.md +Custom instructions for tool usage (appended to the system prompt). + +### memory.md +Persistent knowledge base that survives across conversations. + +### bootstrap.md +Guidance information provided at the start of each conversation. + +### config.json +JSON configuration for the profile: + +```json +{ + "tools": { + "profile": "coding", + "allow": ["web_fetch"], + "deny": ["exec"] + }, + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "thinkingLevel": "medium" +} +``` + +## Configuration Options + +### tools +Tool policy configuration. See [Tools README](../tools/README.md) for details. + +| Field | Type | Description | +|-------|------|-------------| +| `profile` | string | Base profile: `minimal`, `coding`, `web`, `full` | +| `allow` | string[] | Additional tools to allow (supports `group:*` syntax) | +| `deny` | string[] | Tools to block (takes precedence over allow) | +| `byProvider` | object | Provider-specific tool rules | + +Example configurations: + +```json +// Minimal - only file operations +{ + "tools": { + "profile": "minimal", + "allow": ["group:fs"] + } +} + +// Coding without web access +{ + "tools": { + "profile": "coding", + "deny": ["group:web"] + } +} + +// Full access except shell execution +{ + "tools": { + "deny": ["exec", "process"] + } +} +``` + +### provider +Default LLM provider for this profile. + +### model +Default model ID for this profile. + +### thinkingLevel +Default thinking level: `none`, `low`, `medium`, `high`. + +## Usage + +### CLI + +```bash +# Use a specific profile +pnpm agent:cli --profile my-agent "Hello" + +# Profile with custom base directory +pnpm agent:cli --profile my-agent --profile-dir /path/to/profiles "Hello" +``` + +### Programmatic + +```typescript +import { ProfileManager } from "./profile/index.js"; + +// Load existing profile +const manager = new ProfileManager({ + profileId: "my-agent", + baseDir: "/custom/path", // optional +}); + +// Get profile (returns undefined if not exists) +const profile = manager.getProfile(); + +// Get or create with defaults +const profile = manager.getOrCreateProfile(true); // useTemplates + +// Build system prompt from profile +const systemPrompt = manager.buildSystemPrompt(); + +// Get tools configuration +const toolsConfig = manager.getToolsConfig(); + +// Get full profile config +const config = manager.getProfileConfig(); +``` + +## Config Priority + +When using a profile, configurations are merged with CLI options: + +1. **Profile config.json** - Base configuration +2. **CLI options** - Override profile settings + +```bash +# Profile has tools.profile = "coding" +# CLI adds --tools-deny exec +# Result: coding profile without exec tool +pnpm agent:cli --profile my-agent --tools-deny exec "list files" +``` + +The merge behavior: +- `profile`: CLI wins if specified +- `allow`: Union of both lists +- `deny`: Union of both lists +- `byProvider`: Deep merge with CLI taking precedence + +## Creating a Profile + +### Manual Creation + +1. Create directory: `mkdir -p ~/.super-multica/agent-profiles/my-agent` +2. Create markdown files (soul.md, identity.md, etc.) +3. Create config.json with your settings + +### Programmatic Creation + +```typescript +import { createAgentProfile } from "./profile/index.js"; + +// Create with default templates +const profile = createAgentProfile("my-agent", { + useTemplates: true, // Fill with default content +}); + +// Create empty profile +const profile = createAgentProfile("minimal-agent", { + useTemplates: false, +}); +``` diff --git a/src/agent/profile/index.ts b/src/agent/profile/index.ts index 5f0fc87ae..5f8820153 100644 --- a/src/agent/profile/index.ts +++ b/src/agent/profile/index.ts @@ -4,7 +4,8 @@ * 管理 agent 的身份、人格、记忆等配置 */ -import type { AgentProfile, CreateProfileOptions, ProfileManagerOptions } from "./types.js"; +import type { AgentProfile, CreateProfileOptions, ProfileConfig, ProfileManagerOptions } from "./types.js"; +import type { ToolsConfig } from "../tools/policy.js"; import { DEFAULT_TEMPLATES } from "./templates.js"; import { ensureProfileDir, @@ -14,7 +15,7 @@ import { saveProfile, } from "./storage.js"; -export { type AgentProfile, type CreateProfileOptions, type ProfileManagerOptions } from "./types.js"; +export { type AgentProfile, type CreateProfileOptions, type ProfileConfig, type ProfileManagerOptions } from "./types.js"; export { DEFAULT_TEMPLATES } from "./templates.js"; export { getProfileDir, profileExists } from "./storage.js"; @@ -152,4 +153,16 @@ export class ProfileManager { return parts.join("\n\n"); } + + /** 获取 tools 配置 */ + getToolsConfig(): ToolsConfig | undefined { + const profile = this.getProfile(); + return profile?.config?.tools; + } + + /** 获取完整的 profile config */ + getProfileConfig(): ProfileConfig | undefined { + const profile = this.getProfile(); + return profile?.config; + } } diff --git a/src/agent/profile/storage.ts b/src/agent/profile/storage.ts index fa8493fe6..6507288a1 100644 --- a/src/agent/profile/storage.ts +++ b/src/agent/profile/storage.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { PROFILE_FILES, type AgentProfile } from "./types.js"; +import { PROFILE_FILES, type AgentProfile, type ProfileConfig } from "./types.js"; import { DATA_DIR } from "../../shared/index.js"; const DEFAULT_BASE_DIR = join(DATA_DIR, "agent-profiles"); @@ -60,6 +60,33 @@ export function writeProfileFile( writeFileSync(filePath, content, "utf-8"); } +/** 读取 config.json */ +export function readProfileConfig( + profileId: string, + options?: StorageOptions, +): ProfileConfig | undefined { + const content = readProfileFile(profileId, PROFILE_FILES.config, options); + if (!content) { + return undefined; + } + try { + return JSON.parse(content) as ProfileConfig; + } catch { + // Invalid JSON, return undefined + return undefined; + } +} + +/** 写入 config.json */ +export function writeProfileConfig( + profileId: string, + config: ProfileConfig, + options?: StorageOptions, +): void { + const content = JSON.stringify(config, null, 2); + writeProfileFile(profileId, PROFILE_FILES.config, content, options); +} + /** 加载完整的 AgentProfile */ export function loadProfile(profileId: string, options?: StorageOptions): AgentProfile { return { @@ -69,12 +96,13 @@ export function loadProfile(profileId: string, options?: StorageOptions): AgentP tools: readProfileFile(profileId, PROFILE_FILES.tools, options), memory: readProfileFile(profileId, PROFILE_FILES.memory, options), bootstrap: readProfileFile(profileId, PROFILE_FILES.bootstrap, options), + config: readProfileConfig(profileId, options), }; } /** 保存 AgentProfile(只写入非空字段) */ export function saveProfile(profile: AgentProfile, options?: StorageOptions): void { - const { id, soul, identity, tools, memory, bootstrap } = profile; + const { id, soul, identity, tools, memory, bootstrap, config } = profile; if (soul !== undefined) { writeProfileFile(id, PROFILE_FILES.soul, soul, options); @@ -91,4 +119,7 @@ export function saveProfile(profile: AgentProfile, options?: StorageOptions): vo if (bootstrap !== undefined) { writeProfileFile(id, PROFILE_FILES.bootstrap, bootstrap, options); } + if (config !== undefined) { + writeProfileConfig(id, config, options); + } } diff --git a/src/agent/profile/types.ts b/src/agent/profile/types.ts index d6bc0c37a..81db3cbca 100644 --- a/src/agent/profile/types.ts +++ b/src/agent/profile/types.ts @@ -2,6 +2,8 @@ * Agent Profile Type Definitions */ +import type { ToolsConfig } from "../tools/policy.js"; + /** Profile filename constants */ export const PROFILE_FILES = { soul: "soul.md", @@ -9,8 +11,21 @@ export const PROFILE_FILES = { tools: "tools.md", memory: "memory.md", bootstrap: "bootstrap.md", + config: "config.json", } as const; +/** Profile config.json structure */ +export interface ProfileConfig { + /** Tools policy configuration */ + tools?: ToolsConfig; + /** Default LLM provider */ + provider?: string; + /** Default model */ + model?: string; + /** Default thinking level */ + thinkingLevel?: string; +} + /** Agent Profile configuration */ export interface AgentProfile { /** Profile ID */ @@ -25,6 +40,8 @@ export interface AgentProfile { memory?: string | undefined; /** Initial context - guidance information for each conversation */ bootstrap?: string | undefined; + /** Profile configuration (from config.json) */ + config?: ProfileConfig | undefined; } /** Profile Manager options */ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index bcea33b36..5017b8cb7 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -11,6 +11,7 @@ import { DEFAULT_CONTEXT_TOKENS, type ContextWindowGuardResult, } from "./context-window/index.js"; +import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js"; /** * Get API Key based on provider. @@ -249,7 +250,21 @@ export class Agent { } this.agent.setModel(model); - this.agent.setTools(resolveTools(options)); + + // Merge Profile tools config with options.tools (options takes precedence) + const profileToolsConfig = this.profile?.getToolsConfig(); + const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools); + const toolsOptions = mergedToolsConfig ? { ...options, tools: mergedToolsConfig } : options; + + const tools = resolveTools(toolsOptions); + if (this.debug) { + if (profileToolsConfig) { + console.error(`[debug] Profile tools config: ${JSON.stringify(profileToolsConfig)}`); + } + console.error(`[debug] Merged tools config: ${JSON.stringify(mergedToolsConfig)}`); + console.error(`[debug] Resolved ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`); + } + this.agent.setTools(tools); const restoredMessages = this.session.loadMessages(); if (restoredMessages.length > 0) { diff --git a/src/agent/tools-cli.ts b/src/agent/tools-cli.ts new file mode 100644 index 000000000..7a88c531c --- /dev/null +++ b/src/agent/tools-cli.ts @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/** + * CLI tool to inspect and test tool policy configuration. + * + * Usage: + * pnpm tools:cli list # List all available tools + * pnpm tools:cli list --profile coding # List tools after applying profile + * pnpm tools:cli list --deny exec # List tools after denying exec + * pnpm tools:cli groups # Show all tool groups + * pnpm tools:cli profiles # Show all profiles + */ + +import { createAllTools } from "./tools.js"; +import { filterTools, type ToolsConfig } from "./tools/policy.js"; +import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./tools/groups.js"; + +type Command = "list" | "groups" | "profiles" | "help"; + +interface CliOptions { + command: Command; + profile?: string; + allow?: string[]; + deny?: string[]; + provider?: string; + isSubagent?: boolean; +} + +function printUsage() { + console.log("Usage: pnpm tools:cli [options]"); + console.log(""); + console.log("Commands:"); + console.log(" list List available tools (with optional filtering)"); + console.log(" groups Show all tool groups"); + console.log(" profiles Show all profiles"); + console.log(" help Show this help"); + console.log(""); + console.log("Options for 'list':"); + console.log(" --profile PROFILE Apply profile filter (minimal, coding, web, full)"); + console.log(" --allow TOOLS Allow specific tools (comma-separated)"); + console.log(" --deny TOOLS Deny specific tools (comma-separated)"); + console.log(" --provider NAME Apply provider-specific rules"); + console.log(" --subagent Apply subagent restrictions"); + console.log(""); + console.log("Examples:"); + console.log(" pnpm tools:cli list"); + console.log(" pnpm tools:cli list --profile coding"); + console.log(" pnpm tools:cli list --profile coding --deny exec"); + console.log(" pnpm tools:cli list --allow group:fs,web_fetch"); + console.log(" pnpm tools:cli groups"); +} + +function parseArgs(argv: string[]): CliOptions { + const args = [...argv]; + const command = (args.shift() || "help") as Command; + + const opts: CliOptions = { command }; + + while (args.length > 0) { + const arg = args.shift(); + if (!arg) break; + + if (arg === "--profile") { + const value = args.shift(); + if (value) opts.profile = value; + continue; + } + if (arg === "--allow") { + const value = args.shift(); + opts.allow = value?.split(",").map((s) => s.trim()) ?? []; + continue; + } + if (arg === "--deny") { + const value = args.shift(); + opts.deny = value?.split(",").map((s) => s.trim()) ?? []; + continue; + } + if (arg === "--provider") { + const value = args.shift(); + if (value) opts.provider = value; + continue; + } + if (arg === "--subagent") { + opts.isSubagent = true; + continue; + } + } + + return opts; +} + +function listTools(opts: CliOptions) { + const allTools = createAllTools(process.cwd()); + + console.log(`Total tools available: ${allTools.length}`); + console.log(""); + + // Build config + let config: ToolsConfig | undefined; + if (opts.profile || opts.allow || opts.deny) { + config = {}; + if (opts.profile) { + config.profile = opts.profile as any; + } + if (opts.allow) { + config.allow = opts.allow; + } + if (opts.deny) { + config.deny = opts.deny; + } + } + + const filterOpts: import("./tools/policy.js").FilterToolsOptions = {}; + if (config) { + filterOpts.config = config; + } + if (opts.provider) { + filterOpts.provider = opts.provider; + } + if (opts.isSubagent) { + filterOpts.isSubagent = opts.isSubagent; + } + + const filtered = filterTools(allTools, filterOpts); + + if (config || opts.provider || opts.isSubagent) { + console.log("Applied filters:"); + if (opts.profile) console.log(` Profile: ${opts.profile}`); + if (opts.allow) console.log(` Allow: ${opts.allow.join(", ")}`); + if (opts.deny) console.log(` Deny: ${opts.deny.join(", ")}`); + if (opts.provider) console.log(` Provider: ${opts.provider}`); + if (opts.isSubagent) console.log(` Subagent: true`); + console.log(""); + console.log(`Tools after filtering: ${filtered.length}`); + console.log(""); + } + + console.log("Tools:"); + for (const tool of filtered) { + const desc = tool.description?.slice(0, 60) || ""; + console.log(` ${tool.name.padEnd(15)} ${desc}${desc.length >= 60 ? "..." : ""}`); + } + + if (filtered.length < allTools.length) { + const removed = allTools.filter((t) => !filtered.find((f) => f.name === t.name)); + console.log(""); + console.log(`Filtered out (${removed.length}):`); + for (const tool of removed) { + console.log(` ${tool.name}`); + } + } +} + +function showGroups() { + console.log("Tool Groups:"); + console.log(""); + for (const [name, tools] of Object.entries(TOOL_GROUPS)) { + console.log(` ${name}:`); + console.log(` ${tools.join(", ")}`); + console.log(""); + } +} + +function showProfiles() { + console.log("Tool Profiles:"); + console.log(""); + for (const [name, policy] of Object.entries(TOOL_PROFILES)) { + console.log(` ${name}:`); + if (policy.allow) { + const expanded = expandToolGroups(policy.allow); + console.log(` Allow: ${policy.allow.join(", ")}`); + console.log(` Expands to: ${expanded.join(", ")}`); + } else { + console.log(` Allow: (all tools)`); + } + if (policy.deny) { + console.log(` Deny: ${policy.deny.join(", ")}`); + } + console.log(""); + } +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + + switch (opts.command) { + case "list": + listTools(opts); + break; + case "groups": + showGroups(); + break; + case "profiles": + showProfiles(); + break; + case "help": + default: + printUsage(); + break; + } +} + +main().catch((err) => { + console.error(err?.stack || String(err)); + process.exit(1); +}); diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 594f1cc11..6578a238d 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -1,11 +1,13 @@ import type { AgentOptions } from "./types.js"; -import { getModel, type KnownProvider } from "@mariozechner/pi-ai"; +import { getModel } from "@mariozechner/pi-ai"; import { createCodingTools } from "@mariozechner/pi-coding-agent"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; +import { createMemoryTools } from "./tools/memory/index.js"; +import { filterTools } from "./tools/policy.js"; export function resolveModel(options: AgentOptions) { if (options.provider && options.model) { @@ -18,15 +20,35 @@ export function resolveModel(options: AgentOptions) { return getModel("kimi-coding", "kimi-k2-thinking"); } -export function resolveTools(options: AgentOptions): AgentTool[] { - const cwd = options.cwd ?? process.cwd(); - const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash") as AgentTool[]; +/** Options for creating tools */ +export interface CreateToolsOptions { + cwd: string; + /** Profile ID for memory tools (optional) */ + profileId?: string; + /** Base directory for profiles (optional) */ + profileBaseDir?: string; +} + +/** + * Create all available tools. + * This returns the full set before policy filtering. + */ +export function createAllTools(options: CreateToolsOptions | string): AgentTool[] { + // Support legacy string argument for backwards compatibility + const opts: CreateToolsOptions = typeof options === "string" ? { cwd: options } : options; + const { cwd, profileId, profileBaseDir } = opts; + + const baseTools = createCodingTools(cwd).filter( + (tool) => tool.name !== "bash", + ) as AgentTool[]; + const execTool = createExecTool(cwd); const processTool = createProcessTool(cwd); const globTool = createGlobTool(cwd); const webFetchTool = createWebFetchTool(); const webSearchTool = createWebSearchTool(); - return [ + + const tools: AgentTool[] = [ ...baseTools, execTool as AgentTool, processTool as AgentTool, @@ -34,4 +56,64 @@ export function resolveTools(options: AgentOptions): AgentTool[] { webFetchTool as AgentTool, webSearchTool as AgentTool, ]; + + // Add memory tools if profileId is provided + if (profileId) { + const memoryTools = createMemoryTools({ + profileId, + baseDir: profileBaseDir, + }); + tools.push(...memoryTools); + } + + return tools; +} + +/** + * Resolve tools for an agent with policy filtering. + * + * Applies 4-layer filtering: + * 1. Profile (minimal/coding/web/full) + * 2. Global allow/deny + * 3. Provider-specific rules + * 4. Subagent restrictions + */ +export function resolveTools(options: AgentOptions): AgentTool[] { + const cwd = options.cwd ?? process.cwd(); + + // Create all tools (including memory tools if profileId is provided) + const allTools = createAllTools({ + cwd, + profileId: options.profileId, + profileBaseDir: options.profileBaseDir, + }); + + // Apply policy filtering + const filtered = filterTools(allTools, { + config: options.tools, + provider: options.provider, + isSubagent: options.isSubagent, + }); + + return filtered; +} + +/** + * Get all available tool names (for debugging/listing). + * Note: Memory tools require profileId, so they are not included by default. + */ +export function getAllToolNames(cwd?: string): string[] { + const tools = createAllTools({ cwd: cwd ?? process.cwd() }); + return tools.map((t) => t.name); +} + +/** + * Get all available tool names including memory tools (for debugging/listing). + */ +export function getAllToolNamesWithMemory(cwd?: string, profileId?: string): string[] { + const tools = createAllTools({ + cwd: cwd ?? process.cwd(), + profileId: profileId ?? "test-profile", + }); + return tools.map((t) => t.name); } diff --git a/src/agent/tools/README.md b/src/agent/tools/README.md new file mode 100644 index 000000000..7a3c548f1 --- /dev/null +++ b/src/agent/tools/README.md @@ -0,0 +1,330 @@ +# Tools System + +[中文文档](./README.zh-CN.md) + +The tools system provides LLM agents with capabilities to interact with the external world. Tools are the "hands and feet" of an agent - without tools, an LLM can only generate text responses. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Tool Definition │ +│ (AgentTool from @mariozechner/pi-agent-core) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ name │ │ description │ │ parameters │ │ +│ │ label │ │ execute │ │ (TypeBox) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4-Layer Policy Filter │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Layer 1: Profile │ │ +│ │ Base tool set: minimal | coding | web | full │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Layer 2: Global Allow/Deny │ │ +│ │ User customization via CLI or config │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Layer 3: Provider-Specific │ │ +│ │ Different rules for different LLM providers │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Layer 4: Subagent Restrictions │ │ +│ │ Limited tools for spawned child agents │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Filtered Tools │ +│ (passed to pi-agent-core) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Available Tools + +| Tool | Name | Description | +| ------------- | --------------- | --------------------------------------------- | +| Read | `read` | Read file contents | +| Write | `write` | Write content to files | +| Edit | `edit` | Edit existing files | +| Glob | `glob` | Find files by pattern | +| Exec | `exec` | Execute shell commands | +| Process | `process` | Manage long-running processes | +| Web Fetch | `web_fetch` | Fetch and extract content from URLs | +| Web Search | `web_search` | Search the web (requires API key) | +| Memory Get | `memory_get` | Retrieve a value from persistent memory | +| Memory Set | `memory_set` | Store a value in persistent memory | +| Memory Delete | `memory_delete` | Delete a value from persistent memory | +| Memory List | `memory_list` | List all keys in persistent memory | + +> **Note**: Memory tools require a `profileId` to be specified. They store data in the profile's memory directory. + +## Tool Groups + +Groups provide shortcuts for allowing/denying multiple tools at once: + +| Group | Tools | +| --------------- | ------------------------------------------------- | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:memory` | memory_get, memory_set, memory_delete, memory_list| +| `group:core` | All of the above (excluding memory) | + +## Tool Profiles + +Profiles are predefined tool sets for common use cases: + +| Profile | Description | Tools | +| --------- | ----------------------- | ---------------------------------- | +| `minimal` | No tools (chat-only) | None | +| `coding` | File system + execution | group:fs, group:runtime | +| `web` | Coding + web access | group:fs, group:runtime, group:web | +| `full` | No restrictions | All tools | + +## Usage + +### CLI Usage + +```bash +# Use a specific profile +pnpm agent:cli --tools-profile coding "list files" + +# Minimal profile with specific tools allowed +pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls" + +# Deny specific tools +pnpm agent:cli --tools-deny exec,process "read file.txt" + +# Use tool groups +pnpm agent:cli --tools-allow group:fs "read config.json" +``` + +### Programmatic Usage + +```typescript +import { Agent } from './runner.js'; + +const agent = new Agent({ + tools: { + // Layer 1: Base profile + profile: 'coding', + + // Layer 2: Global customization + allow: ['web_fetch'], // Add web_fetch to coding profile + deny: ['exec'], // But deny exec + + // Layer 3: Provider-specific rules + byProvider: { + google: { + deny: ['exec', 'process'], // Google models can't use runtime tools + }, + }, + }, + + // Layer 4: Subagent mode + isSubagent: false, +}); +``` + +### Inspecting Tool Configuration + +Use the tools CLI to inspect and test configurations: + +```bash +# List all available tools +pnpm tools:cli list + +# List tools after applying a profile +pnpm tools:cli list --profile coding + +# List tools with deny rules +pnpm tools:cli list --profile coding --deny exec + +# Show all tool groups +pnpm tools:cli groups + +# Show all profiles +pnpm tools:cli profiles +``` + +## Policy System Details + +### Layer 1: Profile + +The profile determines the base set of available tools. If not specified, all tools are available. + +```typescript +// In groups.ts +export const TOOL_PROFILES = { + minimal: { allow: [] }, // No tools + coding: { allow: ['group:fs', 'group:runtime'] }, // FS + execution + web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + web + full: {}, // No restrictions +}; +``` + +### Layer 2: Global Allow/Deny + +User-specified allow/deny lists that modify the profile's tool set: + +- `allow`: Only these tools are available (additive to profile) +- `deny`: These tools are blocked (takes precedence over allow) + +### Layer 3: Provider-Specific + +Different LLM providers may have different capabilities or restrictions: + +```typescript +{ + byProvider: { + google: { deny: ["exec"] }, // Gemini can't execute commands + anthropic: { allow: ["*"] }, // Claude has full access + } +} +``` + +### Layer 4: Subagent Restrictions + +When `isSubagent: true`, additional restrictions are applied to prevent spawned agents from accessing sensitive tools like session management. + +## Adding New Tools + +1. Create a new file in `src/agent/tools/` (e.g., `my-tool.ts`) + +2. Define the tool using TypeBox for the schema: + +```typescript +import { Type } from '@sinclair/typebox'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; + +const MyToolSchema = Type.Object({ + param1: Type.String({ description: 'Parameter description' }), + param2: Type.Optional(Type.Number()), +}); + +export function createMyTool(): AgentTool { + return { + name: 'my_tool', + label: 'My Tool', + description: 'What this tool does', + parameters: MyToolSchema, + execute: async (toolCallId, args) => { + // Implementation + return { result: 'success' }; + }, + }; +} +``` + +3. Register the tool in `src/agent/tools.ts`: + +```typescript +import { createMyTool } from './tools/my-tool.js'; + +export function createAllTools(cwd: string): AgentTool[] { + // ... existing tools + const myTool = createMyTool(); + + return [ + ...baseTools, + myTool as AgentTool, + // ... + ]; +} +``` + +4. Add the tool to appropriate groups in `groups.ts`: + +```typescript +export const TOOL_GROUPS: Record = { + 'group:my_category': ['my_tool', 'other_tool'], + // ... +}; +``` + +## Testing + +Run the policy system tests: + +```bash +npx tsx src/agent/tools/policy.test.ts +``` + +## Agent Profile Integration + +Tools configuration can be defined in Agent Profile's `config.json`, allowing different agents to have different tool capabilities: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Super Multica Hub │ +│ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ Agent A │ │ Agent B │ │ Agent C │ │ +│ │ Profile: │ │ Profile: │ │ Profile: │ │ +│ │ coder │ │ reviewer │ │ devops │ │ +│ │ │ │ │ │ │ │ +│ │ tools: │ │ tools: │ │ tools: │ │ +│ │ coding │ │ minimal │ │ full │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +└─────────┼────────────────┼────────────────┼─────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Client │ │ Client │ │ Client │ + └──────────┘ └──────────┘ └──────────┘ +``` + +Each Agent's Profile can define its own tools configuration in `config.json`: + +```json +{ + "tools": { + "profile": "coding", + "deny": ["exec"] + }, + "provider": "anthropic", + "model": "claude-sonnet-4-20250514" +} +``` + +See [Profile README](../profile/README.md) for full documentation. + +### Config Priority + +When both Profile config and CLI options are provided: + +1. **Profile `config.json`** - Base configuration +2. **CLI options** - Override/extend profile settings + +```bash +# Profile has tools.profile = "coding" +# CLI adds --tools-deny exec +# Result: coding profile without exec tool +pnpm agent:cli --profile my-agent --tools-deny exec "list files" +``` + +## Future Tools + +The following tools are planned for future implementation: + +- **Browser** - Simplified web automation (screenshot, click, type) +- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` +- **Image** - Image generation and manipulation +- **Cron** - Scheduled task execution +- **Message** - Inter-agent communication +- **Canvas** - Visual output generation diff --git a/src/agent/tools/README.zh-CN.md b/src/agent/tools/README.zh-CN.md new file mode 100644 index 000000000..dea6c2ce8 --- /dev/null +++ b/src/agent/tools/README.zh-CN.md @@ -0,0 +1,330 @@ +# 工具系统 + +[English](./README.md) + +工具系统为 LLM Agent 提供与外部世界交互的能力。工具是 Agent 的"手和脚"——没有工具,LLM 只能生成文本响应。 + +## 架构概览 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 工具定义 │ +│ (AgentTool from @mariozechner/pi-agent-core) │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ name │ │ description │ │ parameters │ │ +│ │ label │ │ execute │ │ (TypeBox) │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4 层策略过滤器 │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 第 1 层: Profile │ │ +│ │ 基础工具集: minimal | coding | web | full │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 第 2 层: 全局 Allow/Deny │ │ +│ │ 通过 CLI 或配置文件进行用户自定义 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 第 3 层: Provider 特定规则 │ │ +│ │ 不同 LLM Provider 有不同的规则 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 第 4 层: Subagent 限制 │ │ +│ │ 子 Agent 的工具访问受限 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 过滤后的工具 │ +│ (传递给 pi-agent-core) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 可用工具 + +| 工具 | 名称 | 描述 | +| ------------- | --------------- | --------------------------------------------- | +| Read | `read` | 读取文件内容 | +| Write | `write` | 写入文件内容 | +| Edit | `edit` | 编辑现有文件 | +| Glob | `glob` | 按模式查找文件 | +| Exec | `exec` | 执行 Shell 命令 | +| Process | `process` | 管理长时间运行的进程 | +| Web Fetch | `web_fetch` | 从 URL 获取并提取内容 | +| Web Search | `web_search` | 搜索网络(需要 API Key) | +| Memory Get | `memory_get` | 从持久化内存中获取值 | +| Memory Set | `memory_set` | 向持久化内存中存储值 | +| Memory Delete | `memory_delete` | 从持久化内存中删除值 | +| Memory List | `memory_list` | 列出持久化内存中的所有键 | + +> **注意**: Memory 工具需要指定 `profileId`。数据存储在 Profile 的 memory 目录中。 + +## 工具组 + +工具组提供了一次性允许/禁止多个工具的快捷方式: + +| 组 | 工具 | +| --------------- | ------------------------------------------------- | +| `group:fs` | read, write, edit, glob | +| `group:runtime` | exec, process | +| `group:web` | web_search, web_fetch | +| `group:memory` | memory_get, memory_set, memory_delete, memory_list| +| `group:core` | 以上所有(不包括 memory) | + +## 工具配置文件 + +配置文件是为常见用例预定义的工具集: + +| Profile | 描述 | 工具 | +| --------- | ------------------- | ---------------------------------- | +| `minimal` | 无工具(仅聊天) | 无 | +| `coding` | 文件系统 + 执行 | group:fs, group:runtime | +| `web` | 编码 + 网络访问 | group:fs, group:runtime, group:web | +| `full` | 无限制 | 所有工具 | + +## 使用方法 + +### CLI 使用 + +```bash +# 使用特定配置文件 +pnpm agent:cli --tools-profile coding "list files" + +# 最小配置文件 + 允许特定工具 +pnpm agent:cli --tools-profile minimal --tools-allow exec "run ls" + +# 禁止特定工具 +pnpm agent:cli --tools-deny exec,process "read file.txt" + +# 使用工具组 +pnpm agent:cli --tools-allow group:fs "read config.json" +``` + +### 编程使用 + +```typescript +import { Agent } from './runner.js'; + +const agent = new Agent({ + tools: { + // 第 1 层: 基础配置文件 + profile: 'coding', + + // 第 2 层: 全局自定义 + allow: ['web_fetch'], // 在 coding 配置文件基础上添加 web_fetch + deny: ['exec'], // 但禁止 exec + + // 第 3 层: Provider 特定规则 + byProvider: { + google: { + deny: ['exec', 'process'], // Google 模型不能使用运行时工具 + }, + }, + }, + + // 第 4 层: Subagent 模式 + isSubagent: false, +}); +``` + +### 检查工具配置 + +使用 tools CLI 检查和测试配置: + +```bash +# 列出所有可用工具 +pnpm tools:cli list + +# 列出应用配置文件后的工具 +pnpm tools:cli list --profile coding + +# 列出带有禁止规则的工具 +pnpm tools:cli list --profile coding --deny exec + +# 显示所有工具组 +pnpm tools:cli groups + +# 显示所有配置文件 +pnpm tools:cli profiles +``` + +## 策略系统详情 + +### 第 1 层: Profile + +配置文件决定了可用工具的基础集合。如果未指定,则所有工具都可用。 + +```typescript +// 在 groups.ts 中 +export const TOOL_PROFILES = { + minimal: { allow: [] }, // 无工具 + coding: { allow: ['group:fs', 'group:runtime'] }, // 文件系统 + 执行 + web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + 网络 + full: {}, // 无限制 +}; +``` + +### 第 2 层: 全局 Allow/Deny + +用户指定的 allow/deny 列表,用于修改配置文件的工具集: + +- `allow`: 只有这些工具可用(在配置文件基础上添加) +- `deny`: 这些工具被阻止(优先于 allow) + +### 第 3 层: Provider 特定规则 + +不同的 LLM Provider 可能有不同的能力或限制: + +```typescript +{ + byProvider: { + google: { deny: ["exec"] }, // Gemini 不能执行命令 + anthropic: { allow: ["*"] }, // Claude 有完全访问权限 + } +} +``` + +### 第 4 层: Subagent 限制 + +当 `isSubagent: true` 时,会应用额外的限制,防止子 Agent 访问敏感工具(如会话管理)。 + +## 添加新工具 + +1. 在 `src/agent/tools/` 中创建新文件(例如 `my-tool.ts`) + +2. 使用 TypeBox 定义工具的 Schema: + +```typescript +import { Type } from '@sinclair/typebox'; +import type { AgentTool } from '@mariozechner/pi-agent-core'; + +const MyToolSchema = Type.Object({ + param1: Type.String({ description: '参数描述' }), + param2: Type.Optional(Type.Number()), +}); + +export function createMyTool(): AgentTool { + return { + name: 'my_tool', + label: 'My Tool', + description: '这个工具做什么', + parameters: MyToolSchema, + execute: async (toolCallId, args) => { + // 实现 + return { result: 'success' }; + }, + }; +} +``` + +3. 在 `src/agent/tools.ts` 中注册工具: + +```typescript +import { createMyTool } from './tools/my-tool.js'; + +export function createAllTools(cwd: string): AgentTool[] { + // ... 现有工具 + const myTool = createMyTool(); + + return [ + ...baseTools, + myTool as AgentTool, + // ... + ]; +} +``` + +4. 在 `groups.ts` 中将工具添加到适当的组: + +```typescript +export const TOOL_GROUPS: Record = { + 'group:my_category': ['my_tool', 'other_tool'], + // ... +}; +``` + +## 测试 + +运行策略系统测试: + +```bash +npx tsx src/agent/tools/policy.test.ts +``` + +## Agent Profile 集成 + +工具配置可以在 Agent Profile 的 `config.json` 中定义,允许不同的 Agent 拥有不同的工具能力: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Super Multica Hub │ +│ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ Agent A │ │ Agent B │ │ Agent C │ │ +│ │ Profile: │ │ Profile: │ │ Profile: │ │ +│ │ coder │ │ reviewer │ │ devops │ │ +│ │ │ │ │ │ │ │ +│ │ tools: │ │ tools: │ │ tools: │ │ +│ │ coding │ │ minimal │ │ full │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +└─────────┼────────────────┼────────────────┼─────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ Client │ │ Client │ │ Client │ + └──────────┘ └──────────┘ └──────────┘ +``` + +每个 Agent 的 Profile 可以在 `config.json` 中定义自己的工具配置: + +```json +{ + "tools": { + "profile": "coding", + "deny": ["exec"] + }, + "provider": "anthropic", + "model": "claude-sonnet-4-20250514" +} +``` + +详见 [Profile README](../profile/README.md)。 + +### 配置优先级 + +当同时提供 Profile 配置和 CLI 选项时: + +1. **Profile `config.json`** - 基础配置 +2. **CLI 选项** - 覆盖/扩展 Profile 设置 + +```bash +# Profile 有 tools.profile = "coding" +# CLI 添加 --tools-deny exec +# 结果: coding 配置文件但没有 exec 工具 +pnpm agent:cli --profile my-agent --tools-deny exec "list files" +``` + +## 未来工具 + +以下工具计划在未来实现: + +- **Browser** - 简化的网页自动化(截图、点击、输入) +- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` +- **Image** - 图像生成和处理 +- **Cron** - 定时任务执行 +- **Message** - Agent 间通信 +- **Canvas** - 可视化输出生成 diff --git a/src/agent/tools/glob.test.ts b/src/agent/tools/glob.test.ts index 83bd7f672..4bf6fc493 100644 --- a/src/agent/tools/glob.test.ts +++ b/src/agent/tools/glob.test.ts @@ -161,7 +161,12 @@ describe("glob", () => { expect(result.details.count).toBe(0); expect(result.details.files).toHaveLength(0); - expect(result.content[0].text).toContain("No files found"); + const content = result.content[0]; + expect(content).toBeDefined(); + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toContain("No files found"); + } }); it("should sort files by modification time (most recent first)", async () => { diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts new file mode 100644 index 000000000..a886b22b0 --- /dev/null +++ b/src/agent/tools/groups.ts @@ -0,0 +1,145 @@ +/** + * Tool groups and profiles for policy-based filtering. + * + * Groups provide shortcuts for allowing/denying multiple tools at once. + * Profiles are predefined tool sets for common use cases. + */ + +export type ToolProfileId = "minimal" | "coding" | "web" | "full"; + +/** + * Tool name aliases for compatibility. + * Maps alternative names to canonical tool names. + */ +export const TOOL_NAME_ALIASES: Record = { + bash: "exec", + shell: "exec", + search: "web_search", + fetch: "web_fetch", +}; + +/** + * Tool groups - shortcuts for multiple tools. + * Use "group:name" in allow/deny lists. + */ +export const TOOL_GROUPS: Record = { + // File system operations + "group:fs": ["read", "write", "edit", "glob"], + + // Runtime/execution tools + "group:runtime": ["exec", "process"], + + // Web tools + "group:web": ["web_search", "web_fetch"], + + // Memory tools (requires profileId) + "group:memory": ["memory_get", "memory_set", "memory_delete", "memory_list"], + + // All core tools + "group:core": [ + "read", + "write", + "edit", + "glob", + "exec", + "process", + "web_search", + "web_fetch", + ], +}; + +/** + * Tool profiles - predefined tool sets. + */ +export const TOOL_PROFILES: Record = { + // Minimal: no tools (useful for chat-only agents) + minimal: { + allow: [], + }, + + // Coding: file system + execution (default for coding tasks) + coding: { + allow: ["group:fs", "group:runtime"], + }, + + // Web: coding + web access + web: { + allow: ["group:fs", "group:runtime", "group:web"], + }, + + // Full: no restrictions + full: {}, +}; + +/** + * Default tools denied for subagents. + * Subagents should not have access to session management or system tools. + */ +export const DEFAULT_SUBAGENT_TOOL_DENY: string[] = [ + // Future: session management tools + // "sessions_list", + // "sessions_history", + // "sessions_send", + // "sessions_spawn", + // "session_status", + + // Future: system tools + // "gateway", + // "agents_list", +]; + +/** + * Normalize a tool name to its canonical form. + */ +export function normalizeToolName(name: string): string { + const normalized = name.trim().toLowerCase(); + return TOOL_NAME_ALIASES[normalized] ?? normalized; +} + +/** + * Normalize a list of tool names. + */ +export function normalizeToolList(list?: string[]): string[] { + if (!list) return []; + return list.map(normalizeToolName).filter(Boolean); +} + +/** + * Expand group references in a tool list. + * "group:fs" -> ["read", "write", "edit", "glob"] + */ +export function expandToolGroups(list?: string[]): string[] { + const normalized = normalizeToolList(list); + const expanded: string[] = []; + + for (const value of normalized) { + const group = TOOL_GROUPS[value]; + if (group) { + expanded.push(...group); + continue; + } + expanded.push(value); + } + + return Array.from(new Set(expanded)); +} + +/** + * Get the policy for a profile. + */ +export function getProfilePolicy( + profile?: ToolProfileId, +): { allow?: string[]; deny?: string[] } | undefined { + if (!profile) return undefined; + const resolved = TOOL_PROFILES[profile]; + if (!resolved) return undefined; + if (!resolved.allow && !resolved.deny) return undefined; + const result: { allow?: string[]; deny?: string[] } = {}; + if (resolved.allow) { + result.allow = [...resolved.allow]; + } + if (resolved.deny) { + result.deny = [...resolved.deny]; + } + return result; +} diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts new file mode 100644 index 000000000..1e6f63340 --- /dev/null +++ b/src/agent/tools/index.ts @@ -0,0 +1,34 @@ +/** + * Tools module - provides tool creation and policy-based filtering. + */ + +// Tool implementations +export { createExecTool } from "./exec.js"; +export { createProcessTool } from "./process.js"; +export { createGlobTool } from "./glob.js"; +export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; + +// Tool groups and profiles +export { + type ToolProfileId, + TOOL_NAME_ALIASES, + TOOL_GROUPS, + TOOL_PROFILES, + DEFAULT_SUBAGENT_TOOL_DENY, + normalizeToolName, + normalizeToolList, + expandToolGroups, + getProfilePolicy, +} from "./groups.js"; + +// Tool policy system +export { + type ToolPolicy, + type ToolsConfig, + type FilterToolsOptions, + isToolAllowed, + filterToolsByPolicy, + filterTools, + getSubagentPolicy, + wouldToolBeAllowed, +} from "./policy.js"; diff --git a/src/agent/tools/memory/index.ts b/src/agent/tools/memory/index.ts new file mode 100644 index 000000000..f2d14e6eb --- /dev/null +++ b/src/agent/tools/memory/index.ts @@ -0,0 +1,6 @@ +/** + * Memory Tools Module + */ + +export { createMemoryTools } from "./memory-tools.js"; +export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js"; diff --git a/src/agent/tools/memory/memory-tools.ts b/src/agent/tools/memory/memory-tools.ts new file mode 100644 index 000000000..19fe58049 --- /dev/null +++ b/src/agent/tools/memory/memory-tools.ts @@ -0,0 +1,175 @@ +/** + * Memory Tools + * + * Provides persistent key-value storage for agents. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { memoryDelete, memoryGet, memoryList, memorySet, validateKey } from "./storage.js"; +import type { MemoryStorageOptions } from "./types.js"; + +// ============================================================================ +// Schemas +// ============================================================================ + +const MemoryGetSchema = Type.Object({ + key: Type.String({ description: "The key to retrieve" }), +}); + +const MemorySetSchema = Type.Object({ + key: Type.String({ description: "The key to set (alphanumeric, underscore, dot, hyphen)" }), + value: Type.Unknown({ description: "The value to store (will be JSON serialized)" }), + description: Type.Optional( + Type.String({ description: "Optional description of this memory entry" }), + ), +}); + +const MemoryDeleteSchema = Type.Object({ + key: Type.String({ description: "The key to delete" }), +}); + +const MemoryListSchema = Type.Object({ + prefix: Type.Optional(Type.String({ description: "Filter keys by prefix" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of keys to return (default 100)" })), +}); + +// ============================================================================ +// Helper +// ============================================================================ + +function jsonResult(data: T): { + content: Array<{ type: "text"; text: string }>; + details: T; +} { + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + details: data, + }; +} + +// ============================================================================ +// Tools +// ============================================================================ + +export function createMemoryGetTool( + options: MemoryStorageOptions, +): AgentTool { + return { + name: "memory_get", + label: "Memory Get", + description: "Retrieve a value from persistent memory by key.", + parameters: MemoryGetSchema, + execute: async (_toolCallId, params) => { + const key = typeof params.key === "string" ? params.key.trim() : ""; + + const validation = validateKey(key); + if (!validation.valid) { + return jsonResult({ found: false, error: validation.error }); + } + + const result = memoryGet(key, options); + if (!result.found) { + return jsonResult({ found: false, key }); + } + + return jsonResult({ + found: true, + key, + value: result.entry.value, + description: result.entry.description, + updatedAt: result.entry.updatedAt, + }); + }, + }; +} + +export function createMemorySetTool( + options: MemoryStorageOptions, +): AgentTool { + return { + name: "memory_set", + label: "Memory Set", + description: + "Store a value in persistent memory. The value will be JSON serialized. " + + "Keys can contain letters, numbers, underscores, dots, and hyphens.", + parameters: MemorySetSchema, + execute: async (_toolCallId, params) => { + const key = typeof params.key === "string" ? params.key.trim() : ""; + const value = params.value; + const description = typeof params.description === "string" ? params.description : undefined; + + const result = memorySet(key, value, description, options); + if (!result.success) { + return jsonResult({ success: false, error: result.error }); + } + + return jsonResult({ success: true, key }); + }, + }; +} + +export function createMemoryDeleteTool( + options: MemoryStorageOptions, +): AgentTool { + return { + name: "memory_delete", + label: "Memory Delete", + description: "Delete a value from persistent memory by key.", + parameters: MemoryDeleteSchema, + execute: async (_toolCallId, params) => { + const key = typeof params.key === "string" ? params.key.trim() : ""; + + const validation = validateKey(key); + if (!validation.valid) { + return jsonResult({ success: false, error: validation.error }); + } + + const result = memoryDelete(key, options); + if (!result.success) { + return jsonResult({ success: false, error: result.error }); + } + + return jsonResult({ success: true, key, existed: result.existed }); + }, + }; +} + +export function createMemoryListTool( + options: MemoryStorageOptions, +): AgentTool { + return { + name: "memory_list", + label: "Memory List", + description: + "List all keys in persistent memory, sorted by most recently updated. " + + "Optionally filter by prefix.", + parameters: MemoryListSchema, + execute: async (_toolCallId, params) => { + const prefix = typeof params.prefix === "string" ? params.prefix : undefined; + const limit = typeof params.limit === "number" ? params.limit : undefined; + + const result = memoryList(prefix, limit, options); + + return jsonResult({ + keys: result.keys, + total: result.total, + truncated: result.truncated, + }); + }, + }; +} + +/** + * Create all memory tools for a profile + */ +export function createMemoryTools( + options: MemoryStorageOptions, +): Array> { + return [ + createMemoryGetTool(options), + createMemorySetTool(options), + createMemoryDeleteTool(options), + createMemoryListTool(options), + ]; +} diff --git a/src/agent/tools/memory/storage.test.ts b/src/agent/tools/memory/storage.test.ts new file mode 100644 index 000000000..c67592aa9 --- /dev/null +++ b/src/agent/tools/memory/storage.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + validateKey, + memoryGet, + memorySet, + memoryDelete, + memoryList, + getMemoryDir, +} from "./storage.js"; +import type { MemoryStorageOptions } from "./types.js"; + +describe("memory storage", () => { + const testBaseDir = join(tmpdir(), `multica-memory-test-${Date.now()}`); + const profileId = "test-profile"; + + const options: MemoryStorageOptions = { + profileId, + baseDir: testBaseDir, + }; + + beforeEach(() => { + if (existsSync(testBaseDir)) { + rmSync(testBaseDir, { recursive: true }); + } + mkdirSync(testBaseDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testBaseDir)) { + rmSync(testBaseDir, { recursive: true }); + } + }); + + describe("validateKey", () => { + it("should accept valid alphanumeric keys", () => { + expect(validateKey("mykey")).toEqual({ valid: true }); + expect(validateKey("my_key")).toEqual({ valid: true }); + expect(validateKey("my-key")).toEqual({ valid: true }); + expect(validateKey("my.key")).toEqual({ valid: true }); + expect(validateKey("MyKey123")).toEqual({ valid: true }); + }); + + it("should reject empty keys", () => { + expect(validateKey("")).toMatchObject({ valid: false, error: "Key is required" }); + expect(validateKey(" ")).toMatchObject({ valid: false, error: "Key cannot be empty" }); + }); + + it("should reject keys with invalid characters", () => { + const result = validateKey("my key"); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain("can only contain"); + } + }); + + it("should reject keys that are too long", () => { + const longKey = "a".repeat(129); + const result = validateKey(longKey); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain("exceeds maximum length"); + } + }); + }); + + describe("memorySet and memoryGet", () => { + it("should set and get a string value", () => { + const result = memorySet("test-key", "test-value", undefined, options); + expect(result).toEqual({ success: true }); + + const getResult = memoryGet("test-key", options); + expect(getResult.found).toBe(true); + if (getResult.found) { + expect(getResult.entry.value).toBe("test-value"); + } + }); + + it("should set and get a complex object", () => { + const value = { name: "test", count: 42, nested: { a: 1 } }; + memorySet("complex-key", value, "A complex object", options); + + const getResult = memoryGet("complex-key", options); + expect(getResult.found).toBe(true); + if (getResult.found) { + expect(getResult.entry.value).toEqual(value); + expect(getResult.entry.description).toBe("A complex object"); + } + }); + + it("should update existing key and preserve createdAt", async () => { + memorySet("update-key", "initial", undefined, options); + const firstGet = memoryGet("update-key", options); + expect(firstGet.found).toBe(true); + + // Wait a bit to ensure different timestamp + await new Promise((resolve) => setTimeout(resolve, 10)); + + memorySet("update-key", "updated", undefined, options); + const secondGet = memoryGet("update-key", options); + + expect(secondGet.found).toBe(true); + if (firstGet.found && secondGet.found) { + expect(secondGet.entry.value).toBe("updated"); + expect(secondGet.entry.createdAt).toBe(firstGet.entry.createdAt); + expect(secondGet.entry.updatedAt).toBeGreaterThan(firstGet.entry.createdAt); + } + }); + + it("should return not found for non-existent key", () => { + const result = memoryGet("non-existent", options); + expect(result.found).toBe(false); + }); + + it("should handle keys with dots", () => { + memorySet("user.settings.theme", "dark", undefined, options); + + const result = memoryGet("user.settings.theme", options); + expect(result.found).toBe(true); + if (result.found) { + expect(result.entry.value).toBe("dark"); + } + }); + + it("should reject value that is too large", () => { + const largeValue = "x".repeat(1024 * 1024 + 1); + const result = memorySet("large-key", largeValue, undefined, options); + expect(result).toMatchObject({ success: false }); + if (!result.success) { + expect(result.error).toContain("exceeds maximum size"); + } + }); + }); + + describe("memoryDelete", () => { + it("should delete existing key", () => { + memorySet("delete-me", "value", undefined, options); + expect(memoryGet("delete-me", options).found).toBe(true); + + const result = memoryDelete("delete-me", options); + expect(result).toEqual({ success: true, existed: true }); + + expect(memoryGet("delete-me", options).found).toBe(false); + }); + + it("should handle deleting non-existent key", () => { + const result = memoryDelete("non-existent", options); + expect(result).toEqual({ success: true, existed: false }); + }); + + it("should reject invalid key", () => { + const result = memoryDelete("invalid key", options); + expect(result.success).toBe(false); + }); + }); + + describe("memoryList", () => { + beforeEach(() => { + // Create some test keys + memorySet("project.config", { name: "test" }, "Project config", options); + memorySet("project.settings", { theme: "dark" }, "Settings", options); + memorySet("user.name", "Alice", "User name", options); + }); + + it("should list all keys", () => { + const result = memoryList(undefined, undefined, options); + + expect(result.total).toBe(3); + expect(result.truncated).toBe(false); + expect(result.keys.map((k) => k.key)).toContain("project.config"); + expect(result.keys.map((k) => k.key)).toContain("project.settings"); + expect(result.keys.map((k) => k.key)).toContain("user.name"); + }); + + it("should filter by prefix", () => { + const result = memoryList("project", undefined, options); + + expect(result.total).toBe(2); + expect(result.keys.map((k) => k.key)).toContain("project.config"); + expect(result.keys.map((k) => k.key)).toContain("project.settings"); + expect(result.keys.map((k) => k.key)).not.toContain("user.name"); + }); + + it("should respect limit", () => { + const result = memoryList(undefined, 2, options); + + expect(result.keys.length).toBe(2); + expect(result.total).toBe(3); + expect(result.truncated).toBe(true); + }); + + it("should sort by updatedAt descending", async () => { + // Wait and update one key + await new Promise((resolve) => setTimeout(resolve, 10)); + memorySet("project.config", { name: "updated" }, "Updated config", options); + + const result = memoryList(undefined, undefined, options); + + // project.config should be first as it was updated most recently + expect(result.keys[0]?.key).toBe("project.config"); + }); + + it("should return empty array for non-existent directory", () => { + const emptyOptions: MemoryStorageOptions = { + profileId: "non-existent-profile", + baseDir: testBaseDir, + }; + + const result = memoryList(undefined, undefined, emptyOptions); + expect(result.keys).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + describe("getMemoryDir", () => { + it("should return correct memory directory path", () => { + const dir = getMemoryDir(options); + expect(dir).toContain(profileId); + expect(dir).toContain("memory"); + }); + }); +}); diff --git a/src/agent/tools/memory/storage.ts b/src/agent/tools/memory/storage.ts new file mode 100644 index 000000000..e7c297589 --- /dev/null +++ b/src/agent/tools/memory/storage.ts @@ -0,0 +1,240 @@ +/** + * Memory Storage Layer + * + * Handles file-based storage for agent memory in the profile directory. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getProfileDir } from "../../profile/storage.js"; +import { + DEFAULT_LIST_LIMIT, + KEY_PATTERN, + MAX_KEY_LENGTH, + MAX_LIST_LIMIT, + MAX_VALUE_SIZE, + type MemoryEntry, + type MemoryListResult, + type MemoryStorageOptions, +} from "./types.js"; + +/** + * Validate a memory key + */ +export function validateKey(key: string): { valid: true } | { valid: false; error: string } { + if (!key || typeof key !== "string") { + return { valid: false, error: "Key is required" }; + } + + const trimmed = key.trim(); + if (trimmed.length === 0) { + return { valid: false, error: "Key cannot be empty" }; + } + + if (trimmed.length > MAX_KEY_LENGTH) { + return { valid: false, error: `Key exceeds maximum length of ${MAX_KEY_LENGTH}` }; + } + + if (!KEY_PATTERN.test(trimmed)) { + return { + valid: false, + error: "Key can only contain letters, numbers, underscores, dots, and hyphens", + }; + } + + return { valid: true }; +} + +/** + * Get the memory directory for a profile + */ +export function getMemoryDir(options: MemoryStorageOptions): string { + const profileDir = getProfileDir(options.profileId, { baseDir: options.baseDir }); + return join(profileDir, "memory"); +} + +/** + * Ensure the memory directory exists + */ +export function ensureMemoryDir(options: MemoryStorageOptions): string { + const memoryDir = getMemoryDir(options); + if (!existsSync(memoryDir)) { + mkdirSync(memoryDir, { recursive: true }); + } + return memoryDir; +} + +/** + * Get the file path for a memory key + */ +function getKeyFilePath(key: string, options: MemoryStorageOptions): string { + const memoryDir = getMemoryDir(options); + // Sanitize key for filename (replace dots with double underscore to avoid extension issues) + const safeKey = key.replace(/\./g, "__DOT__"); + return join(memoryDir, `${safeKey}.json`); +} + +/** + * Decode a sanitized filename back to the original key + */ +function decodeKeyFromFilename(filename: string): string { + // Remove .json extension and decode + const base = filename.replace(/\.json$/, ""); + return base.replace(/__DOT__/g, "."); +} + +/** + * Get a memory value by key + */ +export function memoryGet( + key: string, + options: MemoryStorageOptions, +): { found: true; entry: MemoryEntry } | { found: false } { + const validation = validateKey(key); + if (!validation.valid) { + return { found: false }; + } + + const filePath = getKeyFilePath(key.trim(), options); + if (!existsSync(filePath)) { + return { found: false }; + } + + try { + const content = readFileSync(filePath, "utf-8"); + const entry = JSON.parse(content) as MemoryEntry; + return { found: true, entry }; + } catch { + return { found: false }; + } +} + +/** + * Set a memory value + */ +export function memorySet( + key: string, + value: unknown, + description: string | undefined, + options: MemoryStorageOptions, +): { success: true } | { success: false; error: string } { + const validation = validateKey(key); + if (validation.valid === false) { + return { success: false, error: validation.error }; + } + + // Check value size + const serialized = JSON.stringify(value); + if (serialized.length > MAX_VALUE_SIZE) { + return { success: false, error: `Value exceeds maximum size of ${MAX_VALUE_SIZE} bytes` }; + } + + const trimmedKey = key.trim(); + ensureMemoryDir(options); + + const now = Date.now(); + const existing = memoryGet(trimmedKey, options); + + const trimmedDescription = description?.trim(); + const entry: MemoryEntry = { + value, + ...(trimmedDescription ? { description: trimmedDescription } : {}), + createdAt: existing.found ? existing.entry.createdAt : now, + updatedAt: now, + }; + + const filePath = getKeyFilePath(trimmedKey, options); + + try { + writeFileSync(filePath, JSON.stringify(entry, null, 2), "utf-8"); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to write memory: ${message}` }; + } +} + +/** + * Delete a memory key + */ +export function memoryDelete( + key: string, + options: MemoryStorageOptions, +): { success: true; existed: boolean } | { success: false; error: string } { + const validation = validateKey(key); + if (validation.valid === false) { + return { success: false, error: validation.error }; + } + + const filePath = getKeyFilePath(key.trim(), options); + const existed = existsSync(filePath); + + if (existed) { + try { + rmSync(filePath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { success: false, error: `Failed to delete memory: ${message}` }; + } + } + + return { success: true, existed }; +} + +/** + * List memory keys + */ +export function memoryList( + prefix: string | undefined, + limit: number | undefined, + options: MemoryStorageOptions, +): MemoryListResult { + const memoryDir = getMemoryDir(options); + + if (!existsSync(memoryDir)) { + return { keys: [], total: 0, truncated: false }; + } + + const effectiveLimit = Math.min( + Math.max(1, limit ?? DEFAULT_LIST_LIMIT), + MAX_LIST_LIMIT, + ); + + try { + const files = readdirSync(memoryDir).filter((f) => f.endsWith(".json")); + const entries: Array<{ key: string; description?: string; updatedAt: number }> = []; + + for (const file of files) { + const key = decodeKeyFromFilename(file); + + // Apply prefix filter + if (prefix && !key.startsWith(prefix)) { + continue; + } + + const filePath = join(memoryDir, file); + try { + const content = readFileSync(filePath, "utf-8"); + const entry = JSON.parse(content) as MemoryEntry; + entries.push({ + key, + ...(entry.description ? { description: entry.description } : {}), + updatedAt: entry.updatedAt, + }); + } catch { + // Skip invalid files + } + } + + // Sort by updatedAt descending (most recent first) + entries.sort((a, b) => b.updatedAt - a.updatedAt); + + const total = entries.length; + const truncated = total > effectiveLimit; + const keys = entries.slice(0, effectiveLimit); + + return { keys, total, truncated }; + } catch { + return { keys: [], total: 0, truncated: false }; + } +} diff --git a/src/agent/tools/memory/types.ts b/src/agent/tools/memory/types.ts new file mode 100644 index 000000000..81912eed9 --- /dev/null +++ b/src/agent/tools/memory/types.ts @@ -0,0 +1,67 @@ +/** + * Memory Tool Type Definitions + */ + +/** Memory entry stored in JSON file */ +export interface MemoryEntry { + /** The stored value */ + value: unknown; + /** Optional description of this memory entry */ + description?: string; + /** Timestamp when created */ + createdAt: number; + /** Timestamp when last updated */ + updatedAt: number; +} + +/** Memory index structure */ +export interface MemoryIndex { + /** Version for future migrations */ + version: 1; + /** Map of key to metadata */ + keys: Record; +} + +/** Metadata for each key in the index */ +export interface MemoryKeyMeta { + /** Optional description */ + description?: string; + /** Created timestamp */ + createdAt: number; + /** Updated timestamp */ + updatedAt: number; +} + +/** Options for memory storage */ +export interface MemoryStorageOptions { + /** Profile ID (required for storage path) */ + profileId: string; + /** Base directory for profiles */ + baseDir?: string; +} + +/** Result from memory_list */ +export interface MemoryListResult { + keys: Array<{ + key: string; + description?: string; + updatedAt: number; + }>; + total: number; + truncated: boolean; +} + +/** Valid key pattern: alphanumeric, underscore, dot, hyphen */ +export const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/; + +/** Maximum key length */ +export const MAX_KEY_LENGTH = 128; + +/** Maximum value size in bytes (1MB) */ +export const MAX_VALUE_SIZE = 1024 * 1024; + +/** Default list limit */ +export const DEFAULT_LIST_LIMIT = 100; + +/** Maximum list limit */ +export const MAX_LIST_LIMIT = 1000; diff --git a/src/agent/tools/policy.test.ts b/src/agent/tools/policy.test.ts new file mode 100644 index 000000000..1862fd6f7 --- /dev/null +++ b/src/agent/tools/policy.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for tool policy system. + * Run with: npx tsx src/agent/tools/policy.test.ts + */ + +import { filterTools, type ToolsConfig } from "./policy.js"; +import { TOOL_GROUPS, TOOL_PROFILES, expandToolGroups } from "./groups.js"; + +// Simple test helper +function test(name: string, fn: () => void) { + try { + fn(); + console.log(`✓ ${name}`); + } catch (e) { + console.error(`✗ ${name}`); + console.error(e); + process.exit(1); + } +} + +function assertEqual(actual: T, expected: T, msg?: string) { + const actualStr = JSON.stringify(actual); + const expectedStr = JSON.stringify(expected); + if (actualStr !== expectedStr) { + throw new Error( + `${msg || "Assertion failed"}\n Expected: ${expectedStr}\n Actual: ${actualStr}`, + ); + } +} + +// Mock tools for testing +const mockTools = [ + { name: "read" }, + { name: "write" }, + { name: "edit" }, + { name: "exec" }, + { name: "process" }, + { name: "glob" }, + { name: "web_fetch" }, + { name: "web_search" }, +] as any[]; + +console.log("=== Tool Groups Tests ===\n"); + +test("expandToolGroups: group:fs", () => { + const expanded = expandToolGroups(["group:fs"]); + assertEqual(expanded.sort(), ["edit", "glob", "read", "write"]); +}); + +test("expandToolGroups: group:runtime", () => { + const expanded = expandToolGroups(["group:runtime"]); + assertEqual(expanded.sort(), ["exec", "process"]); +}); + +test("expandToolGroups: group:web", () => { + const expanded = expandToolGroups(["group:web"]); + assertEqual(expanded.sort(), ["web_fetch", "web_search"]); +}); + +test("expandToolGroups: mixed groups and tools", () => { + const expanded = expandToolGroups(["group:runtime", "web_fetch"]); + assertEqual(expanded.sort(), ["exec", "process", "web_fetch"]); +}); + +console.log("\n=== Tool Profiles Tests ===\n"); + +test("TOOL_PROFILES: minimal has empty allow", () => { + assertEqual(TOOL_PROFILES.minimal.allow, []); +}); + +test("TOOL_PROFILES: coding has fs and runtime", () => { + assertEqual(TOOL_PROFILES.coding.allow, ["group:fs", "group:runtime"]); +}); + +test("TOOL_PROFILES: full has no restrictions", () => { + assertEqual(TOOL_PROFILES.full.allow, undefined); + assertEqual(TOOL_PROFILES.full.deny, undefined); +}); + +console.log("\n=== Filter Tests ===\n"); + +test("filterTools: no config returns all tools", () => { + const filtered = filterTools(mockTools, {}); + assertEqual(filtered.length, mockTools.length); +}); + +test("filterTools: minimal profile returns no tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "minimal" } }); + assertEqual(filtered.length, 0); +}); + +test("filterTools: coding profile returns fs and runtime", () => { + const filtered = filterTools(mockTools, { config: { profile: "coding" } }); + const names = filtered.map((t) => t.name).sort(); + assertEqual(names, ["edit", "exec", "glob", "process", "read", "write"]); +}); + +test("filterTools: web profile returns all", () => { + const filtered = filterTools(mockTools, { config: { profile: "web" } }); + const names = filtered.map((t) => t.name).sort(); + assertEqual(names, [ + "edit", + "exec", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); +}); + +test("filterTools: full profile returns all tools", () => { + const filtered = filterTools(mockTools, { config: { profile: "full" } }); + assertEqual(filtered.length, mockTools.length); +}); + +test("filterTools: deny specific tool", () => { + const filtered = filterTools(mockTools, { config: { deny: ["exec"] } }); + const names = filtered.map((t) => t.name); + assertEqual(names.includes("exec"), false); + assertEqual(names.length, mockTools.length - 1); +}); + +test("filterTools: allow specific tools", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write"] }, + }); + const names = filtered.map((t) => t.name).sort(); + assertEqual(names, ["read", "write"]); +}); + +test("filterTools: deny takes precedence over allow", () => { + const filtered = filterTools(mockTools, { + config: { allow: ["read", "write", "exec"], deny: ["exec"] }, + }); + const names = filtered.map((t) => t.name).sort(); + assertEqual(names, ["read", "write"]); +}); + +console.log("\n=== Provider-specific Tests ===\n"); + +test("filterTools: provider-specific deny", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, + }, + provider: "google", + }); + const names = filtered.map((t) => t.name); + assertEqual(names.includes("exec"), false); + assertEqual(names.includes("process"), false); + assertEqual(names.length, mockTools.length - 2); +}); + +test("filterTools: provider not matching does not apply", () => { + const filtered = filterTools(mockTools, { + config: { + byProvider: { + google: { deny: ["exec", "process"] }, + }, + }, + provider: "openai", + }); + assertEqual(filtered.length, mockTools.length); +}); + +console.log("\n=== Subagent Tests ===\n"); + +test("filterTools: subagent restrictions apply", () => { + // Currently DEFAULT_SUBAGENT_TOOL_DENY is empty, so no tools are denied + const filtered = filterTools(mockTools, { isSubagent: true }); + // With empty deny list, all tools are allowed + assertEqual(filtered.length, mockTools.length); +}); + +console.log("\n=== Combined Tests ===\n"); + +test("filterTools: profile + deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "coding", + deny: ["exec"], + }, + }); + const names = filtered.map((t) => t.name).sort(); + // coding = fs + runtime, minus exec + assertEqual(names, ["edit", "glob", "process", "read", "write"]); +}); + +test("filterTools: profile + provider deny", () => { + const filtered = filterTools(mockTools, { + config: { + profile: "web", + byProvider: { + google: { deny: ["exec"] }, + }, + }, + provider: "google", + }); + const names = filtered.map((t) => t.name).sort(); + // web profile - exec + assertEqual(names, [ + "edit", + "glob", + "process", + "read", + "web_fetch", + "web_search", + "write", + ]); +}); + +console.log("\n=== All tests passed! ===\n"); diff --git a/src/agent/tools/policy.ts b/src/agent/tools/policy.ts new file mode 100644 index 000000000..7e1a94070 --- /dev/null +++ b/src/agent/tools/policy.ts @@ -0,0 +1,363 @@ +/** + * Tool policy system for filtering tools based on configuration. + * + * Supports 4 layers of filtering: + * 1. Profile - base tool set (minimal/coding/web/full) + * 2. Global allow/deny - user customization + * 3. Provider-specific - different rules for different LLM providers + * 4. Subagent restrictions - limited tools for spawned agents + */ + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { + type ToolProfileId, + expandToolGroups, + getProfilePolicy, + normalizeToolName, + DEFAULT_SUBAGENT_TOOL_DENY, +} from "./groups.js"; + +/** + * Tool policy configuration. + */ +export interface ToolPolicy { + /** Allow list - only these tools are available (supports group:* syntax) */ + allow?: string[]; + /** Deny list - these tools are blocked (takes precedence over allow) */ + deny?: string[]; +} + +/** + * Full tool configuration from config file. + */ +export interface ToolsConfig { + /** Base profile (minimal/coding/web/full) */ + profile?: ToolProfileId; + /** Additional tools to allow */ + allow?: string[]; + /** Tools to deny */ + deny?: string[]; + /** Provider-specific overrides */ + byProvider?: Record; +} + +// ============================================================================ +// Pattern Matching +// ============================================================================ + +type CompiledPattern = + | { kind: "all" } + | { kind: "exact"; value: string } + | { kind: "regex"; value: RegExp }; + +function compilePattern(pattern: string): CompiledPattern { + const normalized = normalizeToolName(pattern); + if (!normalized) return { kind: "exact", value: "" }; + if (normalized === "*") return { kind: "all" }; + if (!normalized.includes("*")) return { kind: "exact", value: normalized }; + + // Convert wildcard to regex + const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return { + kind: "regex", + value: new RegExp(`^${escaped.replace(/\\\*/g, ".*")}$`), + }; +} + +function compilePatterns(patterns?: string[]): CompiledPattern[] { + if (!Array.isArray(patterns)) return []; + return expandToolGroups(patterns) + .map(compilePattern) + .filter((pattern) => pattern.kind !== "exact" || pattern.value); +} + +function matchesAny(name: string, patterns: CompiledPattern[]): boolean { + for (const pattern of patterns) { + if (pattern.kind === "all") return true; + if (pattern.kind === "exact" && name === pattern.value) return true; + if (pattern.kind === "regex" && pattern.value.test(name)) return true; + } + return false; +} + +// ============================================================================ +// Policy Matching +// ============================================================================ + +/** + * Create a matcher function for a policy. + * Returns true if the tool is allowed, false if denied. + */ +function createPolicyMatcher(policy: ToolPolicy): (name: string) => boolean { + const deny = compilePatterns(policy.deny); + const allow = compilePatterns(policy.allow); + // Check if allow was explicitly set (even if empty) + const hasAllowList = Array.isArray(policy.allow); + + return (name: string) => { + const normalized = normalizeToolName(name); + + // Deny takes precedence + if (matchesAny(normalized, deny)) return false; + + // If no allow list configured, allow all + if (!hasAllowList) return true; + + // If allow list is empty, deny all (explicit restriction) + if (allow.length === 0) return false; + + // Check if in allow list + return matchesAny(normalized, allow); + }; +} + +/** + * Check if a tool is allowed by a policy. + */ +export function isToolAllowed(name: string, policy?: ToolPolicy): boolean { + if (!policy) return true; + return createPolicyMatcher(policy)(name); +} + +/** + * Filter tools by a policy. + */ +export function filterToolsByPolicy( + tools: T[], + policy?: ToolPolicy, +): T[] { + if (!policy) return tools; + const matcher = createPolicyMatcher(policy); + return tools.filter((tool) => matcher(tool.name)); +} + +// ============================================================================ +// Policy Resolution +// ============================================================================ + +/** + * Merge allow lists (union). + */ +function mergeAllow(base?: string[], extra?: string[]): string[] | undefined { + if (!extra || extra.length === 0) return base; + if (!base || base.length === 0) return extra; + return Array.from(new Set([...base, ...extra])); +} + +/** + * Merge deny lists (union). + */ +function mergeDeny(base?: string[], extra?: string[]): string[] | undefined { + if (!extra || extra.length === 0) return base; + if (!base || base.length === 0) return extra; + return Array.from(new Set([...base, ...extra])); +} + +/** + * Resolve provider-specific policy. + */ +function resolveProviderPolicy( + byProvider?: Record, + provider?: string, +): ToolPolicy | undefined { + if (!provider || !byProvider) return undefined; + + const normalized = provider.trim().toLowerCase(); + return byProvider[normalized]; +} + +/** + * Get subagent tool policy. + */ +export function getSubagentPolicy(extraDeny?: string[]): ToolPolicy { + const deny = mergeDeny(DEFAULT_SUBAGENT_TOOL_DENY, extraDeny); + if (deny) { + return { deny }; + } + return {}; +} + +// ============================================================================ +// Main Filter Function +// ============================================================================ + +export interface FilterToolsOptions { + /** Tool configuration */ + config?: ToolsConfig; + /** Current LLM provider (for provider-specific rules) */ + provider?: string; + /** Whether this is a subagent (applies subagent restrictions) */ + isSubagent?: boolean; +} + +/** + * Filter tools through the 4-layer policy system. + * + * Layer 1: Profile (base tool set) + * Layer 2: Global allow/deny + * Layer 3: Provider-specific + * Layer 4: Subagent restrictions + */ +export function filterTools( + tools: AgentTool[], + options: FilterToolsOptions = {}, +): AgentTool[] { + const { config, provider, isSubagent } = options; + + let filtered = tools; + + // Layer 1: Profile + if (config?.profile) { + const profilePolicy = getProfilePolicy(config.profile); + if (profilePolicy) { + filtered = filterToolsByPolicy(filtered, profilePolicy); + } + } + + // Layer 2: Global allow/deny + if (config?.allow || config?.deny) { + const globalPolicy: ToolPolicy = {}; + if (config.allow) { + globalPolicy.allow = config.allow; + } + if (config.deny) { + globalPolicy.deny = config.deny; + } + filtered = filterToolsByPolicy(filtered, globalPolicy); + } + + // Layer 3: Provider-specific + if (provider && config?.byProvider) { + const providerPolicy = resolveProviderPolicy(config.byProvider, provider); + if (providerPolicy) { + filtered = filterToolsByPolicy(filtered, providerPolicy); + } + } + + // Layer 4: Subagent restrictions + if (isSubagent) { + const subagentPolicy = getSubagentPolicy(); + filtered = filterToolsByPolicy(filtered, subagentPolicy); + } + + return filtered; +} + +/** + * Merge two ToolsConfig objects. + * The override config takes precedence: + * - profile: override wins if set + * - allow: union of both + * - deny: union of both + * - byProvider: deep merge with override taking precedence + */ +export function mergeToolsConfig( + base?: ToolsConfig, + override?: ToolsConfig, +): ToolsConfig | undefined { + if (!base && !override) return undefined; + if (!base) return override; + if (!override) return base; + + const result: ToolsConfig = {}; + + // profile: override wins + const profile = override.profile ?? base.profile; + if (profile) { + result.profile = profile; + } + + // allow: union + const allow = mergeAllow(base.allow, override.allow); + if (allow) { + result.allow = allow; + } + + // deny: union + const deny = mergeDeny(base.deny, override.deny); + if (deny) { + result.deny = deny; + } + + // byProvider: deep merge + if (base.byProvider || override.byProvider) { + const providers = new Set([ + ...Object.keys(base.byProvider ?? {}), + ...Object.keys(override.byProvider ?? {}), + ]); + + const byProvider: Record = {}; + for (const provider of providers) { + const basePolicy = base.byProvider?.[provider]; + const overridePolicy = override.byProvider?.[provider]; + + if (basePolicy && overridePolicy) { + const merged: ToolPolicy = {}; + const pAllow = mergeAllow(basePolicy.allow, overridePolicy.allow); + if (pAllow) { + merged.allow = pAllow; + } + const pDeny = mergeDeny(basePolicy.deny, overridePolicy.deny); + if (pDeny) { + merged.deny = pDeny; + } + byProvider[provider] = merged; + } else { + byProvider[provider] = overridePolicy ?? basePolicy!; + } + } + result.byProvider = byProvider; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * Check if a specific tool would be allowed given the options. + */ +export function wouldToolBeAllowed( + toolName: string, + options: FilterToolsOptions = {}, +): boolean { + const { config, provider, isSubagent } = options; + + // Layer 1: Profile + if (config?.profile) { + const profilePolicy = getProfilePolicy(config.profile); + if (profilePolicy && !isToolAllowed(toolName, profilePolicy)) { + return false; + } + } + + // Layer 2: Global allow/deny + if (config?.allow || config?.deny) { + const globalPolicy: ToolPolicy = {}; + if (config.allow) { + globalPolicy.allow = config.allow; + } + if (config.deny) { + globalPolicy.deny = config.deny; + } + if (!isToolAllowed(toolName, globalPolicy)) { + return false; + } + } + + // Layer 3: Provider-specific + if (provider && config?.byProvider) { + const providerPolicy = resolveProviderPolicy(config.byProvider, provider); + if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) { + return false; + } + } + + // Layer 4: Subagent restrictions + if (isSubagent) { + const subagentPolicy = getSubagentPolicy(); + if (!isToolAllowed(toolName, subagentPolicy)) { + return false; + } + } + + return true; +} diff --git a/src/agent/tools/web/param-helpers.test.ts b/src/agent/tools/web/param-helpers.test.ts index 28bda65df..4605ea56b 100644 --- a/src/agent/tools/web/param-helpers.test.ts +++ b/src/agent/tools/web/param-helpers.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect } from "vitest"; import { readStringParam, readNumberParam, jsonResult } from "./param-helpers.js"; +// Helper to safely get text content from result +function getTextContent(result: ReturnType): string { + const content = result.content[0]; + if (content?.type === "text") { + return content.text; + } + throw new Error("Expected text content"); +} + describe("param-helpers", () => { describe("readStringParam", () => { it("should return string value when present", () => { @@ -195,8 +204,11 @@ describe("param-helpers", () => { const result = jsonResult(payload); expect(result.content).toHaveLength(1); - expect(result.content[0].type).toBe("text"); - expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2)); + const content = result.content[0]; + expect(content?.type).toBe("text"); + if (content?.type === "text") { + expect(content.text).toBe(JSON.stringify(payload, null, 2)); + } expect(result.details).toBe(payload); }); @@ -204,7 +216,7 @@ describe("param-helpers", () => { const payload = [1, 2, 3]; const result = jsonResult(payload); - expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2)); + expect(getTextContent(result)).toBe(JSON.stringify(payload, null, 2)); expect(result.details).toBe(payload); }); @@ -212,14 +224,14 @@ describe("param-helpers", () => { const payload = "simple string"; const result = jsonResult(payload); - expect(result.content[0].text).toBe('"simple string"'); + expect(getTextContent(result)).toBe('"simple string"'); expect(result.details).toBe(payload); }); it("should handle null payload", () => { const result = jsonResult(null); - expect(result.content[0].text).toBe("null"); + expect(getTextContent(result)).toBe("null"); expect(result.details).toBeNull(); }); @@ -230,8 +242,8 @@ describe("param-helpers", () => { }; const result = jsonResult(payload); - expect(result.content[0].text).toContain("user"); - expect(result.content[0].text).toContain("settings"); + expect(getTextContent(result)).toContain("user"); + expect(getTextContent(result)).toContain("settings"); expect(result.details).toBe(payload); }); }); diff --git a/src/agent/types.ts b/src/agent/types.ts index 3f1ebb1e2..4a1cf9d78 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -1,5 +1,6 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { SkillsConfig } from "./skills/types.js"; +import type { ToolsConfig } from "./tools/policy.js"; export type AgentRunResult = { text: string; @@ -61,6 +62,12 @@ export type AgentOptions = { extraSkillDirs?: string[] | undefined; /** Full skills configuration */ skills?: SkillsConfig | undefined; + + // === Tools Configuration === + /** Tools policy configuration (profile, allow/deny, byProvider) */ + tools?: ToolsConfig | undefined; + /** Whether this is a subagent (applies restricted tool set) */ + isSubagent?: boolean | undefined; }; export interface Message {