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
+21
-1
@@ -18,7 +18,7 @@ import * as registry from './registry';
|
||||
import { Session } from './session';
|
||||
import { loadSkills } from './skills';
|
||||
import * as store from './store';
|
||||
import { createTaskTool } from './subagent';
|
||||
import { createTaskTool, type SubagentApproval } from './subagent';
|
||||
import { VERSION, versionLine } from './version';
|
||||
import { createAskBridge } from './ui/Ask';
|
||||
import { App, createApprovalBridge, createNoticeBus, createSubagentBus, type AppHooks } from './ui/App';
|
||||
@@ -245,6 +245,20 @@ async function persist(messages: ModelMessage[]): Promise<void> {
|
||||
}
|
||||
|
||||
const bridge = createApprovalBridge();
|
||||
|
||||
/**
|
||||
* Late-bound so the task tool can be built before the Session that owns the gate.
|
||||
*
|
||||
* `createTaskTool` decides whether to offer the `worker` kind from whether an
|
||||
* approval channel exists, so the callback has to be present at construction even
|
||||
* though the Session it delegates to does not exist yet.
|
||||
*/
|
||||
let approveSubagent: SubagentApproval | undefined;
|
||||
const subagentGate: SubagentApproval = (req) => {
|
||||
if (!approveSubagent) throw new Error('the subagent approval channel is not wired yet');
|
||||
return approveSubagent(req);
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
askApproval: bridge.ask,
|
||||
@@ -268,6 +282,10 @@ const session = new Session({
|
||||
task: createTaskTool({
|
||||
model: languageModel ?? unconfiguredModel,
|
||||
...(headless ? {} : { report: subagents.emit }),
|
||||
// A worker's writes go through the parent's rules and the parent's
|
||||
// prompt. Headless has nobody to answer, so `worker` is withheld there
|
||||
// rather than silently running unattended writes.
|
||||
...(headless ? {} : { approve: subagentGate }),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
@@ -280,6 +298,8 @@ const session = new Session({
|
||||
},
|
||||
});
|
||||
|
||||
approveSubagent = session.approveForSubagent();
|
||||
|
||||
async function shutdown(code: number): Promise<never> {
|
||||
clearTimeout(saveTimer);
|
||||
if (session.messages.length > 0) await persist(session.messages);
|
||||
|
||||
+138
-19
@@ -1,24 +1,73 @@
|
||||
import { isStepCount, streamText, tool, type LanguageModel, type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { globTool, grepTool, readFileTool } from './tools';
|
||||
import {
|
||||
applyPatchTool,
|
||||
bashTool,
|
||||
editFileTool,
|
||||
globTool,
|
||||
grepTool,
|
||||
listDirTool,
|
||||
multiEditTool,
|
||||
readFileTool,
|
||||
readManyFilesTool,
|
||||
writeFileTool,
|
||||
} from './tools';
|
||||
import { gitTools } from './tools-git';
|
||||
|
||||
export type SubagentKind = 'explore' | 'review';
|
||||
export type SubagentKind = 'explore' | 'review' | 'worker';
|
||||
|
||||
export type SubagentEvent =
|
||||
| { type: 'start'; id: string; kind: SubagentKind; description: string }
|
||||
| { type: 'step'; id: string; tool: string; summary: string }
|
||||
| { type: 'result'; id: string; tool: string; summary: string; ok: boolean }
|
||||
| { type: 'end'; id: string; ok: boolean; steps: number }
|
||||
| { type: 'error'; id: string; message: string };
|
||||
|
||||
export type SubagentReporter = (event: SubagentEvent) => void;
|
||||
|
||||
const READ_ONLY: ToolSet = { read_file: readFileTool, glob: globTool, grep: grepTool };
|
||||
/**
|
||||
* A subagent's approval callback, supplied by the parent.
|
||||
*
|
||||
* The parent owns the gate. A subagent that could approve its own writes would be
|
||||
* a way to launder a tool call past the user, so `worker` routes every gated call
|
||||
* back through the same rules and the same prompt as a direct call.
|
||||
*/
|
||||
export type SubagentApproval = (req: { toolName: string; input: unknown }) => Promise<boolean>;
|
||||
|
||||
const READ_TOOLS: ToolSet = {
|
||||
read_file: readFileTool,
|
||||
read_many_files: readManyFilesTool,
|
||||
glob: globTool,
|
||||
grep: grepTool,
|
||||
list_dir: listDirTool,
|
||||
...gitTools,
|
||||
};
|
||||
|
||||
/**
|
||||
* `worker` adds the mutating tools. Every one of them is gated by the parent, so
|
||||
* the extra capability is capability to *ask*, not capability to write unasked.
|
||||
*/
|
||||
const WRITE_TOOLS: ToolSet = {
|
||||
write_file: writeFileTool,
|
||||
edit_file: editFileTool,
|
||||
multi_edit: multiEditTool,
|
||||
apply_patch: applyPatchTool,
|
||||
bash: bashTool,
|
||||
};
|
||||
|
||||
const TOOLS: Record<SubagentKind, ToolSet> = {
|
||||
explore: READ_TOOLS,
|
||||
review: READ_TOOLS,
|
||||
worker: { ...READ_TOOLS, ...WRITE_TOOLS },
|
||||
};
|
||||
|
||||
export const subagentToolNames = (kind: SubagentKind): string[] => Object.keys(TOOLS[kind]);
|
||||
|
||||
const PROMPTS: Record<SubagentKind, (cwd: string) => string> = {
|
||||
explore: (cwd) => `You are a research subagent inside a coding agent.
|
||||
|
||||
Workspace root: ${cwd}
|
||||
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
|
||||
You can read, search, and inspect git history. You cannot write files, run commands, or ask questions.
|
||||
|
||||
Find what was asked and report once. Rules:
|
||||
- Give file paths with line numbers, plus a short quote where the quote is the answer.
|
||||
@@ -29,52 +78,107 @@ Find what was asked and report once. Rules:
|
||||
review: (cwd) => `You are a review subagent inside a coding agent.
|
||||
|
||||
Workspace root: ${cwd}
|
||||
Tools: read_file, glob, grep. You cannot write files, run commands, or ask questions.
|
||||
You can read, search, and inspect git history. You cannot write files, run commands, or ask questions.
|
||||
|
||||
Review what was asked and report once. Severity order: incorrect behaviour, missing validation at
|
||||
trust boundaries, security, resource handling, then clarity. For each finding give file, line, what
|
||||
breaks, and the fix. Say plainly when something is correct. Do not invent findings to look thorough.`,
|
||||
|
||||
worker: (cwd) => `You are an implementation subagent inside a coding agent.
|
||||
|
||||
Workspace root: ${cwd}
|
||||
You can read, search, edit files, and run commands. Every write and every command is approved by the
|
||||
user through the parent agent, so a denial is the user's decision: stop and report it, do not work
|
||||
around it.
|
||||
|
||||
You cannot ask questions. If the task is ambiguous, do the smaller reading of it and say in your
|
||||
report which reading you took and what the alternative was.
|
||||
|
||||
Rules:
|
||||
- Do only what was asked. Do not tidy neighbouring code, rename things, or add abstraction.
|
||||
- Read before you write. Match the file's existing style rather than inventing one.
|
||||
- Verify: run the project's tests or build after changing code. "Should work" is not verification.
|
||||
Report the actual command and its outcome.
|
||||
- Report once, and make it a handover: every file you changed with its path, what you ran and what
|
||||
it said, and anything you could not finish. The parent cannot see your transcript.`,
|
||||
};
|
||||
|
||||
/** One line of detail for the panel: the argument that identifies the call. */
|
||||
const summarize = (input: unknown): string => {
|
||||
if (input === null || typeof input !== 'object') return String(input);
|
||||
const o = input as Record<string, unknown>;
|
||||
const first = o['pattern'] ?? o['path'] ?? o['include'];
|
||||
return typeof first === 'string' ? first : JSON.stringify(o).slice(0, 80);
|
||||
const first = o['command'] ?? o['path'] ?? o['pattern'] ?? o['ref'] ?? o['include'];
|
||||
if (typeof first === 'string') return first.length > 80 ? `${first.slice(0, 80)}...` : first;
|
||||
|
||||
const files = o['files'];
|
||||
if (Array.isArray(files)) return `${files.length} file${files.length === 1 ? '' : 's'}`;
|
||||
const edits = o['edits'];
|
||||
if (Array.isArray(edits)) return `${edits.length} edit${edits.length === 1 ? '' : 's'}`;
|
||||
return JSON.stringify(o).slice(0, 80);
|
||||
};
|
||||
|
||||
/** First line of a tool result, so the panel can show an outcome rather than just a call. */
|
||||
const outcome = (output: unknown): string => {
|
||||
const text = typeof output === 'string' ? output : JSON.stringify(output ?? '');
|
||||
const line = text.split('\n').find((l) => l.trim().length > 0) ?? '';
|
||||
return line.length > 70 ? `${line.slice(0, 70)}...` : line;
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
|
||||
/**
|
||||
* Read-only child agent.
|
||||
* Child agent with its own context window.
|
||||
*
|
||||
* It runs its own tool loop and returns one message, so the parent pays for the
|
||||
* findings rather than the whole search transcript. No write, bash, or ask tool is
|
||||
* passed in, which is also why a subagent can never trigger an approval prompt.
|
||||
* findings rather than the whole transcript.
|
||||
*
|
||||
* `explore` and `review` hold no mutating tool, so they cannot reach the approval
|
||||
* gate at all — that is structural, not policy. `worker` does hold them, and every
|
||||
* one is routed back through the parent's `approve` callback. Without a callback,
|
||||
* `worker` is refused rather than silently downgraded to read-only: a caller that
|
||||
* asked for a worker and got an explorer would be told the task failed for the
|
||||
* wrong reason.
|
||||
*/
|
||||
export function createTaskTool(opts: {
|
||||
model: LanguageModel;
|
||||
cwd?: string;
|
||||
maxSteps?: number;
|
||||
report?: SubagentReporter;
|
||||
/** Parent-owned approval for a worker's gated calls. Omit to disable `worker`. */
|
||||
approve?: SubagentApproval;
|
||||
}) {
|
||||
const canWrite = opts.approve !== undefined;
|
||||
|
||||
return tool({
|
||||
description:
|
||||
'Delegate a read-only investigation to a subagent that can read, glob, and grep. Use it for questions ' +
|
||||
'spanning many files ("where is auth handled", "every caller of X") and to keep a long search out of your ' +
|
||||
'own context. The subagent sees none of this conversation, so its prompt must be self-contained. ' +
|
||||
'It returns one text report. Do not delegate something you can answer with a single grep.',
|
||||
'Delegate work to a subagent with its own context window. It sees none of this conversation, so its ' +
|
||||
'prompt must be self-contained, and it returns one text report.\n' +
|
||||
'explore: find and report, read-only. review: critique code for defects, read-only.' +
|
||||
(canWrite
|
||||
? '\nworker: read, edit, and run commands to carry out a change. Its writes and commands are approved by ' +
|
||||
'the user exactly as yours are. Use it for a self-contained task whose intermediate steps you do not ' +
|
||||
'need to see; keep work you must supervise step by step in your own turn.'
|
||||
: '') +
|
||||
'\nDo not delegate something you can answer with a single grep.',
|
||||
inputSchema: z.object({
|
||||
description: z.string().describe('Short label shown to the user, 3-6 words'),
|
||||
prompt: z.string().describe('Self-contained instructions: what to find, where to look, what to return'),
|
||||
prompt: z.string().describe('Self-contained instructions: what to do, where, and what to report'),
|
||||
kind: z
|
||||
.enum(['explore', 'review'])
|
||||
.enum(canWrite ? ['explore', 'review', 'worker'] : ['explore', 'review'])
|
||||
.optional()
|
||||
.describe('explore: find and report. review: critique code for defects. Default explore.'),
|
||||
.describe(
|
||||
canWrite
|
||||
? 'explore: read-only research. review: read-only critique. worker: makes changes. Default explore.'
|
||||
: 'explore: find and report. review: critique code for defects. Default explore.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ description, prompt, kind }, { abortSignal }) => {
|
||||
const id = `sub${++counter}`;
|
||||
const flavour: SubagentKind = kind ?? 'explore';
|
||||
if (flavour === 'worker' && !opts.approve) {
|
||||
throw new Error('The worker kind needs an approval channel, which this session has not provided.');
|
||||
}
|
||||
|
||||
const id = `sub${++counter}`;
|
||||
const report = opts.report;
|
||||
report?.({ type: 'start', id, kind: flavour, description });
|
||||
|
||||
@@ -86,8 +190,18 @@ export function createTaskTool(opts: {
|
||||
model: opts.model,
|
||||
system: PROMPTS[flavour](opts.cwd ?? process.cwd()),
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
tools: READ_ONLY,
|
||||
tools: TOOLS[flavour],
|
||||
stopWhen: isStepCount(opts.maxSteps ?? 20),
|
||||
...(opts.approve
|
||||
? {
|
||||
toolApproval: async ({ toolCall }: { toolCall: { toolName: string; input: unknown } }) => {
|
||||
const approved = await opts.approve!(toolCall);
|
||||
return approved
|
||||
? undefined
|
||||
: { type: 'denied' as const, reason: 'The user denied this call. Stop and report it.' };
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(abortSignal ? { abortSignal } : {}),
|
||||
});
|
||||
|
||||
@@ -102,6 +216,11 @@ export function createTaskTool(opts: {
|
||||
if (part.type === 'tool-call') {
|
||||
steps++;
|
||||
report?.({ type: 'step', id, tool: part.toolName, summary: summarize(part.input) });
|
||||
} else if (part.type === 'tool-result') {
|
||||
report?.({ type: 'result', id, tool: part.toolName, summary: outcome(part.output), ok: true });
|
||||
} else if (part.type === 'tool-error') {
|
||||
const message = part.error instanceof Error ? part.error.message : String(part.error);
|
||||
report?.({ type: 'result', id, tool: part.toolName, summary: outcome(message), ok: false });
|
||||
} else if (part.type === 'text-delta') {
|
||||
text += part.text;
|
||||
} else if (part.type === 'error') {
|
||||
|
||||
Reference in New Issue
Block a user