From f7426ab40fa98da8994e79442c24f78d2cf363e8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 21:43:04 +0200 Subject: [PATCH] feat(ai): policy is read from the owning agent (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now AiPolicy lived as a user-global setting consulted for every AI action. With agents as the principal unit of AI behavior, policy belongs on the agent — different agents can be aggressive about tasks but conservative about calendar edits, etc. Webapp (tools/executor.ts): - When an AI actor invokes a tool, the executor looks up the owning agent via getAgent(actor.principalId) and passes agent.policy into resolvePolicy. Falls back to DEFAULT_AI_POLICY when the agent record is missing (legacy write, deleted agent, race) so no tool call can silently bypass the propose/deny path. - resolvePolicy already accepted an optional policy arg, so the call site change is a single line plus the agent load. Server (mana-ai): - ServerAgent gains an optional policy field, projected off the same plaintext JSONB that the webapp writes. - Tick loop filters AI_AVAILABLE_TOOLS through filterToolsByAgentPolicy before passing them to the planner prompt. Resolution order mirrors the webapp: tools[name] → defaultsByModule → defaultForAi; 'deny' drops the tool so the LLM never even sees it. Phase 5 will surface a per-agent policy editor on the agent-detail UI. Until then all agents inherit DEFAULT_AI_POLICY (baked in during createAgent), which means no behavior change for existing users — every tool that was 'propose' before is still 'propose' now, just reached via agent.policy instead of the user-level singleton. Tests: mana-ai 41/41, webapp svelte-check clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/tools/executor.ts | 15 ++++++++++- services/mana-ai/src/cron/tick.ts | 27 ++++++++++++++++++- services/mana-ai/src/db/agents-projection.ts | 9 +++++++ 3 files changed, 49 insertions(+), 2 deletions(-) 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,