Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions container/agent-runner/src/mcp-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import './scheduling.js';
import './interactive.js';
import './agents.js';
import './self-mod.js';
import './reassign-approval.js';
import { startMcpServer } from './server.js';

function log(msg: string): void {
Expand Down
84 changes: 84 additions & 0 deletions container/agent-runner/src/mcp-tools/reassign-approval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* `reassign_approval` MCP tool.
*
* Lets the agent ask the host to re-send a pending approval card to the
* next available admin (skipping anyone already notified), or to a specific
* admin. Fire-and-forget: the tool writes a system action to messages_out
* and the host processes it via the registered `reassign_approval` delivery
* action.
*
* Use when:
* - The admin who received the original card is unavailable.
* - You want to route the approval to a specific admin.
* - You received a "no response" notification and want to escalate.
*/
import { writeMessageOut } from '../db/messages-out.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';

function log(msg: string): void {
console.error(`[mcp-tools] ${msg}`);
}

function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

function ok(text: string) {
return { content: [{ type: 'text' as const, text }] };
}

function err(text: string) {
return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true };
}

export const reassignApproval: McpToolDefinition = {
tool: {
name: 'reassign_approval',
description:
'Re-send a pending approval card to the next available admin, or to a specific admin. ' +
'Use when the original approver is unavailable or unresponsive. Fire-and-forget — ' +
'you will be notified of the result via a system message.',
inputSchema: {
type: 'object' as const,
properties: {
approval_id: {
type: 'string',
description: 'The approval ID to reassign (from the original approval request).',
},
to_user_id: {
type: 'string',
description:
'Optional: specific admin user ID to reassign to (e.g. "telegram:123456"). ' +
'If omitted, the host auto-picks the next eligible admin who has not yet been notified.',
},
},
required: ['approval_id'],
},
},
async handler(args) {
const approvalId = args.approval_id as string | undefined;
if (!approvalId) return err('approval_id is required');

const toUserId = args.to_user_id as string | undefined;

const requestId = generateId();
writeMessageOut({
id: requestId,
kind: 'system',
content: JSON.stringify({
action: 'reassign_approval',
approval_id: approvalId,
...(toUserId ? { to_user_id: toUserId } : {}),
}),
});

log(`reassign_approval: ${requestId} → approvalId=${approvalId}${toUserId ? ` to=${toUserId}` : ''}`);
return ok(
`Reassignment request submitted for approval ${approvalId}. ` +
`You will be notified when the card has been re-delivered${toUserId ? ` to ${toUserId}` : ''}.`,
);
},
};

registerTools([reassignApproval]);
29 changes: 29 additions & 0 deletions src/cli/resources/approvals.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { registerResource } from '../crud.js';
import { reassignApproval } from '../../modules/approvals/index.js';

registerResource({
name: 'approval',
Expand Down Expand Up @@ -48,6 +49,34 @@ registerResource({
},
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
{
name: 'notified_approver_ids',
type: 'json',
description: 'JSON array of user IDs that have already been sent a card for this approval.',
},
],
operations: { list: 'open', get: 'open' },
customOperations: {
reassign: {
access: 'open',
description:
'Re-deliver a pending approval card to the next available admin (skipping already-notified ones), or to a specific admin with --to.',
args: [
{ name: 'id', type: 'string', description: 'Approval ID to reassign.', required: true },
{
name: 'to',
type: 'string',
description: 'Specific user ID to reassign to (optional; auto-picks next admin if omitted).',
},
],
async handler(args) {
const approvalId = args.id as string | undefined;
if (!approvalId) throw new Error('--id is required');
const toUserId = args.to as string | undefined;
const result = await reassignApproval({ approvalId, toUserId });
if (!result.ok) throw new Error(result.message);
return { message: result.message };
},
},
},
});
15 changes: 15 additions & 0 deletions src/db/migrations/019-approvals-notified-approvers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Migration } from './index.js';

/**
* `notified_approver_ids` on `pending_approvals`: JSON array of user IDs that
* have already been sent an approval card for this request. Used by the
* reassign flow to skip already-notified approvers when picking the next one.
* NULL on rows created before this migration; treated as an empty list.
*/
export const migration019: Migration = {
version: 19,
name: 'approvals-notified-approvers',
up(db) {
db.exec(`ALTER TABLE pending_approvals ADD COLUMN notified_approver_ids TEXT;`);
},
};
2 changes: 2 additions & 0 deletions src/db/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { migration016 } from './016-messaging-group-instance.js';
import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js';
import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js';
import { migration018 } from './018-approvals-approver-user-id.js';
import { migration019 } from './019-approvals-notified-approvers.js';

export interface Migration {
version: number;
Expand All @@ -41,6 +42,7 @@ export const migrations: Migration[] = [
migration017,
moduleApprovalsTitleOptions,
migration018,
migration019,
migration008,
migration009,
migration010,
Expand Down
17 changes: 15 additions & 2 deletions src/db/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,11 @@ export function createPendingApproval(
`INSERT OR IGNORE INTO pending_approvals
(approval_id, session_id, request_id, action, payload, created_at,
agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status,
title, options_json, approver_user_id)
title, options_json, approver_user_id, notified_approver_ids)
VALUES
(@approval_id, @session_id, @request_id, @action, @payload, @created_at,
@agent_group_id, @channel_type, @platform_id, @platform_message_id, @expires_at, @status,
@title, @options_json, @approver_user_id)`,
@title, @options_json, @approver_user_id, @notified_approver_ids)`,
)
.run({
session_id: null,
Expand All @@ -170,6 +170,7 @@ export function createPendingApproval(
expires_at: null,
status: 'pending',
approver_user_id: null,
notified_approver_ids: null,
...pa,
});
return result.changes > 0;
Expand All @@ -185,6 +186,18 @@ export function updatePendingApprovalStatus(approvalId: string, status: PendingA
getDb().prepare('UPDATE pending_approvals SET status = ? WHERE approval_id = ?').run(status, approvalId);
}

export function updateApprovalNotifiedApprovers(approvalId: string, notifiedIds: string[]): void {
getDb()
.prepare('UPDATE pending_approvals SET notified_approver_ids = ? WHERE approval_id = ?')
.run(JSON.stringify(notifiedIds), approvalId);
}

export function updateApprovalApprover(approvalId: string, approverUserId: string, notifiedIds: string[]): void {
getDb()
.prepare('UPDATE pending_approvals SET approver_user_id = ?, notified_approver_ids = ? WHERE approval_id = ?')
.run(approverUserId, JSON.stringify(notifiedIds), approvalId);
}

export function deletePendingApproval(approvalId: string): void {
getDb().prepare('DELETE FROM pending_approvals WHERE approval_id = ?').run(approvalId);
}
Expand Down
51 changes: 48 additions & 3 deletions src/modules/approvals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,30 @@
* - An adapter-ready callback that starts the OneCLI manual-approval handler
* once the delivery adapter is set.
* - A shutdown callback that stops the OneCLI handler cleanly.
* - A `reassign_approval` delivery action so agents can request the card
* be re-sent to the next available admin via the `reassign_approval` MCP tool.
*
* Self-mod flows (install_packages, add_mcp_server) moved out to
* `src/modules/self-mod/` in PR #7 — they now register delivery actions
* + approval handlers via this module's public API.
*/
import { onDeliveryAdapterReady } from '../../delivery.js';
import { onDeliveryAdapterReady, registerDeliveryAction } from '../../delivery.js';
import { registerResponseHandler, onShutdown } from '../../response-registry.js';
import { log } from '../../log.js';
import { getPendingApproval, getSession } from '../../db/sessions.js';
import { handleApprovalsResponse } from './response-handler.js';
import { startOneCLIApprovalHandler, stopOneCLIApprovalHandler } from './onecli-approvals.js';
import { notifyAgent, reassignApproval } from './primitive.js';

// Public API re-exports so consumers import from the module root.
export { requestApproval, registerApprovalHandler, notifyAgent } from './primitive.js';
export type { ApprovalHandler, ApprovalHandlerContext, RequestApprovalOptions } from './primitive.js';
export { requestApproval, registerApprovalHandler, notifyAgent, reassignApproval } from './primitive.js';
export type {
ApprovalHandler,
ApprovalHandlerContext,
RequestApprovalOptions,
ReassignApprovalOptions,
ReassignApprovalResult,
} from './primitive.js';

registerResponseHandler(handleApprovalsResponse);

Expand All @@ -34,3 +45,37 @@ onDeliveryAdapterReady((adapter) => {
onShutdown(() => {
stopOneCLIApprovalHandler();
});

// Delivery action: agent-initiated approval reassignment.
// The container writes { action: 'reassign_approval', approval_id, to_user_id? }
// to messages_out. The host picks it up here and re-delivers the card.
registerDeliveryAction('reassign_approval', async (content, session) => {
const approvalId = content.approval_id as string | undefined;
if (!approvalId) {
notifyAgent(session, 'reassign_approval failed: approval_id is required.');
return;
}

// Guard: the approval must belong to this session's agent group.
const approval = getPendingApproval(approvalId);
if (!approval) {
notifyAgent(session, `reassign_approval failed: approval not found: ${approvalId}`);
return;
}
if (approval.session_id) {
const approvalSession = getSession(approval.session_id);
if (!approvalSession || approvalSession.agent_group_id !== session.agent_group_id) {
notifyAgent(session, `reassign_approval failed: approval ${approvalId} does not belong to this agent group.`);
log.warn('reassign_approval: cross-group attempt blocked', {
sessionId: session.id,
approvalId,
approvalAgentGroupId: approvalSession?.agent_group_id,
});
return;
}
}

const toUserId = content.to_user_id as string | undefined;
const result = await reassignApproval({ approvalId, toUserId, agentGroupId: session.agent_group_id });
notifyAgent(session, result.message);
});
29 changes: 29 additions & 0 deletions src/modules/approvals/picks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,33 @@ describe('pickApprovalDelivery', () => {
seedUser('telegram:111', 'telegram');
expect(await pickApprovalDelivery(['telegram:111'], 'telegram')).toBeNull();
});

it('skips excluded user IDs', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');
seedUser('telegram:222', 'telegram');

// telegram:111 is already notified — should fall through to telegram:222
const result = await pickApprovalDelivery(['telegram:111', 'telegram:222'], 'telegram', ['telegram:111']);
expect(result?.userId).toBe('telegram:222');
});

it('returns null when all approvers are excluded', async () => {
await mountMockAdapter('telegram');
seedUser('telegram:111', 'telegram');

const result = await pickApprovalDelivery(['telegram:111'], 'telegram', ['telegram:111']);
expect(result).toBeNull();
});

it('exclude does not affect same-channel tie-break when candidate is not excluded', async () => {
await mountMockAdapter('telegram');
await mountMockAdapter('discord', async (h) => `dm-${h}`);
seedUser('telegram:111', 'telegram');
seedUser('discord:222', 'discord');

// telegram:111 excluded; discord:222 should still be found despite origin being 'discord'
const result = await pickApprovalDelivery(['telegram:111', 'discord:222'], 'discord', ['telegram:111']);
expect(result?.userId).toBe('discord:222');
});
});
Loading