Merge pull request #39 from multica-ai/feat/tools-system

feat(tools): comprehensive tools system with policy filtering and memory tools
This commit is contained in:
Bohan Jiang
2026-01-31 02:26:20 +08:00
committed by GitHub
23 changed files with 2777 additions and 18 deletions

View File

@@ -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",

View File

@@ -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

197
src/agent/profile/README.md Normal file
View File

@@ -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/
└── <profile-id>/
├── 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,
});
```

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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 */

View File

@@ -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) {

205
src/agent/tools-cli.ts Normal file
View File

@@ -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 <command> [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);
});

View File

@@ -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<any>[] {
const cwd = options.cwd ?? process.cwd();
const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash") as AgentTool<any>[];
/** 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<any>[] {
// 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<any>[];
const execTool = createExecTool(cwd);
const processTool = createProcessTool(cwd);
const globTool = createGlobTool(cwd);
const webFetchTool = createWebFetchTool();
const webSearchTool = createWebSearchTool();
return [
const tools: AgentTool<any>[] = [
...baseTools,
execTool as AgentTool<any>,
processTool as AgentTool<any>,
@@ -34,4 +56,64 @@ export function resolveTools(options: AgentOptions): AgentTool<any>[] {
webFetchTool as AgentTool<any>,
webSearchTool as AgentTool<any>,
];
// 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<any>[] {
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);
}

330
src/agent/tools/README.md Normal file
View File

@@ -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<typeof MyToolSchema> {
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<any>[] {
// ... existing tools
const myTool = createMyTool();
return [
...baseTools,
myTool as AgentTool<any>,
// ...
];
}
```
4. Add the tool to appropriate groups in `groups.ts`:
```typescript
export const TOOL_GROUPS: Record<string, string[]> = {
'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

View File

@@ -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<typeof MyToolSchema> {
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<any>[] {
// ... 现有工具
const myTool = createMyTool();
return [
...baseTools,
myTool as AgentTool<any>,
// ...
];
}
```
4.`groups.ts` 中将工具添加到适当的组:
```typescript
export const TOOL_GROUPS: Record<string, string[]> = {
'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** - 可视化输出生成

View File

@@ -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 () => {

145
src/agent/tools/groups.ts Normal file
View File

@@ -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<string, string> = {
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<string, string[]> = {
// 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<ToolProfileId, { allow?: string[]; deny?: string[] }> = {
// 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;
}

34
src/agent/tools/index.ts Normal file
View File

@@ -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";

View File

@@ -0,0 +1,6 @@
/**
* Memory Tools Module
*/
export { createMemoryTools } from "./memory-tools.js";
export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js";

View File

@@ -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<T>(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<typeof MemoryGetSchema> {
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<typeof MemorySetSchema> {
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<typeof MemoryDeleteSchema> {
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<typeof MemoryListSchema> {
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<AgentTool<any>> {
return [
createMemoryGetTool(options),
createMemorySetTool(options),
createMemoryDeleteTool(options),
createMemoryListTool(options),
];
}

View File

@@ -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");
});
});
});

View File

@@ -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 };
}
}

View File

@@ -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<string, MemoryKeyMeta>;
}
/** 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;

View File

@@ -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<T>(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");

363
src/agent/tools/policy.ts Normal file
View File

@@ -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<string, ToolPolicy>;
}
// ============================================================================
// 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<T extends { name: string }>(
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<string, ToolPolicy>,
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<any>[],
options: FilterToolsOptions = {},
): AgentTool<any>[] {
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<string, ToolPolicy> = {};
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;
}

View File

@@ -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<typeof jsonResult>): 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);
});
});

View File

@@ -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 {