diff --git a/apps/mana/apps/web/src/lib/data/tools/executor.ts b/apps/mana/apps/web/src/lib/data/tools/executor.ts index 2fed9d2f7..f9cbd0345 100644 --- a/apps/mana/apps/web/src/lib/data/tools/executor.ts +++ b/apps/mana/apps/web/src/lib/data/tools/executor.ts @@ -17,7 +17,9 @@ import { getTool } from './registry'; import { runAsAsync, USER_ACTOR } from '../events/actor'; import { resolvePolicy } from '../ai/policy'; import { createProposal } from '../ai/proposals/store'; +import { getAgent } from '../ai/agents/store'; import type { Actor } from '../events/actor'; +import type { AiPolicy } from '@mana/shared-ai'; import type { ToolResult } from './types'; export async function executeTool( @@ -34,7 +36,18 @@ export async function executeTool( if (!validation.ok) return validation.error; const effectiveActor: Actor = actor ?? USER_ACTOR; - const decision = resolvePolicy(name, effectiveActor); + + // Multi-Agent Workbench (Phase 4): policy lives on the agent. When + // the actor is AI, look up the owning agent and use its policy. If + // the agent record is missing (legacy write, deleted agent, race), + // resolvePolicy falls back to the user-level DEFAULT_AI_POLICY via + // its optional-argument default. + let agentPolicy: AiPolicy | undefined; + if (effectiveActor.kind === 'ai') { + const agent = await getAgent(effectiveActor.principalId); + agentPolicy = agent?.policy; + } + const decision = resolvePolicy(name, effectiveActor, agentPolicy); if (decision === 'deny') { return { diff --git a/services/mana-ai/src/cron/tick.ts b/services/mana-ai/src/cron/tick.ts index 628008f18..bf679c442 100644 --- a/services/mana-ai/src/cron/tick.ts +++ b/services/mana-ai/src/cron/tick.ts @@ -240,7 +240,7 @@ async function planOneMission( const input: AiPlanInput = { mission, resolvedInputs, - availableTools: AI_AVAILABLE_TOOLS, + availableTools: filterToolsByAgentPolicy(AI_AVAILABLE_TOOLS, agent), }; const messages = withAgentContext(buildPlannerPrompt(input), agent); const result = await planner.complete(messages); @@ -265,6 +265,31 @@ async function planOneMission( * Ciphertext fields (`enc:1:…`) are intentionally skipped — the server * doesn't hold the decrypt key; the foreground runner handles those. */ +/** + * Drop tools that the agent's policy denies, so the Planner never even + * sees a tool it can't use. Tools with policy `propose` stay in the + * allowlist (they just get proposed rather than auto-run on the user's + * device), and `auto` tools stay too. A missing policy or missing + * agent leaves the list unchanged. + * + * Resolution order matches the webapp's `resolvePolicy`: + * tools[name] ?? defaultsByModule[tool.module] ?? defaultForAi + */ +function filterToolsByAgentPolicy( + tools: readonly import('@mana/shared-ai').AvailableTool[], + agent: ServerAgent | null +): import('@mana/shared-ai').AvailableTool[] { + if (!agent?.policy) return tools as import('@mana/shared-ai').AvailableTool[]; + const policy = agent.policy; + return tools.filter((t) => { + const byTool = policy.tools[t.name]; + if (byTool) return byTool !== 'deny'; + const byModule = policy.defaultsByModule?.[t.module]; + if (byModule) return byModule !== 'deny'; + return policy.defaultForAi !== 'deny'; + }); +} + function withAgentContext(messages: PlannerMessages, agent: ServerAgent | null): PlannerMessages { if (!agent) return messages; diff --git a/services/mana-ai/src/db/agents-projection.ts b/services/mana-ai/src/db/agents-projection.ts index 8542df9a1..2ab9af04c 100644 --- a/services/mana-ai/src/db/agents-projection.ts +++ b/services/mana-ai/src/db/agents-projection.ts @@ -19,6 +19,7 @@ import type { Sql } from './connection'; import { withUser } from './connection'; +import type { AiPolicy } from '@mana/shared-ai'; export interface ServerAgent { id: string; @@ -31,6 +32,10 @@ export interface ServerAgent { * only injects plaintext. */ systemPrompt?: string; memory?: string; + /** Per-tool auto/propose/deny — drives the server-side tool + * allowlist when Phase 4 wiring is complete. Plaintext. Undefined + * on legacy agent records (pre-Phase-2 writes). */ + policy?: AiPolicy; state: 'active' | 'paused' | 'archived'; maxConcurrentMissions: number; maxTokensPerDay?: number; @@ -238,6 +243,10 @@ function toServerAgent(row: SnapshotRow): ServerAgent { role: String(r.role ?? ''), systemPrompt: typeof r.systemPrompt === 'string' ? r.systemPrompt : undefined, memory: typeof r.memory === 'string' ? r.memory : undefined, + policy: + r.policy && typeof r.policy === 'object' && !Array.isArray(r.policy) + ? (r.policy as AiPolicy) + : undefined, state: (r.state as ServerAgent['state']) ?? 'active', maxConcurrentMissions: Number(r.maxConcurrentMissions ?? 1), maxTokensPerDay: typeof r.maxTokensPerDay === 'number' ? r.maxTokensPerDay : undefined,