Add the gated worker subagent kind
worker holds the write tools and routes every write and command through the parent's approval gate: same permission rules, same guard, same prompt, flagged as coming from a subagent. Without an approval channel, as in headless runs, the kind is not offered at all rather than silently downgraded to read-only. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
parent
83f4399e64
commit
41546154fe
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import type { LanguageModelV4CallOptions, LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { z } from 'zod';
|
||||
import { Session } from '../src/session';
|
||||
import { createTaskTool, type SubagentEvent } from '../src/subagent';
|
||||
|
||||
@@ -30,7 +31,7 @@ const text = (body: string): LanguageModelV4StreamPart[] => [
|
||||
const run = (tool: ReturnType<typeof createTaskTool>, input: Record<string, unknown>) =>
|
||||
Promise.resolve(tool.execute!(input as never, { toolCallId: 'x', messages: [] } as never)) as Promise<string>;
|
||||
|
||||
test('a subagent reports start, each step, and end', async () => {
|
||||
test('a subagent reports start, each step, its outcome, and end', async () => {
|
||||
const seen: SubagentEvent[] = [];
|
||||
let n = 0;
|
||||
const model = new MockLanguageModelV4({
|
||||
@@ -46,7 +47,7 @@ test('a subagent reports start, each step, and end', async () => {
|
||||
});
|
||||
|
||||
expect(out).toContain('src/auth.ts:12');
|
||||
expect(seen.map((e) => e.type)).toEqual(['start', 'step', 'end']);
|
||||
expect(seen.map((e) => e.type)).toEqual(['start', 'step', 'result', 'end']);
|
||||
|
||||
const start = seen[0];
|
||||
if (start?.type !== 'start') throw new Error('expected start');
|
||||
@@ -58,7 +59,14 @@ test('a subagent reports start, each step, and end', async () => {
|
||||
expect(step.tool).toBe('grep');
|
||||
expect(step.summary).toBe('login');
|
||||
|
||||
const end = seen[2];
|
||||
// The outcome, not just the call: a panel showing only calls cannot tell a
|
||||
// search that found something from one that found nothing.
|
||||
const result = seen[2];
|
||||
if (result?.type !== 'result') throw new Error('expected result');
|
||||
expect(result.tool).toBe('grep');
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const end = seen[3];
|
||||
if (end?.type !== 'end') throw new Error('expected end');
|
||||
expect(end).toMatchObject({ ok: true, steps: 1 });
|
||||
});
|
||||
@@ -87,7 +95,30 @@ test('explore is the default kind', async () => {
|
||||
expect(start.kind).toBe('explore');
|
||||
});
|
||||
|
||||
test('a subagent only ever gets read-only tools', async () => {
|
||||
test('explore and review can only read, whatever else exists', async () => {
|
||||
for (const kind of ['explore', 'review'] as const) {
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (o) => {
|
||||
seen.push(o);
|
||||
return stream(text('done'));
|
||||
},
|
||||
});
|
||||
|
||||
// With an approval channel present, so this proves the read-only kinds are
|
||||
// restricted by their tool set rather than by the absence of a gate.
|
||||
await run(createTaskTool({ model, approve: async () => true }), { description: 'd', prompt: 'p', kind });
|
||||
|
||||
const names = (seen[0]?.tools ?? []).map((t) => t.name);
|
||||
for (const write of ['write_file', 'edit_file', 'multi_edit', 'bash']) {
|
||||
expect(names, `${kind} must not hold ${write}`).not.toContain(write);
|
||||
}
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('grep');
|
||||
}
|
||||
});
|
||||
|
||||
test('a worker holds the mutating tools as well', async () => {
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (o) => {
|
||||
@@ -96,9 +127,54 @@ test('a subagent only ever gets read-only tools', async () => {
|
||||
},
|
||||
});
|
||||
|
||||
await run(createTaskTool({ model }), { description: 'd', prompt: 'p' });
|
||||
const names = (seen[0]?.tools ?? []).map((t) => t.name).sort();
|
||||
expect(names).toEqual(['glob', 'grep', 'read_file']);
|
||||
await run(createTaskTool({ model, approve: async () => true }), {
|
||||
description: 'd',
|
||||
prompt: 'p',
|
||||
kind: 'worker',
|
||||
});
|
||||
|
||||
const names = (seen[0]?.tools ?? []).map((t) => t.name);
|
||||
for (const write of ['write_file', 'edit_file', 'multi_edit', 'apply_patch', 'bash']) expect(names).toContain(write);
|
||||
});
|
||||
|
||||
test('worker is not offered at all without an approval channel', async () => {
|
||||
const model = new MockLanguageModelV4({ doStream: async () => stream(text('done')) });
|
||||
const readOnly = createTaskTool({ model });
|
||||
|
||||
const schema = JSON.stringify(z.toJSONSchema(readOnly.inputSchema as never));
|
||||
expect(schema).not.toContain('worker');
|
||||
expect(readOnly.description).not.toContain('worker');
|
||||
|
||||
// Asking for one anyway fails loudly. Silently downgrading to explore would
|
||||
// report the task as complete having written nothing.
|
||||
expect(run(readOnly, { description: 'd', prompt: 'p', kind: 'worker' })).rejects.toThrow(
|
||||
/needs an approval channel/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a worker call the user denies is refused and the subagent is told', async () => {
|
||||
const asked: string[] = [];
|
||||
let n = 0;
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async () =>
|
||||
n++ === 0
|
||||
? stream(toolCall('s1', 'write_file', { path: 'out.txt', content: 'x' }))
|
||||
: stream(text('I was denied, so I stopped.')),
|
||||
});
|
||||
|
||||
const out = await run(
|
||||
createTaskTool({
|
||||
model,
|
||||
approve: async ({ toolName }) => {
|
||||
asked.push(toolName);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
{ description: 'write it', prompt: 'write out.txt', kind: 'worker' },
|
||||
);
|
||||
|
||||
expect(asked).toEqual(['write_file']);
|
||||
expect(out).toContain('denied');
|
||||
});
|
||||
|
||||
test('an empty report is stated rather than returned blank', async () => {
|
||||
|
||||
+162
-3
@@ -4,6 +4,8 @@ import type { LanguageModelV4CallOptions, LanguageModelV4StreamPart } from '@ai-
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHost } from '../src/plugins';
|
||||
import { guardPlugin } from '../src/plugins-builtin';
|
||||
import { Session } from '../src/session';
|
||||
import { createTaskTool } from '../src/subagent';
|
||||
|
||||
@@ -75,15 +77,172 @@ test('subagent greps the workspace and returns text to the parent', async () =>
|
||||
|
||||
expect(events).toEqual(['tool-start', 'tool-call', 'tool-result', 'text', 'done']);
|
||||
|
||||
// The subagent gets only read tools, so it can never trigger an approval prompt.
|
||||
const subagentTools = (seen[1]?.tools ?? []).map((t) => t.name).sort();
|
||||
expect(subagentTools).toEqual(['glob', 'grep', 'read_file']);
|
||||
// An explore subagent holds no mutating tool, so it cannot reach the approval
|
||||
// gate at all. That is structural rather than policy.
|
||||
const subagentTools = (seen[1]?.tools ?? []).map((t) => t.name);
|
||||
for (const write of ['write_file', 'edit_file', 'multi_edit', 'bash']) {
|
||||
expect(subagentTools).not.toContain(write);
|
||||
}
|
||||
expect(subagentTools).toContain('grep');
|
||||
|
||||
// Its findings reach the parent as a tool result, not as raw transcript.
|
||||
const toolMessage = session.messages.find((m) => m.role === 'tool');
|
||||
expect(JSON.stringify(toolMessage)).toContain('src/auth.ts:1');
|
||||
}));
|
||||
|
||||
test('a worker subagent edits a file, and the write goes through the parent gate', async () =>
|
||||
inTempDir(async () => {
|
||||
await Bun.write('app.ts', 'const port = 8080;\n');
|
||||
|
||||
const asked: { toolName: string; subagent?: boolean }[] = [];
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (opts) => {
|
||||
const call = seen.length;
|
||||
seen.push(opts);
|
||||
if (call === 0) {
|
||||
return stream(
|
||||
toolCall('c1', 'task', { description: 'bump the port', prompt: 'Set port to 9090 in app.ts.', kind: 'worker' }),
|
||||
);
|
||||
}
|
||||
if (call === 1) {
|
||||
return stream(
|
||||
toolCall('s1', 'edit_file', { path: 'app.ts', oldString: 'const port = 8080;', newString: 'const port = 9090;' }),
|
||||
);
|
||||
}
|
||||
if (call === 2) return stream(text('Changed app.ts: port is now 9090.'));
|
||||
return stream(text('The worker bumped the port.'));
|
||||
},
|
||||
});
|
||||
|
||||
const session: Session = new Session({
|
||||
model,
|
||||
askApproval: async (req) => {
|
||||
asked.push({ toolName: req.toolName, ...(req.subagent ? { subagent: true } : {}) });
|
||||
return 'once';
|
||||
},
|
||||
extraTools: {
|
||||
task: createTaskTool({ model, approve: (r) => session.approveForSubagent()(r) }),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
});
|
||||
|
||||
for await (const _ of session.send('bump the port')) void _;
|
||||
|
||||
// The write reached the user's prompt, flagged as coming from a subagent, and
|
||||
// the file on disk actually changed.
|
||||
expect(asked).toEqual([{ toolName: 'edit_file', subagent: true }]);
|
||||
expect(await Bun.file('app.ts').text()).toBe('const port = 9090;\n');
|
||||
}));
|
||||
|
||||
test('a denied worker write leaves the file alone and the worker reports it', async () =>
|
||||
inTempDir(async () => {
|
||||
await Bun.write('app.ts', 'const port = 8080;\n');
|
||||
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (opts) => {
|
||||
const call = seen.length;
|
||||
seen.push(opts);
|
||||
if (call === 0) {
|
||||
return stream(toolCall('c1', 'task', { description: 'bump it', prompt: 'set 9090', kind: 'worker' }));
|
||||
}
|
||||
if (call === 1) {
|
||||
return stream(
|
||||
toolCall('s1', 'edit_file', { path: 'app.ts', oldString: 'const port = 8080;', newString: 'const port = 9090;' }),
|
||||
);
|
||||
}
|
||||
if (call === 2) return stream(text('The user denied the edit, so I stopped.'));
|
||||
return stream(text('The worker was denied.'));
|
||||
},
|
||||
});
|
||||
|
||||
const session: Session = new Session({
|
||||
model,
|
||||
askApproval: async () => 'deny',
|
||||
extraTools: {
|
||||
task: createTaskTool({ model, approve: (r) => session.approveForSubagent()(r) }),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
});
|
||||
|
||||
for await (const _ of session.send('bump the port')) void _;
|
||||
|
||||
expect(await Bun.file('app.ts').text()).toBe('const port = 8080;\n');
|
||||
expect(JSON.stringify(session.messages)).toContain('denied');
|
||||
}));
|
||||
|
||||
test('a permission rule denies a worker write without ever prompting', async () =>
|
||||
inTempDir(async () => {
|
||||
await Bun.write('app.ts', 'const port = 8080;\n');
|
||||
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (opts) => {
|
||||
const call = seen.length;
|
||||
seen.push(opts);
|
||||
if (call === 0) {
|
||||
return stream(toolCall('c1', 'task', { description: 'bump it', prompt: 'set 9090', kind: 'worker' }));
|
||||
}
|
||||
if (call === 1) {
|
||||
return stream(
|
||||
toolCall('s1', 'edit_file', { path: 'app.ts', oldString: 'const port = 8080;', newString: 'const port = 9090;' }),
|
||||
);
|
||||
}
|
||||
if (call === 2) return stream(text('That edit was refused.'));
|
||||
return stream(text('Refused.'));
|
||||
},
|
||||
});
|
||||
|
||||
const session: Session = new Session({
|
||||
model,
|
||||
askApproval: async () => {
|
||||
throw new Error('a rule that denies must not reach the prompt');
|
||||
},
|
||||
permissions: { edit_file: { '*': 'deny' } },
|
||||
extraTools: {
|
||||
task: createTaskTool({ model, approve: (r) => session.approveForSubagent()(r) }),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
});
|
||||
|
||||
for await (const _ of session.send('bump the port')) void _;
|
||||
expect(await Bun.file('app.ts').text()).toBe('const port = 8080;\n');
|
||||
}));
|
||||
|
||||
test('the guard plugin refuses a worker command, as it does a direct one', async () =>
|
||||
inTempDir(async () => {
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
const model = new MockLanguageModelV4({
|
||||
doStream: async (opts) => {
|
||||
const call = seen.length;
|
||||
seen.push(opts);
|
||||
if (call === 0) {
|
||||
return stream(toolCall('c1', 'task', { description: 'clean', prompt: 'clean the tree', kind: 'worker' }));
|
||||
}
|
||||
if (call === 1) return stream(toolCall('s1', 'bash', { command: 'rm -rf build' }));
|
||||
if (call === 2) return stream(text('That command was refused.'));
|
||||
return stream(text('Refused.'));
|
||||
},
|
||||
});
|
||||
|
||||
const session: Session = new Session({
|
||||
yolo: true,
|
||||
model,
|
||||
askApproval: async () => {
|
||||
throw new Error('the guard must refuse without asking');
|
||||
},
|
||||
plugins: createHost([guardPlugin]),
|
||||
extraTools: {
|
||||
task: createTaskTool({ model, approve: (r) => session.approveForSubagent()(r) }),
|
||||
},
|
||||
autoApprove: ['task'],
|
||||
});
|
||||
|
||||
for await (const _ of session.send('clean up')) void _;
|
||||
expect(await Bun.file('build').exists()).toBe(false);
|
||||
}));
|
||||
|
||||
test('subagent does not see the parent conversation', async () =>
|
||||
inTempDir(async () => {
|
||||
const seen: LanguageModelV4CallOptions[] = [];
|
||||
|
||||
Reference in New Issue
Block a user