feat(ai): policy is read from the owning agent (Phase 4)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-15 21:43:04 +02:00
parent 968e08059f
commit f7426ab40f
3 changed files with 49 additions and 2 deletions

View file

@ -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 {