Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
17 changes: 17 additions & 0 deletions docs/commands.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,20 @@ mailly agents show <agent-id> --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 <agent-id> --thread-id <thread-id> --limit 20 --json
mailly agents runs show <run-id> --json
mailly agents runs approve <run-id> --text-file ./reply.txt --yes --json
mailly agents runs approve <run-id> --text-file - --yes --json < ./reply.txt
mailly agents runs discard <run-id> --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` 放弃草稿,不发送。
17 changes: 17 additions & 0 deletions skills/mailly/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ mailly agents show <agent-id> --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 <agent-id> --thread-id <thread-id> --limit 20 --json
mailly agents runs show <run-id> --json
mailly agents runs approve <run-id> --text-file ./reply.txt --yes --json
mailly agents runs approve <run-id> --text-file - --yes --json < ./reply.txt
mailly agents runs discard <run-id> --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 配置:
Expand Down
41 changes: 41 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<any>('POST', `/agents/runs/${encodeURIComponent(id)}/approve`, { body: { text } });
}

discardAgentRun(id: string) {
return this.request<any>('POST', `/agents/runs/${encodeURIComponent(id)}/discard`);
}

createInboxAddress(input: { prefix?: string; domain: string }) {
return this.request<{
success: boolean;
Expand Down
55 changes: 54 additions & 1 deletion src/commands/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function profileInput(opts: AgentOptions): Promise<AgentInput> {
}

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);
Expand Down Expand Up @@ -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 <id>', 'Only runs of this agent')
.option('--thread-id <id>', 'Only runs in this email thread')
.option('--email-id <id>', 'Only the run for this email')
.option('--status <status>', 'Run status, such as running / done / failed')
.option('--approval-status <status>', 'pending finds drafts waiting for review')
.option('--limit <n>', '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('<run-id>').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('<run-id>')
.requiredOption('--text-file <path>', '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('<run-id>').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`);
});
}
45 changes: 45 additions & 0 deletions tests/agents.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
Loading