Merge branch 'main' into exec-approvals
This commit is contained in:
commit
3c303df8f1
61 changed files with 4538 additions and 1024 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s
|
|||
label: params.label,
|
||||
task: params.task,
|
||||
},
|
||||
tools: params.tools,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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** - 可视化输出生成
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue