diff --git a/lib/ai/agent/tools/propose_change.js b/lib/ai/agent/tools/propose_change.js new file mode 100644 index 0000000..f93e97c --- /dev/null +++ b/lib/ai/agent/tools/propose_change.js @@ -0,0 +1,41 @@ +import { canAct } from '../../../auth/capability.js'; +import * as pendingChanges from '../../../db/repos/pending_changes.js'; + +const ENTITY_TYPES = ['task', 'page', 'project', 'ref', 'resource', 'source_doc']; +const ACTIONS = ['create', 'update', 'delete']; + +export const proposeChangeTool = { + name: 'propose_change', + description: 'Propose a change to the Void. This NEVER applies directly — it creates a draft the owner must approve. Use for creating/updating/deleting tasks, pages, projects, refs, resources.', + input_schema: { + type: 'object', + properties: { + entity_type: { type: 'string', enum: ENTITY_TYPES }, + action: { type: 'string', enum: ACTIONS }, + entity_id: { type: 'string', description: 'uuid; required for update/delete' }, + payload: { type: 'object', description: 'fields for the change' }, + reason: { type: 'string', description: 'one-line rationale shown to the owner' } + }, + required: ['entity_type', 'action', 'payload'] + }, + async handler({ entity_type, action, entity_id, payload, reason }, ctx) { + const tier = canAct(ctx.agent, action, entity_type); + if (tier === 'deny') { + return { error: `not permitted to ${action} ${entity_type}` }; + } + // v1: drafting always routes through approval, even for allow-tier agents. + const change = await pendingChanges.create({ + agent_id: ctx.agent.id, + entity_type, + entity_id: entity_id ?? null, + action, + payload: payload ?? {}, + reason: reason ?? null + }); + return { + pending_change_id: change.id, + applied: false, + summary: `${action} ${entity_type}${payload?.title ? ` "${payload.title}"` : ''}` + }; + } +}; diff --git a/tests/ai/agent/tools/propose_change.test.js b/tests/ai/agent/tools/propose_change.test.js new file mode 100644 index 0000000..7b22242 --- /dev/null +++ b/tests/ai/agent/tools/propose_change.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { pool } from '../../../../lib/db/pool.js'; +import { resetDb } from '../../../helpers/db.js'; +import { migrateUp } from '../../../../lib/db/migrate.js'; +import { proposeChangeTool } from '../../../../lib/ai/agent/tools/propose_change.js'; + +let spaceId, agentId; +beforeAll(async () => { + await resetDb(); await migrateUp(); + ({ rows: [{ id: spaceId }] } = await pool.query( + `INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`)); + ({ rows: [{ id: agentId }] } = await pool.query(`SELECT id FROM agents WHERE slug='companion'`)); +}); + +const suggestAgent = (id) => ({ kind: 'agent', id, capabilities: { read: true, suggest: true, write: false }, scopes: {} }); + +describe('propose_change tool', () => { + it('writes a pending_changes row and never applies', async () => { + const ctx = { agent: suggestAgent(agentId), space_id: spaceId }; + const out = await proposeChangeTool.handler( + { entity_type: 'task', action: 'create', payload: { space_id: spaceId, title: 'Validate CSV' }, reason: 'tracking' }, + ctx + ); + expect(out.pending_change_id).toBeTruthy(); + expect(out.applied).toBe(false); + + const { rows } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [out.pending_change_id]); + expect(rows[0].status).toBe('pending'); + expect(rows[0].agent_id).toBe(agentId); + + const { rows: tasks } = await pool.query(`SELECT * FROM tasks WHERE title='Validate CSV'`); + expect(tasks).toHaveLength(0); // not applied + }); + + it('refuses when the agent cannot even suggest', async () => { + const denied = { kind: 'agent', id: agentId, capabilities: { read: true, suggest: false, write: false }, scopes: {} }; + const out = await proposeChangeTool.handler( + { entity_type: 'task', action: 'create', payload: { title: 'x' } }, + { agent: denied, space_id: spaceId } + ); + expect(out.error).toMatch(/not permitted/i); + }); +});