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 {

View file

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

View file

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