diff --git a/README.md b/README.md index 41535ad..448863c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ In order of precedence: | `send`, `status` | Send email; multi-recipient sends and campaigns are queued and return a task ID | | `inbox` | Inbox addresses, threads, message content, replies, attachments, and inbox webhooks | | `outbox`, `delivery-events`, `webhooks` | Per-recipient sent messages, delivery events, and signed delivery webhooks | -| `drafts`, `agents` | Drafts for human review, and hosted agent profiles and skills | +| `drafts`, `agents` | Drafts for human review, hosted agent profiles and skills, agent runs, and approving or discarding agent drafts | | `templates`, `workflows`, `events` | Versioned templates and lifecycle workflows triggered by events | | `contacts`, `audiences`, `campaigns` | Contacts, CSV import, suppression, audiences, and campaigns | | `preferences`, `marketing-settings` | Recipient subscription state and marketing footer identity | diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 6d50e35..e40fcf8 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -660,3 +660,20 @@ mailly agents show --json | jq '.agent.skills_json // "[]" | fromjson ``` Skills 接口依赖服务端迁移 `0038_agent_skills.sql` 和配套代码;目标 Server 尚未升级时,请先升级服务端或指向本地 Server 验证。 + +### Agent 评估记录与草稿审批 + +托管 Agent 每处理一封来信生成一条 run,包含评估结论;`draft` 模式下还会起草回复,等人审批。 + +```bash +mailly agents runs list --approval-status pending --json +mailly agents runs list --agent-id --thread-id --limit 20 --json +mailly agents runs show --json +mailly agents runs approve --text-file ./reply.txt --yes --json +mailly agents runs approve --text-file - --yes --json < ./reply.txt +mailly agents runs discard --json +``` + +- `list` 支持 `--agent-id / --thread-id / --email-id / --status / --approval-status / --limit`(1–200,默认 100);`--approval-status pending` 找出待审批的草稿。 +- `approve` 把 `--text-file` 中的文本作为回复发出,必须是人类已核对过的最终全文,并带上 `--yes`。先用 `show` 读出 `draft_text` 给人类看,确认收件人和正文后再发送;同一 run 重复批准不会再发第二封。run 仍在处理中或已不在待审批状态时返回 409。 +- `discard` 放弃草稿,不发送。 diff --git a/skills/mailly/reference/commands.md b/skills/mailly/reference/commands.md index 7485ea9..7db3f62 100644 --- a/skills/mailly/reference/commands.md +++ b/skills/mailly/reference/commands.md @@ -63,6 +63,23 @@ mailly agents show --json | jq '.agent.skills_json // "[]" | fromjson Skills 接口依赖服务端迁移 `0038_agent_skills.sql` 和配套代码;目标 Server 尚未升级时,请先升级服务端或指向本地 Server 验证。 +### Agent 评估记录与草稿审批 + +托管 Agent 每处理一封来信生成一条 run,包含评估结论;`draft` 模式下还会起草回复,等人审批。 + +```bash +mailly agents runs list --approval-status pending --json +mailly agents runs list --agent-id --thread-id --limit 20 --json +mailly agents runs show --json +mailly agents runs approve --text-file ./reply.txt --yes --json +mailly agents runs approve --text-file - --yes --json < ./reply.txt +mailly agents runs discard --json +``` + +- `list` 支持 `--agent-id / --thread-id / --email-id / --status / --approval-status / --limit`(1–200,默认 100);`--approval-status pending` 找出待审批的草稿。 +- `approve` 把 `--text-file` 中的文本作为回复发出,必须是人类已核对过的最终全文,并带上 `--yes`。先用 `show` 读出 `draft_text` 给人类看,确认收件人和正文后再发送;同一 run 重复批准不会再发第二封。run 仍在处理中或已不在待审批状态时返回 409。 +- `discard` 放弃草稿,不发送。 + ### 发送邮件 首次执行 Marketing 发送前先检查 Workspace Footer 配置: diff --git a/src/client.ts b/src/client.ts index 5acc91b..8eb7b28 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,6 +24,31 @@ export interface AgentProfile { skills_json?: string; } +export interface AgentRunFilters { + agent_id?: string; + thread_id?: string; + email_id?: string; + status?: string; + approval_status?: string; + limit?: number; +} + +export interface AgentRun { + id: string; + agent_id: string; + agent_name?: string; + inbound_email_id: string; + thread_id?: string; + status: string; + approval_status: string | null; + summary: string | null; + draft_text: string | null; + subject?: string | null; + from_address?: string | null; + error?: string | null; + created_at: number; +} + export type InboxBodyFormat = 'text' | 'markdown' | 'html'; export interface InboxWebhookConfig { @@ -782,6 +807,22 @@ export class MaillyClient { return this.request<{ success: boolean }>('PATCH', `/agents/${encodeURIComponent(id)}`, { body: input }); } + listAgentRuns(filters: AgentRunFilters = {}) { + return this.request<{ success: boolean; runs: AgentRun[] }>('GET', '/agents/runs', { query: { ...filters } }); + } + + getAgentRun(id: string) { + return this.request<{ success: boolean; run: AgentRun }>('GET', `/agents/runs/${encodeURIComponent(id)}`); + } + + approveAgentRun(id: string, text: string) { + return this.request('POST', `/agents/runs/${encodeURIComponent(id)}/approve`, { body: { text } }); + } + + discardAgentRun(id: string) { + return this.request('POST', `/agents/runs/${encodeURIComponent(id)}/discard`); + } + createInboxAddress(input: { prefix?: string; domain: string }) { return this.request<{ success: boolean; diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 834a9c8..981c2f4 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -70,7 +70,7 @@ async function profileInput(opts: AgentOptions): Promise { } export function registerAgents(program: Command) { - const agents = program.command('agents').description('List, create, and update agent profiles and user skills'); + const agents = program.command('agents').description('Manage agent profiles and skills, and review what agents drafted'); agents.command('list', { isDefault: true }).action(async (_opts, command) => { const res = await (await clientFor(command)).listAgents(); if (getOutputFormat() === 'json') return printJSON(res); @@ -113,4 +113,57 @@ export function registerAgents(program: Command) { if (getOutputFormat() === 'json') return printJSON(res); success(`Agent ${id} updated`); }); + + const runs = agents.command('runs').description('Agent runs (one per handled email) and the reply drafts waiting for review'); + runs.command('list', { isDefault: true }) + .option('--agent-id ', 'Only runs of this agent') + .option('--thread-id ', 'Only runs in this email thread') + .option('--email-id ', 'Only the run for this email') + .option('--status ', 'Run status, such as running / done / failed') + .option('--approval-status ', 'pending finds drafts waiting for review') + .option('--limit ', 'Maximum runs to return (1-200, default 100)') + .action(async (opts, command) => { + let limit: number | undefined; + if (opts.limit !== undefined) { + limit = Number(opts.limit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('--limit must be an integer from 1 to 200'); + } + const res = await (await clientFor(command)).listAgentRuns({ + agent_id: opts.agentId, thread_id: opts.threadId, email_id: opts.emailId, + status: opts.status, approval_status: opts.approvalStatus, limit, + }); + if (getOutputFormat() === 'json') return printJSON(res); + if (!res.runs.length) return info('No runs'); + printTable(['ID', 'Agent', 'Status', 'Draft', 'From', 'Subject'], res.runs.map((run) => [ + run.id, run.agent_name ?? run.agent_id, run.status, run.approval_status ?? '-', run.from_address ?? '-', run.subject ?? '-', + ])); + }); + + runs.command('show').argument('').action(async (id, _opts, command) => { + const res = await (await clientFor(command)).getAgentRun(id); + if (getOutputFormat() === 'json') return printJSON(res); + const run = res.run; + info(`Run ${run.id} · ${run.status}`); + info(`Draft: ${run.approval_status ?? 'none'}`); + if (run.error) info(`Error: ${run.error}`); + info(`Summary:\n${run.summary || 'none'}`); + info(`Draft text:\n${run.draft_text || 'none'}`); + }); + + runs.command('approve').argument('') + .requiredOption('--text-file ', 'Exact final reply text the human reviewed (UTF-8); use - to read from stdin') + .requiredOption('--yes', 'Confirm sending this text as the reply') + .action(async (id, opts: { textFile: string }, command) => { + const text = opts.textFile === '-' ? await readStdin() : await readFile(opts.textFile, 'utf8'); + if (!text.trim() || text.length > 512 * 1024) throw new Error('Reply text must be non-empty and at most 512K characters'); + const res = await (await clientFor(command)).approveAgentRun(id, text); + if (getOutputFormat() === 'json') return printJSON(res); + success(`Reply sent for run ${id}`); + }); + + runs.command('discard').argument('').action(async (id, _opts, command) => { + const res = await (await clientFor(command)).discardAgentRun(id); + if (getOutputFormat() === 'json') return printJSON(res); + success(`Draft of run ${id} discarded`); + }); } diff --git a/tests/agents.test.mjs b/tests/agents.test.mjs index a6c26b9..8590225 100644 --- a/tests/agents.test.mjs +++ b/tests/agents.test.mjs @@ -116,3 +116,48 @@ test('server validation errors surface as JSON and nonzero exit', async (t) => { }); assert.equal(requests.length, 1); }); + +test('runs list passes filters, show/discard escape IDs, approve sends the reviewed text', async (t) => { + const { requests, dir, run } = await fixture(t, { success: true, runs: [] }); + assert.deepEqual(JSON.parse((await run(['runs'])).stdout), { success: true, runs: [] }); + assert.equal(requests[0].path, '/agents/runs'); + await run(['runs', 'list', '--agent-id', 'agent-1', '--approval-status', 'pending', '--limit', '20']); + assert.equal(requests[1].path, '/agents/runs?agent_id=agent-1&approval_status=pending&limit=20'); + await run(['runs', 'show', 'r/1']); + assert.deepEqual([requests[2].method, requests[2].path], ['GET', '/agents/runs/r%2F1']); + const reply = '您好,\n已收到,明天回复。\n'; + await writeFile(join(dir, 'reply.txt'), reply); + await run(['runs', 'approve', 'run-1', '--text-file', join(dir, 'reply.txt'), '--yes']); + assert.deepEqual(requests[3], { method: 'POST', path: '/agents/runs/run-1/approve', auth: 'Bearer local-test-key', body: { text: reply } }); + await run(['runs', 'approve', 'run-1', '--text-file', '-', '--yes'], reply); + assert.deepEqual(requests[4].body, { text: reply }); + await run(['runs', 'discard', 'run-1']); + assert.deepEqual([requests[5].method, requests[5].path, requests[5].body], ['POST', '/agents/runs/run-1/discard', undefined]); +}); + +test('runs approve requires --yes and non-blank text; bad limit fails before the API', async (t) => { + const { requests, dir, run } = await fixture(t); + await writeFile(join(dir, 'reply.txt'), 'ok'); + for (const [args, stdin] of [ + [['runs', 'approve', 'run-1', '--text-file', join(dir, 'reply.txt')], ''], + [['runs', 'approve', 'run-1', '--yes'], ''], + [['runs', 'approve', 'run-1', '--text-file', '-', '--yes'], ' \n'], + [['runs', 'list', '--limit', '0'], ''], + [['runs', 'list', '--limit', 'abc'], ''], + ]) { + await assert.rejects(run(args, stdin), (error) => { + assert.equal(error.code, 1); + assert.equal(JSON.parse(error.stdout).success, false); + return true; + }); + } + assert.deepEqual(requests, []); +}); + +test('approve surfaces a 409 from the server', async (t) => { + const { run } = await fixture(t, { success: false, error: 'This draft is no longer awaiting approval' }, 409); + await assert.rejects(run(['runs', 'approve', 'run-1', '--text-file', '-', '--yes'], 'hi'), (error) => { + assert.match(JSON.parse(error.stdout).error, /no longer awaiting approval/); + return true; + }); +});