mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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
197
src/agent/profile/README.md
Normal 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,
|
||||
});
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
205
src/agent/tools-cli.ts
Normal 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);
|
||||
});
|
||||
@@ -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
330
src/agent/tools/README.md
Normal 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
|
||||
330
src/agent/tools/README.zh-CN.md
Normal file
330
src/agent/tools/README.zh-CN.md
Normal 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** - 可视化输出生成
|
||||
@@ -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
145
src/agent/tools/groups.ts
Normal 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
34
src/agent/tools/index.ts
Normal 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";
|
||||
6
src/agent/tools/memory/index.ts
Normal file
6
src/agent/tools/memory/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Memory Tools Module
|
||||
*/
|
||||
|
||||
export { createMemoryTools } from "./memory-tools.js";
|
||||
export type { MemoryEntry, MemoryStorageOptions, MemoryListResult } from "./types.js";
|
||||
175
src/agent/tools/memory/memory-tools.ts
Normal file
175
src/agent/tools/memory/memory-tools.ts
Normal 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),
|
||||
];
|
||||
}
|
||||
224
src/agent/tools/memory/storage.test.ts
Normal file
224
src/agent/tools/memory/storage.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
240
src/agent/tools/memory/storage.ts
Normal file
240
src/agent/tools/memory/storage.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
67
src/agent/tools/memory/types.ts
Normal file
67
src/agent/tools/memory/types.ts
Normal 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;
|
||||
216
src/agent/tools/policy.test.ts
Normal file
216
src/agent/tools/policy.test.ts
Normal 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
363
src/agent/tools/policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user