Merge branch 'main' into exec-approvals

This commit is contained in:
Naiyuan Qing 2026-02-05 11:06:52 +08:00
commit 3c303df8f1
61 changed files with 4538 additions and 1024 deletions

View file

@ -221,4 +221,19 @@ export class AsyncAgent {
getMessages(): AgentMessage[] {
return this.agent.getMessages();
}
/**
* Get current provider and model information.
*/
getProviderInfo(): { provider: string; model: string | undefined } {
return this.agent.getProviderInfo();
}
/**
* Switch to a different provider and/or model.
* This updates the agent's model without recreating the session.
*/
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
return this.agent.setProvider(providerId, modelId);
}
}

View file

@ -2,44 +2,46 @@
* Dev command - Start development servers
*
* Usage:
* multica dev Start all services (gateway + console + web)
* multica dev gateway Start gateway only (:3000)
* multica dev console Start console only (:4000)
* multica dev Start desktop app (with embedded Hub)
* multica dev gateway Start gateway only (:3000) - for remote clients
* multica dev web Start web app only (:3001)
* multica dev desktop Start desktop app
* multica dev all Start all services (gateway + web)
*/
import { spawn } from "node:child_process";
import { cyan, yellow, green, dim, red } from "../colors.js";
type Service = "all" | "gateway" | "console" | "web" | "desktop" | "help";
type Service = "all" | "gateway" | "web" | "desktop" | "help";
function printHelp() {
console.log(`
${cyan("Usage:")} multica dev [service]
${cyan("Services:")}
${yellow("(default)")} Start all services (gateway + console + web)
${yellow("gateway")} Start Gateway server (:3000)
${yellow("console")} Start Console server (:4000)
${yellow("(default)")} Start Desktop app (with embedded Hub)
${yellow("gateway")} Start Gateway server (:3000) - for remote clients
${yellow("web")} Start Web app (:3001)
${yellow("desktop")} Start Desktop app
${yellow("all")} Start all services (gateway + web)
${yellow("help")} Show this help
${cyan("Architecture:")}
Frontend (web:3001 / desktop)
Desktop App (standalone)
Embedded Hub + Agent Engine
(Optional) Gateway connection for remote access
Web App (requires Gateway)
Gateway (WebSocket, :3000)
Console Hub (multi-agent coordination, :4000)
Agent Engine
Hub + Agent Engine
${cyan("Examples:")}
${dim("# Start all services")}
${dim("# Start desktop app (recommended for local development)")}
multica dev
${dim("# Start only the gateway")}
${dim("# Start desktop with remote Gateway for mobile access")}
GATEWAY_URL=http://localhost:3000 multica dev &
multica dev gateway
${dim("# Start web and gateway separately")}
${dim("# Start web app with gateway")}
multica dev gateway &
multica dev web
`);
@ -52,7 +54,7 @@ interface DevOptions {
function parseArgs(argv: string[]): DevOptions {
const args = [...argv];
let service: Service = "all";
let service: Service = "desktop";
let watch = true;
while (args.length > 0) {
@ -68,7 +70,7 @@ function parseArgs(argv: string[]): DevOptions {
}
// Service name
if (["gateway", "console", "web", "desktop", "all", "help"].includes(arg)) {
if (["gateway", "web", "desktop", "all", "help"].includes(arg)) {
service = arg as Service;
}
}
@ -105,14 +107,6 @@ async function startGateway(watch: boolean) {
});
}
async function startConsole(watch: boolean) {
const watchFlag = watch ? "--watch" : "";
return runCommand("tsx", [watchFlag, "src/console/main.ts"].filter(Boolean), {
name: "console",
color: "\x1b[33m", // yellow
});
}
async function startWeb() {
return runCommand("pnpm", ["--filter", "@multica/web", "dev"], {
name: "web",
@ -130,20 +124,17 @@ async function startDesktop() {
async function startAll(watch: boolean) {
console.log(`\n${cyan("Starting all services...")}\n`);
console.log(` ${"\x1b[34m"}Gateway${"\x1b[0m"} → http://localhost:3000`);
console.log(` ${"\x1b[33m"}Console${"\x1b[0m"} → http://localhost:4000`);
console.log(` ${"\x1b[32m"}Web${"\x1b[0m"} → http://localhost:3001`);
console.log("");
// Start all services
const gateway = await startGateway(watch);
const console_ = await startConsole(watch);
const web = await startWeb();
// Handle Ctrl+C
const cleanup = () => {
console.log(`\n${dim("Stopping all services...")}`);
gateway.kill();
console_.kill();
web.kill();
process.exit(0);
};
@ -154,7 +145,6 @@ async function startAll(watch: boolean) {
// Wait for all to exit
await Promise.all([
new Promise((resolve) => gateway.on("exit", resolve)),
new Promise((resolve) => console_.on("exit", resolve)),
new Promise((resolve) => web.on("exit", resolve)),
]);
}
@ -168,11 +158,6 @@ export async function devCommand(args: string[]): Promise<void> {
await startGateway(opts.watch);
break;
case "console":
console.log(`\n${cyan("Starting Console...")} → http://localhost:4000\n`);
await startConsole(opts.watch);
break;
case "web":
console.log(`\n${cyan("Starting Web App...")} → http://localhost:3001\n`);
await startWeb();

View file

@ -1,11 +1,17 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import JSON5 from "json5";
import { DATA_DIR } from "../shared/paths.js";
type ProviderConfig = {
// API Key authentication
apiKey?: string | undefined;
// OAuth authentication
oauthToken?: string | undefined;
oauthRefreshToken?: string | undefined;
oauthExpiresAt?: number | undefined;
// Common
baseUrl?: string | undefined;
model?: string | undefined;
};
@ -223,6 +229,132 @@ export class CredentialManager {
this.skillsConfig = null;
this.resolvedSkillsEnv = null;
}
/**
* Set the API key for a provider and save to credentials.json5.
* Creates the file if it doesn't exist.
*/
setLlmProviderApiKey(provider: string, apiKey: string): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
// If parse fails, start fresh
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
if (!config.llm.providers) {
config.llm.providers = {};
}
// Set or update the provider config
const existing = config.llm.providers[provider] ?? {};
config.llm.providers[provider] = {
...existing,
apiKey,
};
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache so next read picks up the change
this.reset();
}
/**
* Set OAuth token for a provider and save to credentials.json5.
* Used for OAuth providers like claude-code and openai-codex.
*/
setLlmProviderOAuthToken(
provider: string,
token: string,
refreshToken?: string,
expiresAt?: number,
): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
if (!config.llm.providers) {
config.llm.providers = {};
}
// Set or update the provider config
const existing = config.llm.providers[provider] ?? {};
config.llm.providers[provider] = {
...existing,
oauthToken: token,
oauthRefreshToken: refreshToken,
oauthExpiresAt: expiresAt,
};
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache
this.reset();
}
/**
* Set the default LLM provider and save to credentials.json5.
*/
setDefaultLlmProvider(provider: string): void {
const path = getCredentialsPath();
// Load existing config or create new one
let config: CredentialsConfig = { version: 1 };
if (existsSync(path)) {
try {
const raw = readFileSync(path, "utf8");
config = JSON5.parse(raw) as CredentialsConfig;
} catch {
config = { version: 1 };
}
}
// Ensure structure exists
if (!config.llm) {
config.llm = {};
}
// Set default provider
config.llm.provider = provider;
// Write back to file
mkdirSync(dirname(path), { recursive: true });
const content = JSON.stringify(config, null, 2);
writeFileSync(path, content, "utf8");
// Reset cache
this.reset();
}
}
export const credentialManager = new CredentialManager();

View file

@ -92,14 +92,26 @@ You wake up fresh each session. These files are your continuity:
Capture what matters. Decisions, context, things to remember.
### Write It Down
### 📝 Write It Down - No "Mental Notes"!
- Memory is limited if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When you learn something about the user update \`USER.md\`
- When you learn a lesson update \`MEMORY.md\`
**CRITICAL**: You CANNOT "remember" things mentally. Your memory resets each session. If you don't write it to a file, it's gone.
**Which file to edit:**
- \`user.md\` — About your human: name, preferences, habits, context, anything personal
- \`memory.md\` — Your learnings: decisions made, lessons learned, important context
- \`workspace.md\` — Your rules: conventions, workflows, how you should operate
- \`soul.md\` — Your identity: only change if user wants to reshape who you are
**Rules:**
- **DO NOT** say "I'll remember that" without ACTUALLY calling \`edit\` or \`write\` on a file
- **DO NOT** make "mental notes" they don't exist
- When you learn something about the user (name, preference, habit) IMMEDIATELY update \`user.md\`
- When you learn a lesson, make a decision, or gain context IMMEDIATELY update \`memory.md\`
- When you discover a better workflow or convention update \`workspace.md\`
- When you make a mistake document it so future-you doesn't repeat it
**Text > Brain** 📝
## Safety
- Don't exfiltrate private data. Ever.

View file

@ -9,6 +9,8 @@ import {
resolveApiKeyForProvider,
resolveBaseUrl,
resolveModelId,
PROVIDER_ALIAS,
getDefaultModel,
} from "./providers/index.js";
import { SessionManager } from "./session/session-manager.js";
import { ProfileManager } from "./profile/index.js";
@ -82,7 +84,7 @@ export class Agent {
private initialized = false;
// Auth profile rotation state
private readonly resolvedProvider: string;
private resolvedProvider: string;
private currentApiKey: string | undefined;
private currentProfileId: string | undefined;
private profileCandidates: string[];
@ -598,6 +600,72 @@ export class Agent {
this.profile?.updateStyle(style);
}
/**
* Get current provider and model information.
*/
getProviderInfo(): { provider: string; model: string | undefined } {
return {
provider: this.resolvedProvider,
model: this.agent.state.model?.id,
};
}
/**
* Switch to a different provider and/or model.
* This updates the agent's model without recreating the session.
*/
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
// Resolve the actual provider (handle aliases like claude-code -> anthropic)
const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId;
// Resolve the model
const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider);
const model = resolveModel({ provider: providerId, model: targetModel });
if (!model) {
throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`);
}
// Resolve API key for the new provider
// For OAuth providers (claude-code, openai-codex), we need to use the original providerId
// because OAuth credentials are resolved by the original provider name, not the alias
const resolved = resolveApiKeyForProvider(providerId);
if (resolved) {
this.currentApiKey = resolved.apiKey;
this.currentProfileId = resolved.profileId;
} else {
// Fallback: try with actual provider (for API key based providers)
this.currentApiKey = resolveApiKey(actualProvider);
this.currentProfileId = actualProvider;
}
if (!this.currentApiKey) {
throw new Error(`No API key configured for provider: ${providerId}`);
}
// Update the agent's model and API key
const baseUrl = resolveBaseUrl(actualProvider);
const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model;
this.agent.setModel(modelWithBaseUrl);
// Update internal state
this.resolvedProvider = providerId;
// Update session metadata
this.session.saveMeta({
provider: actualProvider,
model: model.id,
thinkingLevel: this.agent.state.thinkingLevel,
reasoningMode: this.reasoningMode,
contextWindowTokens: this.contextWindowGuard.tokens,
});
return {
provider: providerId,
model: model.id,
};
}
/**
* Build the full system prompt using the structured builder.
* Combines profile content, tools, skills, and runtime info.

View file

@ -29,6 +29,7 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
label: params.label,
task: params.task,
},
tools: params.tools,
});
}

View file

@ -71,4 +71,6 @@ export type SubagentSystemPromptParams = {
childSessionId: string;
label?: string | undefined;
task: string;
/** Tool names available to the subagent (for tooling summary in system prompt) */
tools?: string[] | undefined;
};

View file

@ -15,12 +15,13 @@ const TOOLS = ["read", "write", "edit", "glob", "exec", "memory_get", "memory_se
describe("buildSystemPrompt", () => {
// ── Full mode ─────────────────────────────────────────────────────────
it("full mode includes all profile sections", () => {
it("full mode includes workspace section only (progressive disclosure)", () => {
// Soul, user, memory are read on-demand by the agent
const result = buildSystemPrompt({ mode: "full", profile: PROFILE });
expect(result).toContain("# Soul");
expect(result).toContain("# User");
expect(result).not.toContain("# Soul");
expect(result).not.toContain("# User");
expect(result).toContain("# Workspace");
expect(result).toContain("# Memory");
expect(result).not.toContain("# Memory");
});
it("full mode includes safety constitution", () => {
@ -76,13 +77,17 @@ describe("buildSystemPrompt", () => {
expect(result).toContain("os=darwin (arm64)");
});
it("full mode includes profile directory", () => {
it("full mode includes profile info in workspace section", () => {
const result = buildSystemPrompt({
mode: "full",
profileDir: "/home/user/.super-multica/agent-profiles/test",
profile: { workspace: "Workspace rules" },
});
expect(result).toContain("## Profile Directory");
expect(result).toContain("## Profile");
expect(result).toContain("/home/user/.super-multica/agent-profiles/test");
expect(result).toContain("soul.md");
expect(result).toContain("user.md");
expect(result).toContain("memory.md");
});
it("full mode excludes subagent section", () => {
@ -242,8 +247,13 @@ describe("buildSystemPromptWithReport", () => {
it("report marks excluded sections correctly in minimal mode", () => {
const { report } = buildSystemPromptWithReport({ mode: "minimal" });
// Identity is now included in all modes (just a one-liner)
const identity = report.sections.find((s) => s.name === "identity");
expect(identity?.included).toBe(false);
expect(identity?.included).toBe(true);
// User and memory are excluded (progressive disclosure)
const user = report.sections.find((s) => s.name === "user");
expect(user?.included).toBe(false);
const safety = report.sections.find((s) => s.name === "safety");
expect(safety?.included).toBe(true);

View file

@ -56,7 +56,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): {
const candidates: Array<{ name: string; lines: string[] }> = [
{ name: "identity", lines: buildIdentitySection(profile, mode) },
{ name: "user", lines: buildUserSection(profile, mode) },
{ name: "workspace", lines: buildWorkspaceSection(profile, mode) },
{ name: "workspace", lines: buildWorkspaceSection(profile, mode, profileDir) },
{ name: "memory", lines: buildMemoryFileSection(profile, mode) },
{ name: "safety", lines: buildSafetySection(includeSafety) },
{ name: "tooling", lines: buildToolingSummary(tools, mode) },

View file

@ -15,9 +15,15 @@ import {
} from "./sections.js";
describe("buildIdentitySection", () => {
it("returns soul content in full mode", () => {
it("returns identity line in full mode (progressive disclosure)", () => {
// Soul content is no longer injected - agent reads soul.md on demand
const result = buildIdentitySection({ soul: "You are helpful.", config: { name: "Cleo" } }, "full");
expect(result).toEqual(["You are Cleo, a Super Multica agent."]);
});
it("returns generic identity line in full mode without name", () => {
const result = buildIdentitySection({ soul: "You are helpful." }, "full");
expect(result).toEqual(["You are helpful."]);
expect(result).toEqual(["You are a Super Multica agent."]);
});
it("returns identity line with name in none mode", () => {
@ -30,47 +36,48 @@ describe("buildIdentitySection", () => {
expect(result).toEqual(["You are a Super Multica agent."]);
});
it("returns empty in minimal mode", () => {
const result = buildIdentitySection({ soul: "data" }, "minimal");
expect(result).toEqual([]);
it("returns identity line in minimal mode", () => {
const result = buildIdentitySection({ soul: "data", config: { name: "Cleo" } }, "minimal");
expect(result).toEqual(["You are Cleo, a Super Multica agent."]);
});
});
describe("buildUserSection", () => {
it("returns user content in full mode", () => {
const result = buildUserSection({ user: "Name: Bob" }, "full");
expect(result).toEqual(["Name: Bob"]);
});
it("returns empty in minimal mode", () => {
const result = buildUserSection({ user: "data" }, "minimal");
expect(result).toEqual([]);
});
it("returns empty when no user content", () => {
const result = buildUserSection({}, "full");
expect(result).toEqual([]);
it("returns empty in all modes (progressive disclosure)", () => {
// User content is no longer injected - agent reads user.md on demand
expect(buildUserSection({ user: "Name: Bob" }, "full")).toEqual([]);
expect(buildUserSection({ user: "data" }, "minimal")).toEqual([]);
expect(buildUserSection({}, "full")).toEqual([]);
});
});
describe("buildWorkspaceSection", () => {
it("returns workspace content in full mode", () => {
it("returns workspace content with profile info in full mode", () => {
const result = buildWorkspaceSection({ workspace: "Rules here" }, "full", "/path/to/profile");
const text = result.join("\n");
expect(text).toContain("## Profile");
expect(text).toContain("/path/to/profile");
expect(text).toContain("soul.md");
expect(text).toContain("user.md");
expect(text).toContain("memory.md");
expect(text).toContain("Rules here");
});
it("returns workspace content without profile dir", () => {
const result = buildWorkspaceSection({ workspace: "Rules here" }, "full");
expect(result).toEqual(["Rules here"]);
});
it("returns empty in minimal mode", () => {
expect(buildWorkspaceSection({ workspace: "data" }, "minimal")).toEqual([]);
expect(buildWorkspaceSection({ workspace: "data" }, "minimal", "/path")).toEqual([]);
});
});
describe("buildMemoryFileSection", () => {
it("returns memory content in full mode", () => {
const result = buildMemoryFileSection({ memory: "Key facts" }, "full");
expect(result).toEqual(["Key facts"]);
});
it("returns empty in minimal mode", () => {
it("returns empty in all modes (progressive disclosure)", () => {
// Memory content is no longer injected - agent reads memory.md on demand
expect(buildMemoryFileSection({ memory: "Key facts" }, "full")).toEqual([]);
expect(buildMemoryFileSection({ memory: "data" }, "minimal")).toEqual([]);
});
});
@ -227,12 +234,9 @@ describe("buildRuntimeSection", () => {
});
describe("buildProfileDirSection", () => {
it("includes path in full mode", () => {
const result = buildProfileDirSection("/path/to/profile", "full");
expect(result.join("\n")).toContain("/path/to/profile");
});
it("returns empty in minimal mode", () => {
it("returns empty in all modes (merged into workspace section)", () => {
// Profile directory info is now part of buildWorkspaceSection
expect(buildProfileDirSection("/path/to/profile", "full")).toEqual([]);
expect(buildProfileDirSection("/path", "minimal")).toEqual([]);
});
});

View file

@ -47,59 +47,85 @@ const TOOL_ORDER = [
// ─── Section builders ───────────────────────────────────────────────────────
/**
* Identity section soul.md in full mode, single line in none mode, nothing in minimal.
* Identity section brief identity line only.
* Full profile content (soul.md) is loaded on-demand by the agent.
*/
export function buildIdentitySection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
if (mode === "none") {
const name = profile?.config?.name;
const name = profile?.config?.name;
if (mode === "none" || mode === "minimal") {
return name
? [`You are ${name}, a Super Multica agent.`]
: ["You are a Super Multica agent."];
}
if (mode === "minimal") {
return [];
}
// full mode
if (profile?.soul) {
return [profile.soul];
}
// full mode - just identity line, agent reads soul.md on demand
return name
? [`You are ${name}, a Super Multica agent.`]
: ["You are a Super Multica agent."];
}
/**
* User section no longer injected into system prompt.
* Agent reads user.md on demand from profile directory.
*/
export function buildUserSection(
_profile: ProfileContent | undefined,
_mode: SystemPromptMode,
): string[] {
// Progressive disclosure: agent reads user.md on demand
return [];
}
/**
* User section user.md content (full mode only).
*/
export function buildUserSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
): string[] {
if (mode !== "full" || !profile?.user) return [];
return [profile.user];
}
/**
* Workspace section workspace.md content (full mode only).
* Workspace section workspace.md content with profile directory path.
* This is the primary profile content injected into system prompt.
* Other profile files (soul.md, user.md, memory.md) are read on demand.
*/
export function buildWorkspaceSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
profileDir?: string,
): string[] {
if (mode !== "full" || !profile?.workspace) return [];
return [profile.workspace];
if (mode !== "full") return [];
const lines: string[] = [];
// Add profile directory context first
if (profileDir) {
lines.push(
"## Profile",
"",
`Your profile directory: \`${profileDir}\``,
"",
"Profile files:",
"- `soul.md` — Your identity and values",
"- `user.md` — Information about your user",
"- `workspace.md` — Guidelines and conventions (below)",
"- `memory.md` — Persistent knowledge",
"",
);
}
// Add workspace.md content
if (profile?.workspace) {
lines.push(profile.workspace);
}
return lines;
}
/**
* Memory section memory.md content (full mode only).
* Memory section no longer injected into system prompt.
* Agent reads memory.md on demand from profile directory.
*/
export function buildMemoryFileSection(
profile: ProfileContent | undefined,
mode: SystemPromptMode,
_profile: ProfileContent | undefined,
_mode: SystemPromptMode,
): string[] {
if (mode !== "full" || !profile?.memory) return [];
return [profile.memory];
// Progressive disclosure: agent reads memory.md on demand
return [];
}
/**
@ -265,21 +291,15 @@ export function buildRuntimeSection(
}
/**
* Profile directory section tells agent where its files live.
* Full mode only.
* Profile directory section now merged into buildWorkspaceSection.
* Kept for backwards compatibility but returns empty.
*/
export function buildProfileDirSection(
profileDir: string | undefined,
mode: SystemPromptMode,
_profileDir: string | undefined,
_mode: SystemPromptMode,
): string[] {
if (mode !== "full" || !profileDir) return [];
return [
"## Profile Directory",
"",
`Your profile files are located at: \`${profileDir}\``,
"",
"Use `edit` or `write` tools to update these files when needed.",
];
// Profile directory info is now part of workspace section
return [];
}
/**

View file

@ -19,28 +19,22 @@ The tools system provides LLM agents with capabilities to interact with the exte
┌─────────────────────────────────────────────────────────────────┐
4-Layer Policy Filter │
3-Layer Policy Filter │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Layer 1: Profile │ │
│ │ Base tool set: minimal | coding | web | full │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Layer 2: Global Allow/Deny │ │
│ │ Layer 1: Global Allow/Deny │ │
│ │ User customization via CLI or config │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Layer 3: Provider-Specific │ │
│ │ Layer 2: Provider-Specific │ │
│ │ Different rules for different LLM providers │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Layer 4: Subagent Restrictions │ │
│ │ Layer 3: Subagent Restrictions │ │
│ │ Limited tools for spawned child agents │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
@ -55,20 +49,20 @@ The tools system provides LLM agents with capabilities to interact with the exte
## 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 |
| 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.
@ -76,24 +70,13 @@ The tools system provides LLM agents with capabilities to interact with the exte
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 |
| 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) |
## Usage
@ -102,11 +85,8 @@ Profiles are predefined tool sets for common use cases:
All commands use the unified `multica` CLI (or `pnpm multica` during development).
```bash
# Use a specific profile
multica run --tools-profile coding "list files"
# Minimal profile with specific tools allowed
multica run --tools-profile minimal --tools-allow exec "run ls"
# Allow only specific tools
multica run --tools-allow group:fs,group:runtime "list files"
# Deny specific tools
multica run --tools-deny exec,process "read file.txt"
@ -122,14 +102,11 @@ import { Agent } from './runner.js';
const agent = new Agent({
tools: {
// Layer 1: Base profile
profile: 'coding',
// Layer 1: Global allow/deny
allow: ['group:fs', 'group:runtime', 'web_fetch'],
deny: ['exec'],
// Layer 2: Global customization
allow: ['web_fetch'], // Add web_fetch to coding profile
deny: ['exec'], // But deny exec
// Layer 3: Provider-specific rules
// Layer 2: Provider-specific rules
byProvider: {
google: {
deny: ['exec', 'process'], // Google models can't use runtime tools
@ -137,7 +114,7 @@ const agent = new Agent({
},
},
// Layer 4: Subagent mode
// Layer 3: Subagent mode
isSubagent: false,
});
```
@ -150,43 +127,28 @@ Use the tools CLI to inspect and test configurations:
# List all available tools
multica tools list
# List tools after applying a profile
multica tools list --profile coding
# List tools with allow rules
multica tools list --allow group:fs,group:runtime
# List tools with deny rules
multica tools list --profile coding --deny exec
multica tools list --deny exec
# Show all tool groups
multica tools groups
# Show all profiles
multica tools profiles
```
## Policy System Details
### Layer 1: Profile
### Layer 1: Global Allow/Deny
The profile determines the base set of available tools. If not specified, all tools are available.
User-specified allow/deny lists:
```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
};
```
- `allow`: Only these tools are available (supports group:\* syntax)
- `deny`: These tools are blocked (takes precedence over allow)
### Layer 2: Global Allow/Deny
If no `allow` list is specified, all tools are available by default.
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
### Layer 2: Provider-Specific
Different LLM providers may have different capabilities or restrictions:
@ -199,7 +161,7 @@ Different LLM providers may have different capabilities or restrictions:
}
```
### Layer 4: Subagent Restrictions
### Layer 3: Subagent Restrictions
When `isSubagent: true`, additional restrictions are applied to prevent spawned agents from accessing sensitive tools like session management.
@ -280,7 +242,7 @@ Tools configuration can be defined in Agent Profile's `config.json`, allowing di
│ │ coder │ │ reviewer │ │ devops │ │
│ │ │ │ │ │ │ │
│ │ tools: │ │ tools: │ │ tools: │ │
│ │ coding │ │ minimal │ │ full │ │
│ │ allow:fs │ │ deny:* │ │ allow:* │ │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
└─────────┼────────────────┼────────────────┼─────────────────────┘
@ -296,7 +258,7 @@ Each Agent's Profile can define its own tools configuration in `config.json`:
```json
{
"tools": {
"profile": "coding",
"allow": ["group:fs", "group:runtime"],
"deny": ["exec"]
},
"provider": "anthropic",
@ -305,28 +267,3 @@ Each Agent's Profile can define its own tools configuration in `config.json`:
```
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
multica run --profile my-agent --tools-deny exec "list files"
```
## Future Tools
The following tools are planned for future implementation:
- **Browser** - Simplified web automation (screenshot, click, type)
- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- **Image** - Image generation and manipulation
- **Cron** - Scheduled task execution
- **Message** - Inter-agent communication
- **Canvas** - Visual output generation

View file

@ -19,28 +19,22 @@
┌─────────────────────────────────────────────────────────────────┐
4 层策略过滤器 │
3 层策略过滤器 │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 第 1 层: Profile │ │
│ │ 基础工具集: minimal | coding | web | full │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 第 2 层: 全局 Allow/Deny │ │
│ │ 第 1 层: 全局 Allow/Deny │ │
│ │ 通过 CLI 或配置文件进行用户自定义 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 第 3 层: Provider 特定规则 │ │
│ │ 第 2 层: Provider 特定规则 │ │
│ │ 不同 LLM Provider 有不同的规则 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 第 4 层: Subagent 限制 │ │
│ │ 第 3 层: Subagent 限制 │ │
│ │ 子 Agent 的工具访问受限 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
@ -55,20 +49,20 @@
## 可用工具
| 工具 | 名称 | 描述 |
| ------------- | --------------- | --------------------------------------------- |
| 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` | 列出持久化内存中的所有键 |
| 工具 | 名称 | 描述 |
| ------------- | --------------- | ------------------------ |
| 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 目录中。
@ -76,24 +70,13 @@
工具组提供了一次性允许/禁止多个工具的快捷方式:
| 组 | 工具 |
| --------------- | ------------------------------------------------- |
| `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` | 无限制 | 所有工具 |
| 组 | 工具 |
| --------------- | -------------------------------------------------- |
| `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 |
## 使用方法
@ -102,11 +85,8 @@
所有命令使用统一的 `multica` CLI开发时使用 `pnpm multica`)。
```bash
# 使用特定配置文件
multica run --tools-profile coding "list files"
# 最小配置文件 + 允许特定工具
multica run --tools-profile minimal --tools-allow exec "run ls"
# 只允许特定工具
multica run --tools-allow group:fs,group:runtime "list files"
# 禁止特定工具
multica run --tools-deny exec,process "read file.txt"
@ -122,14 +102,11 @@ import { Agent } from './runner.js';
const agent = new Agent({
tools: {
// 第 1 层: 基础配置文件
profile: 'coding',
// 第 1 层: 全局 allow/deny
allow: ['group:fs', 'group:runtime', 'web_fetch'],
deny: ['exec'],
// 第 2 层: 全局自定义
allow: ['web_fetch'], // 在 coding 配置文件基础上添加 web_fetch
deny: ['exec'], // 但禁止 exec
// 第 3 层: Provider 特定规则
// 第 2 层: Provider 特定规则
byProvider: {
google: {
deny: ['exec', 'process'], // Google 模型不能使用运行时工具
@ -137,7 +114,7 @@ const agent = new Agent({
},
},
// 第 4 层: Subagent 模式
// 第 3 层: Subagent 模式
isSubagent: false,
});
```
@ -150,43 +127,28 @@ const agent = new Agent({
# 列出所有可用工具
multica tools list
# 列出应用配置文件后的工具
multica tools list --profile coding
# 列出带有允许规则的工具
multica tools list --allow group:fs,group:runtime
# 列出带有禁止规则的工具
multica tools list --profile coding --deny exec
multica tools list --deny exec
# 显示所有工具组
multica tools groups
# 显示所有配置文件
multica tools profiles
```
## 策略系统详情
### 第 1 层: Profile
### 第 1 层: 全局 Allow/Deny
配置文件决定了可用工具的基础集合。如果未指定,则所有工具都可用。
用户指定的 allow/deny 列表:
```typescript
// 在 groups.ts 中
export const TOOL_PROFILES = {
minimal: { allow: [] }, // 无工具
coding: { allow: ['group:fs', 'group:runtime'] }, // 文件系统 + 执行
web: { allow: ['group:fs', 'group:runtime', 'group:web'] }, // + 网络
full: {}, // 无限制
};
```
- `allow`: 只有这些工具可用(支持 group:\* 语法)
- `deny`: 这些工具被阻止(优先于 allow
### 第 2 层: 全局 Allow/Deny
如果未指定 `allow` 列表,默认所有工具都可用。
用户指定的 allow/deny 列表,用于修改配置文件的工具集:
- `allow`: 只有这些工具可用(在配置文件基础上添加)
- `deny`: 这些工具被阻止(优先于 allow
### 第 3 层: Provider 特定规则
### 第 2 层: Provider 特定规则
不同的 LLM Provider 可能有不同的能力或限制:
@ -199,7 +161,7 @@ export const TOOL_PROFILES = {
}
```
### 第 4 层: Subagent 限制
### 第 3 层: Subagent 限制
`isSubagent: true` 时,会应用额外的限制,防止子 Agent 访问敏感工具(如会话管理)。
@ -280,7 +242,7 @@ pnpm test src/agent/tools/policy.test.ts
│ │ coder │ │ reviewer │ │ devops │ │
│ │ │ │ │ │ │ │
│ │ tools: │ │ tools: │ │ tools: │ │
│ │ coding │ │ minimal │ │ full │ │
│ │ allow:fs │ │ deny:* │ │ allow:* │ │
│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
└─────────┼────────────────┼────────────────┼─────────────────────┘
@ -296,7 +258,7 @@ pnpm test src/agent/tools/policy.test.ts
```json
{
"tools": {
"profile": "coding",
"allow": ["group:fs", "group:runtime"],
"deny": ["exec"]
},
"provider": "anthropic",
@ -305,28 +267,3 @@ pnpm test src/agent/tools/policy.test.ts
```
详见 [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 工具
multica run --profile my-agent --tools-deny exec "list files"
```
## 未来工具
以下工具计划在未来实现:
- **Browser** - 简化的网页自动化(截图、点击、输入)
- **Session Management** - `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- **Image** - 图像生成和处理
- **Cron** - 定时任务执行
- **Message** - Agent 间通信
- **Canvas** - 可视化输出生成

View file

@ -1,12 +1,10 @@
/**
* Tool groups and profiles for policy-based filtering.
* Tool groups for policy-based filtering.
*
* Groups provide shortcuts for allowing/denying multiple tools at once.
* Profiles are predefined tool sets for common use cases.
* Use "group:name" in allow/deny lists.
*/
export type ToolProfileId = "minimal" | "coding" | "web" | "full";
/**
* Tool name aliases for compatibility.
* Maps alternative names to canonical tool names.
@ -51,29 +49,6 @@ export const TOOL_GROUPS: Record<string, string[]> = {
],
};
/**
* 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.
@ -118,23 +93,3 @@ export function expandToolGroups(list?: string[]): string[] {
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;
}

View file

@ -8,17 +8,14 @@ export { createProcessTool } from "./process.js";
export { createGlobTool } from "./glob.js";
export { createWebFetchTool, createWebSearchTool } from "./web/index.js";
// Tool groups and profiles
// Tool groups
export {
type ToolProfileId,
TOOL_NAME_ALIASES,
TOOL_GROUPS,
TOOL_PROFILES,
DEFAULT_SUBAGENT_TOOL_DENY,
normalizeToolName,
normalizeToolList,
expandToolGroups,
getProfilePolicy,
} from "./groups.js";
// Tool policy system

View file

@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import { filterTools } from "./policy.js";
import { TOOL_PROFILES, expandToolGroups } from "./groups.js";
import { expandToolGroups } from "./groups.js";
// Mock tools for testing
const mockTools = [
@ -36,58 +36,12 @@ describe("tool groups", () => {
});
});
describe("tool profiles", () => {
it("minimal has empty allow", () => {
expect(TOOL_PROFILES.minimal.allow).toEqual([]);
});
it("coding has fs and runtime", () => {
expect(TOOL_PROFILES.coding.allow).toEqual(["group:fs", "group:runtime"]);
});
it("full has no restrictions", () => {
expect(TOOL_PROFILES.full.allow).toBeUndefined();
expect(TOOL_PROFILES.full.deny).toBeUndefined();
});
});
describe("filterTools", () => {
it("no config returns all tools", () => {
const filtered = filterTools(mockTools, {});
expect(filtered.length).toBe(mockTools.length);
});
it("minimal profile returns no tools", () => {
const filtered = filterTools(mockTools, { config: { profile: "minimal" } });
expect(filtered.length).toBe(0);
});
it("coding profile returns fs and runtime", () => {
const filtered = filterTools(mockTools, { config: { profile: "coding" } });
const names = filtered.map((t) => t.name).sort();
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
});
it("web profile returns all", () => {
const filtered = filterTools(mockTools, { config: { profile: "web" } });
const names = filtered.map((t) => t.name).sort();
expect(names).toEqual([
"edit",
"exec",
"glob",
"process",
"read",
"web_fetch",
"web_search",
"write",
]);
});
it("full profile returns all tools", () => {
const filtered = filterTools(mockTools, { config: { profile: "full" } });
expect(filtered.length).toBe(mockTools.length);
});
it("deny specific tool", () => {
const filtered = filterTools(mockTools, { config: { deny: ["exec"] } });
const names = filtered.map((t) => t.name);
@ -110,6 +64,22 @@ describe("filterTools", () => {
const names = filtered.map((t) => t.name).sort();
expect(names).toEqual(["read", "write"]);
});
it("allow with group:* syntax", () => {
const filtered = filterTools(mockTools, {
config: { allow: ["group:fs", "group:runtime"] },
});
const names = filtered.map((t) => t.name).sort();
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
});
it("deny with group:* syntax", () => {
const filtered = filterTools(mockTools, {
config: { deny: ["group:web"] },
});
const names = filtered.map((t) => t.name).sort();
expect(names).toEqual(["edit", "exec", "glob", "process", "read", "write"]);
});
});
describe("provider-specific filtering", () => {
@ -149,10 +119,10 @@ describe("subagent restrictions", () => {
});
describe("combined filtering", () => {
it("profile + deny", () => {
it("allow + deny", () => {
const filtered = filterTools(mockTools, {
config: {
profile: "coding",
allow: ["group:fs", "group:runtime"],
deny: ["exec"],
},
});
@ -160,10 +130,10 @@ describe("combined filtering", () => {
expect(names).toEqual(["edit", "glob", "process", "read", "write"]);
});
it("profile + provider deny", () => {
it("allow + provider deny", () => {
const filtered = filterTools(mockTools, {
config: {
profile: "web",
allow: ["group:fs", "group:runtime", "group:web"],
byProvider: {
google: { deny: ["exec"] },
},

View file

@ -1,18 +1,15 @@
/**
* 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
* Supports 3 layers of filtering:
* 1. Global allow/deny - user customization
* 2. Provider-specific - different rules for different LLM providers
* 3. 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";
@ -31,11 +28,9 @@ export interface ToolPolicy {
* Full tool configuration from config file.
*/
export interface ToolsConfig {
/** Base profile (minimal/coding/web/full) */
profile?: ToolProfileId;
/** Additional tools to allow */
/** Tools to allow (supports group:* syntax) */
allow?: string[];
/** Tools to deny */
/** Tools to deny (takes precedence over allow) */
deny?: string[];
/** Provider-specific overrides */
byProvider?: Record<string, ToolPolicy>;
@ -191,12 +186,11 @@ export interface FilterToolsOptions {
}
/**
* Filter tools through the 4-layer policy system.
* Filter tools through the 3-layer policy system.
*
* Layer 1: Profile (base tool set)
* Layer 2: Global allow/deny
* Layer 3: Provider-specific
* Layer 4: Subagent restrictions
* Layer 1: Global allow/deny
* Layer 2: Provider-specific
* Layer 3: Subagent restrictions
*/
export function filterTools(
tools: AgentTool<any>[],
@ -206,15 +200,7 @@ export function filterTools(
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
// Layer 1: Global allow/deny
if (config?.allow || config?.deny) {
const globalPolicy: ToolPolicy = {};
if (config.allow) {
@ -226,7 +212,7 @@ export function filterTools(
filtered = filterToolsByPolicy(filtered, globalPolicy);
}
// Layer 3: Provider-specific
// Layer 2: Provider-specific
if (provider && config?.byProvider) {
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
if (providerPolicy) {
@ -234,7 +220,7 @@ export function filterTools(
}
}
// Layer 4: Subagent restrictions
// Layer 3: Subagent restrictions
if (isSubagent) {
const subagentPolicy = getSubagentPolicy();
filtered = filterToolsByPolicy(filtered, subagentPolicy);
@ -246,7 +232,6 @@ export function filterTools(
/**
* 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
@ -261,12 +246,6 @@ export function mergeToolsConfig(
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) {
@ -321,15 +300,7 @@ export function wouldToolBeAllowed(
): 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
// Layer 1: Global allow/deny
if (config?.allow || config?.deny) {
const globalPolicy: ToolPolicy = {};
if (config.allow) {
@ -343,7 +314,7 @@ export function wouldToolBeAllowed(
}
}
// Layer 3: Provider-specific
// Layer 2: Provider-specific
if (provider && config?.byProvider) {
const providerPolicy = resolveProviderPolicy(config.byProvider, provider);
if (providerPolicy && !isToolAllowed(toolName, providerPolicy)) {
@ -351,7 +322,7 @@ export function wouldToolBeAllowed(
}
}
// Layer 4: Subagent restrictions
// Layer 3: Subagent restrictions
if (isSubagent) {
const subagentPolicy = getSubagentPolicy();
if (!isToolAllowed(toolName, subagentPolicy)) {

View file

@ -11,6 +11,7 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
import { getHub } from "../../hub/hub-singleton.js";
import { buildSubagentSystemPrompt } from "../subagent/announce.js";
import { registerSubagentRun } from "../subagent/registry.js";
import { resolveTools } from "../tools.js";
const SessionsSpawnSchema = Type.Object({
task: Type.String({ description: "The task for the subagent to perform.", minLength: 1 }),
@ -84,12 +85,17 @@ export function createSessionsSpawnTool(
const runId = uuidv7();
const childSessionId = uuidv7();
// Resolve tools for the subagent (with isSubagent=true for policy filtering)
const subagentTools = resolveTools({ isSubagent: true });
const toolNames = subagentTools.map((t) => t.name);
// Build system prompt for the child
const systemPrompt = buildSubagentSystemPrompt({
requesterSessionId,
childSessionId,
label,
task,
tools: toolNames,
});
// Spawn child agent via Hub

View file

@ -21,7 +21,7 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
// 1. Already in whitelist → pass through (reconnection, no confirmation needed)
const allowed = ctx.deviceStore.isAllowed(from);
if (allowed) {
return { hubId: ctx.hubId, agentId: allowed.agentId };
return { hubId: ctx.hubId, agentId: allowed.agentId, isNewDevice: false };
}
// 2. Validate token
@ -42,6 +42,6 @@ export function createVerifyHandler(ctx: VerifyContext): RpcHandler {
// 4. User confirmed → add to whitelist (with device metadata)
ctx.deviceStore.allowDevice(from, result.agentId, meta);
return { hubId: ctx.hubId, agentId: result.agentId };
return { hubId: ctx.hubId, agentId: result.agentId, isNewDevice: true };
};
}