diff --git a/docs/adr/0001-brain-policy-and-read-only-subagents.md b/docs/adr/0001-brain-policy-and-read-only-subagents.md new file mode 100644 index 0000000..b1a237e --- /dev/null +++ b/docs/adr/0001-brain-policy-and-read-only-subagents.md @@ -0,0 +1,37 @@ +# ADR-0001: Brain policies with host-owned bounded specialists + +- Date: 2026-10-20 +- Status: Accepted + +## Context + +Brain defines project instructions, permission decisions and subagent roles. The coding +host already owns project isolation, worktree mutation, operator approvals and model +routing. Executing Brain-defined roles must preserve those boundaries. + +## Decision + +Opt in per project using an operator-owned Brain policy outside repositories. Use a fixed +CLI argument vector to query Brain; the model cannot select policies or executables. +Brain can tighten host decisions but never widen them. Bind approvals to both policies, +the active worktree and the exact child definition/task. Recheck policy snapshots before +execution and fail closed on invalid responses or a missing required Brain executable. + +The host runs first-stage specialists read-only. Children reuse the parent's model and +endpoint, get only allowed project reads/searches, cannot spawn children, and have explicit +concurrency, step, tool-call, output and time limits. Child usage is recorded through the +host's existing metadata-only writer. Brain itself remains a read-only contract provider. + +## Alternatives + +- Execute tools inside Brain: duplicates the host's approval and worktree infrastructure. +- Allow role-specific endpoints: could bypass the project's existing egress decision. +- Enable child shell/write access immediately: requires a broader execution and recovery + contract; defer until the read-only lifecycle is established. +- Replace host policy with Brain allow: would loosen existing operator protections. + +## Consequences + +Existing projects behave as before until opted in. A compatible Brain build is required. +Read-only children cannot implement code changes themselves; the parent performs approved +mutations. Persistent job resume and background scheduling remain outside this increment. diff --git a/docs/brain-agent-policy.md b/docs/brain-agent-policy.md new file mode 100644 index 0000000..dc43b1f --- /dev/null +++ b/docs/brain-agent-policy.md @@ -0,0 +1,71 @@ +# Brain policy at coding-tool dispatch + +Agent Control remains the tool executor. Brain's read-only `agent check` command can +add an operator-selected policy to `run_command`, `write_file`, and `edit_file`. + +The operator opts a project in by creating +`/.local/agent/brain-policies/.yaml` outside product repositories. +Use a Brain build providing `agent check`; `AGENT_BRAIN_BINARY` selects its executable. +Nothing in a model's tool arguments can select an executable or a policy file. + +```yaml +default: ask +rules: + - tool: write + path: "private/**" + decision: deny +``` + +Brain allow never overrides the existing workspace policy. Brain ask requires the +existing single-use approval flow even when the workspace permits automatic execution. +Brain deny, missing executable, malformed output, timeout and invalid policy all prevent +execution. Projects without this optional policy retain their existing behavior. + +Approval hashes include both policies and the worktree. Every dispatch evaluates Brain, +and policy snapshots are rechecked before execution. A changed policy invalidates the +request instead of silently using its old decision. These checks complement filesystem +isolation; neither shell-prefix policies nor approval hashes are an operating-system +sandbox. + +This integration currently gates coding mutations; task-record mutations retain their +existing workspace gate. Read-only subagent execution is described below when enabled. + +## Bounded read-only specialists + +For opted-in project chats the host exposes `project_instructions`, `list_subagents` +and `run_subagent`. Definitions are loaded from the active project's or worktree's +`.brain/agents` directory through Brain, including its symlink confinement checks. +Invalid registries fail closed. The project instruction tool returns Brain's rendered +guidance, which explicitly grants no tool permissions. + +Children always reuse the parent's model and egress-approved endpoint. Role model +preferences cannot switch endpoints. Each child has a separate prompt, no conversation +history from other projects, and only the intersection of the role's tool list with +`read_file` and `search_code`. Every child read requires Brain allow under the role's +effective policy and must stay inside the project; ask is returned to the parent as a +refusal, not automatically approved. Children receive no delegation, shell, or mutation +tools. Definitions requesting those tools are intentionally narrowed. + +Starting a child goes through both policies and the existing approval flow. The approval +input hash includes the role definition, task and role name. Host bounds are two concurrent +children globally, five model steps (or the role's smaller limit), twelve read/search calls, +30 seconds, 2048 output tokens per model step and a 16 KiB returned summary. Request +cancellation propagates to the model call. Completed child token usage is recorded separately +through the parent's normal metadata-only usage writer. No prompt text enters usage logs. + +Example role at `.brain/agents/reviewer.md`: + +```markdown +--- +name: reviewer +description: Review source and identify missing tests +tools: [read, search] +max_turns: 3 +--- +Read the relevant files and return findings with paths. Do not modify files. +``` + +For read-only child operation an operator policy can allow `read` and `search` while +leaving `subagent` at ask. The workspace policy must also permit or approve `run_subagent`. +This increment does not provide child write access, recursive delegation, a background job +queue, or persistent resume after a process restart. diff --git a/server/index.ts b/server/index.ts index 9660824..860b73f 100644 --- a/server/index.ts +++ b/server/index.ts @@ -290,7 +290,16 @@ app.post(`${AGENT_API}/chat`, async (c) => { : '') : SYSTEM_PROMPT, messages: body.messages, - tools: createChatTools(bridge, scope), + tools: createChatTools(bridge, scope, { + model, signal: c.req.raw.signal, + onUsage: (child) => recordUsage(WORKSPACE_ROOT, { + tool: 'agent-control', endpoint: body.provider ?? config.defaultProvider, + model: body.model ?? config.defaultModel, locality: resolved.locality, + ...(scope ? { project: scope.project, projectRoot: join(WORKSPACE_ROOT, scope.folder) } : {}), + start: child.start, end: child.end, finishReason: child.finishReason, + tokens: { input: child.inputTokens, output: child.outputTokens, cacheRead: 0, cacheWrite: 0 }, + }), + }), stopWhen: stepCountIs(5), }); diff --git a/server/projects.test.ts b/server/projects.test.ts index 303d76d..7a74446 100644 --- a/server/projects.test.ts +++ b/server/projects.test.ts @@ -16,6 +16,19 @@ describe('parseProjectList', () => { ]); }); + it('reads key, client, group, and folder from `ws list` with KIND column', () => { + const output = [ + 'KEY KIND CLIENT GROUP FOLDER STATUS REMOTE', + 'demo-api code acme - projects/acme/demo-api ok git@example.com:acme/demo-api.git', + 'demo-web code acme web projects/acme/web/demo-web missing -', + '', + ].join('\n'); + assert.deepEqual(parseProjectList(output), [ + { key: 'demo-api', client: 'acme', group: '-', folder: 'projects/acme/demo-api' }, + { key: 'demo-web', client: 'acme', group: 'web', folder: 'projects/acme/web/demo-web' }, + ]); + }); + it('ignores colour codes and lines that are not project rows', () => { assert.deepEqual(parseProjectList('\u001b[1mKEY CLIENT GROUP FOLDER\u001b[0m\nno projects yet\n'), []); }); diff --git a/server/projects.ts b/server/projects.ts index 622f498..d1e71ae 100644 --- a/server/projects.ts +++ b/server/projects.ts @@ -8,13 +8,21 @@ export interface ProjectEntry { const KEY_RE = /^[a-z0-9][a-z0-9_-]*$/; -/** Rows of `ws list` (KEY CLIENT GROUP FOLDER STATUS REMOTE), header and noise skipped. */ +/** Rows of `ws list` (with or without KIND column), header and noise skipped. */ export function parseProjectList(output: string): ProjectEntry[] { // eslint-disable-next-line no-control-regex const plain = output.replace(/\u001b\[[0-9;]*m/g, ''); return plain.split('\n').flatMap((line) => { - const [key, client, group, folder] = line.trim().split(/\s+/); - if (!key || !KEY_RE.test(key) || !folder?.startsWith('projects/')) return []; - return [{ key, client, group, folder }]; + const cols = line.trim().split(/\s+/); + if (cols.length < 4) return []; + const key = cols[0]; + if (!key || !KEY_RE.test(key)) return []; + if (cols[3]?.startsWith('projects/')) { + return [{ key, client: cols[1], group: cols[2], folder: cols[3] }]; + } + if (cols[4]?.startsWith('projects/')) { + return [{ key, client: cols[2], group: cols[3], folder: cols[4] }]; + } + return []; }); } diff --git a/server/tools/brain-policy.test.ts b/server/tools/brain-policy.test.ts new file mode 100644 index 0000000..4bba3a5 --- /dev/null +++ b/server/tools/brain-policy.test.ts @@ -0,0 +1,66 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { evaluateBrainPolicy } from './brain-policy.js'; + +it('is inactive unless the operator configures a project Brain policy', async () => { + const root=mkdtempSync(join(tmpdir(),'brain-policy-')); + assert.equal((await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'})).decision,'inactive'); +}); +it('fails closed for configured policy when Brain is unavailable', async () => { + const root=mkdtempSync(join(tmpdir(),'brain-policy-')); + mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true}); + writeFileSync(join(root,'.local/agent/brain-policies/demo.yaml'),'default: deny'); + const result=await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'},'/nonexistent/brain'); + assert.equal(result.decision,'deny'); +}); +it('passes structured command arguments and rejects extra Brain output', async () => { + const root=mkdtempSync(join(tmpdir(),'brain-policy-')); + mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true}); + writeFileSync(join(root,'.local/agent/brain-policies/demo.yaml'),'default: ask'); + const fake=join(root,'brain'); + writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"ask","executed":false}\\n\'\n',{mode:0o700}); + const result=await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'run_command'},{command:'echo',args:['$(touch /tmp/never-run)']},fake); + assert.equal(result.decision,'ask'); + assert.ok(result.fingerprint); + writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"allow","executed":true}\\n\'\n',{mode:0o700}); + assert.equal((await evaluateBrainPolicy({root,project:'demo',cwd:root,action:'write_file'},{path:'a'},fake)).decision,'deny'); +}); + +it('Brain ask forces approval even when workspace permits; policy changes void it', async () => { + const { execFileSync } = await import('node:child_process'); + const { gate } = await import('./gate.js'); + const { decide } = await import('../approvals.js'); + const root=mkdtempSync(join(tmpdir(),'brain-gate-')); + execFileSync('git',['init','-q'],{cwd:root}); + execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.com','commit','--allow-empty','-qm','init'],{cwd:root}); + mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true}); + mkdirSync(join(root,'.local/agent/policies'),{recursive:true}); + const policy=join(root,'.local/agent/brain-policies/demo.yaml'); + writeFileSync(policy,'default: ask'); + writeFileSync(join(root,'.local/agent/policies/demo.json'),JSON.stringify({schema_version:1,default:'change',actions:{}})); + const fake=join(root,'brain'); + writeFileSync(fake,'#!/bin/sh\nprintf \'{"decision":"ask","executed":false}\\n\'\n',{mode:0o700}); + const previous=process.env.AGENT_BRAIN_BINARY;process.env.AGENT_BRAIN_BINARY=fake; + try { + let calls=0; + const deps={root,project:'demo',cwd:root,action:'write_file'}; + const first=await gate(deps,undefined,{path:'a'},async()=>++calls); + assert.ok(!first.ran && 'approvalRequired' in first); + assert.equal(calls,0); + decide(root,'demo',first.requestId,'approved'); + writeFileSync(policy,'default: ask\n# changed'); + const changed=await gate(deps,first.requestId,{path:'a'},async()=>++calls); + assert.ok(!changed.ran && 'error' in changed); + assert.equal(calls,0); + const second=await gate(deps,undefined,{path:'a'},async()=>++calls); + assert.ok(!second.ran && 'approvalRequired' in second); + decide(root,'demo',second.requestId,'approved'); + assert.equal((await gate(deps,second.requestId,{path:'a'},async()=>++calls)).ran,true); + assert.equal(calls,1); + assert.equal((await gate(deps,second.requestId,{path:'a'},async()=>++calls)).ran,false); + assert.equal(calls,1); + } finally {if(previous===undefined)delete process.env.AGENT_BRAIN_BINARY;else process.env.AGENT_BRAIN_BINARY=previous;} +}); diff --git a/server/tools/brain-policy.ts b/server/tools/brain-policy.ts new file mode 100644 index 0000000..6b3fdb9 --- /dev/null +++ b/server/tools/brain-policy.ts @@ -0,0 +1,61 @@ +import { execFile } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { sha256Hex } from '../approvals.js'; +import type { GateDeps } from './gate.js'; + +type BrainDecision = 'inactive' | 'allow' | 'ask' | 'deny'; +export interface BrainEvaluation { decision: BrainDecision; fingerprint?: string; error?: string } +const TOOLS: Record = { run_command: 'shell', write_file: 'write', edit_file: 'edit', read_file: 'read', search_code: 'search', run_subagent: 'subagent' }; + +function snapshot(path: string): string | undefined { + try { + const stat = statSync(path); + if (!stat.isFile() || stat.size > 96 * 1024) throw new Error('invalid policy file'); + return sha256Hex(readFileSync(path, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +/** Operator-selected executable; fixed argv, bounded output, no shell. */ +export function runBrain(cwd: string, args: string[], maxBuffer = 128 * 1024, binary = process.env.AGENT_BRAIN_BINARY || 'brain'): Promise { + return new Promise((resolve, reject) => { + execFile(binary, args, { cwd, timeout: 5000, maxBuffer }, (error, stdout) => error ? reject(error) : resolve(stdout)); + }); +} + +/** Brain may only tighten the workspace gate. Its policy is selected by the operator. */ +export async function evaluateBrainPolicy( + deps: GateDeps, + input: unknown, + binary = process.env.AGENT_BRAIN_BINARY || 'brain', +): Promise { + if (!/^[a-z0-9][a-z0-9_-]*$/.test(deps.project)) return { decision: 'deny', error: 'invalid project key' }; + // Business task mutations retain their existing gate; Brain governs coding tools. + if (!TOOLS[deps.action]) return { decision: 'inactive' }; + const policy = join(deps.root, '.local', 'agent', 'brain-policies', `${deps.project}.yaml`); + try { + const fingerprint = snapshot(policy); + if (!fingerprint) return { decision: 'inactive' }; + const args = ['agent', 'check', '--root', deps.cwd, '--policy', policy, '--tool', TOOLS[deps.action]]; + if (deps.role) args.push('--role', deps.role); + const data = input as Record; + if (deps.action === 'run_command') { + if (typeof data.command !== 'string' || (data.args !== undefined && (!Array.isArray(data.args) || !data.args.every((arg) => typeof arg === 'string')))) return { decision: 'deny', error: 'invalid command input' }; + const tokens = [data.command, ...((data.args as string[] | undefined) ?? [])]; + args.push('--command', tokens.map((token) => /^[a-zA-Z0-9_./:@=+-]+$/.test(token) ? token : JSON.stringify(token)).join(' ')); + } else if (deps.action !== 'run_subagent') { + if (typeof data.path !== 'string') return { decision: 'deny', error: 'invalid path input' }; + args.push('--path', data.path); + } + const output = await runBrain(deps.cwd, args, 16 * 1024, binary); + const result = JSON.parse(output) as Record; + if (result.executed !== false || !['allow', 'ask', 'deny'].includes(String(result.decision)) || Object.keys(result).some((key) => !['decision', 'executed'].includes(key))) throw new Error('invalid Brain response'); + if (snapshot(policy) !== fingerprint) throw new Error('Brain policy changed during evaluation'); + return { decision: result.decision as BrainDecision, fingerprint }; + } catch { + return { decision: 'deny', error: 'Brain policy could not be verified; no action executed' }; + } +} diff --git a/server/tools/chat-tools.ts b/server/tools/chat-tools.ts index 869777f..0d4cdb8 100644 --- a/server/tools/chat-tools.ts +++ b/server/tools/chat-tools.ts @@ -1,5 +1,6 @@ import { isAbsolute, join, normalize, sep } from 'node:path'; -import { jsonSchema, type Tool } from 'ai'; +import { jsonSchema, type Tool, type LanguageModel } from 'ai'; +import { subagentTools, type SubagentUsage } from './subagents.js'; import type { WorkspaceBridge } from './workspace.js'; import { runCommand } from './run-command.js'; import { editFileTool, writeFileTool } from './worktree-mutate.js'; @@ -43,7 +44,7 @@ function withJsonSchema(tools: Record): Record } /** The workspace tools offered to the model in /api/agent/chat. */ -export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope): Record { +export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope, child?: { model: LanguageModel; signal?: AbortSignal; onUsage?: (usage: SubagentUsage) => void }): Record { const tools: Record = { list_projects: { description: 'List all registered projects in the workspace', @@ -228,7 +229,8 @@ export function createChatTools(bridge: WorkspaceBridge, scope?: ChatScope): Rec }, }, }; - return withJsonSchema(scope ? bindToProject(tools, scope) : tools); + const scoped = scope ? bindToProject(tools, scope) : tools; + return withJsonSchema({ ...scoped, ...(scope && child ? subagentTools(scope, scoped, child.model, child.signal, child.onUsage) : {}) }); } /** diff --git a/server/tools/gate.ts b/server/tools/gate.ts index 0844195..be791f7 100644 --- a/server/tools/gate.ts +++ b/server/tools/gate.ts @@ -1,6 +1,7 @@ import { requestApproval, sha256Hex, verifyAndConsume } from '../approvals.js'; import { headSha } from '../git-head.js'; import { classifyAction, policyHash } from '../policy.js'; +import { evaluateBrainPolicy } from './brain-policy.js'; export interface GateDeps { root: string; @@ -8,6 +9,8 @@ export interface GateDeps { /** Where an approval's revision is read from: the coding session's worktree when one is active, else the project's folder in the primary checkout. */ cwd: string; action: string; + /** Optional child role; can only narrow the operator policy. */ + role?: string; } export type GateOutcome = @@ -30,13 +33,22 @@ export async function gate( inputForHash: unknown, run: () => Promise, ): Promise> { + const brain = await evaluateBrainPolicy(deps, inputForHash); + if (brain.decision === 'deny') return { ran: false, error: brain.error ?? 'Brain policy denies this action' }; const decision = classifyAction(deps.root, deps.project, deps.action); if (decision.decision === 'incomplete') { return { ran: false, error: `cannot classify this action: ${decision.reason}` }; } - if (decision.decision === 'allow') { + const workspaceHash = policyHash(deps.root, deps.project); + const guardedRun = async (): Promise> => { + const current = await evaluateBrainPolicy(deps, inputForHash); + if (current.decision !== brain.decision || current.fingerprint !== brain.fingerprint + || policyHash(deps.root, deps.project) !== workspaceHash) { + return { ran: false, error: 'tool policy changed before execution; request the action again' }; + } return { ran: true, result: await run() }; - } + }; + if (decision.decision === 'allow' && brain.decision !== 'ask' && !approvalId) return guardedRun(); const sha = await headSha(deps.cwd); if (!sha) return { ran: false, error: 'this folder is not a git repository: an approval cannot be bound to a revision' }; @@ -45,13 +57,15 @@ export async function gate( headSha: sha, action: deps.action, inputHash: sha256Hex(JSON.stringify(inputForHash)), - policyHash: policyHash(deps.root, deps.project), + policyHash: brain.fingerprint + ? sha256Hex(JSON.stringify([workspaceHash, brain.fingerprint, deps.cwd])) + : workspaceHash, }; if (approvalId) { const verified = verifyAndConsume(deps.root, approvalId, context); if (!verified.ok) return { ran: false, error: `approval ${approvalId}: ${verified.reason}` }; - return { ran: true, result: await run() }; + return guardedRun(); } const request = requestApproval(deps.root, context); diff --git a/server/tools/subagents.test.ts b/server/tools/subagents.test.ts new file mode 100644 index 0000000..dd7dde2 --- /dev/null +++ b/server/tools/subagents.test.ts @@ -0,0 +1,62 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { SubagentBudget } from './subagents.js'; + +it('limits concurrent children, releases slots after failures and rejects nested calls', async () => { + const budget=new SubagentBudget(1); + let release!:()=>void; + const active=budget.run(0,async()=>new Promise((resolve)=>{release=()=>resolve('done');})); + await assert.rejects(()=>budget.run(0,async()=>''),/concurrent/); + await assert.rejects(()=>budget.run(1,async()=>''),/nested/); + release();assert.equal(await active,'done'); + await assert.rejects(()=>budget.run(0,async()=>{throw new Error('failed');}),/failed/); + assert.equal(await budget.run(0,async()=>'recovered'),'recovered'); +}); + +it('runs a specialist with the inherited mock model and refuses unknown roles', async () => { + const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const { execFileSync } = await import('node:child_process'); + const { createMockModel } = await import('../providers/mock.js'); + const { subagentTools } = await import('./subagents.js'); + const root=mkdtempSync(join(tmpdir(),'child-agent-')); + execFileSync('git',['init','-q'],{cwd:root}); + execFileSync('git',['-c','user.name=Test','-c','user.email=test@example.com','commit','--allow-empty','-qm','init'],{cwd:root}); + mkdirSync(join(root,'.local/agent/brain-policies'),{recursive:true}); + mkdirSync(join(root,'.local/agent/policies'),{recursive:true}); + writeFileSync(join(root,'.local/agent/brain-policies/demo.yaml'),'default: allow'); + writeFileSync(join(root,'.local/agent/policies/demo.json'),JSON.stringify({schema_version:1,default:'change',actions:{}})); + const fake=join(root,'brain'); + writeFileSync(fake,`#!/bin/sh +if [ "$2" = "subagents" ]; then +printf '%s\\n' '{"definitions":[{"Name":"reviewer","Description":"Review","Tools":["read","search","write","subagent"],"MaxTurns":12,"Prompt":"Review only."}],"errors":[]}' +else +printf '%s\\n' '{"decision":"allow","executed":false}' +fi +`,{mode:0o700}); + const previous=process.env.AGENT_BRAIN_BINARY;process.env.AGENT_BRAIN_BINARY=fake; + try { + let usage=0; + const tools=subagentTools({root,project:'demo',folder:'.',remote:false},{},createMockModel('inherited'),undefined,()=>{usage++;}); + const result=await tools.run_subagent.execute({role:'reviewer',task:'inspect'} as never) as {content:string;steps:number}; + assert.equal(result.content,'mock(inherited): inspect'); + assert.equal(result.steps,1);assert.equal(usage,1); + const missing=await tools.run_subagent.execute({role:'missing',task:'inspect'} as never) as {error:string}; + assert.equal(missing.error,'unknown subagent role'); + const { MockLanguageModelV4 } = await import('ai/test'); + const usageShape={inputTokens:{total:1,noCache:1,cacheRead:0,cacheWrite:0},outputTokens:{total:1,text:1,reasoning:0}}; + const toolModel=new MockLanguageModelV4({doGenerate:[ + {content:[{type:'tool-call',toolCallId:'escape',toolName:'read_file',input:JSON.stringify({path:'../outside'})}],finishReason:{unified:'tool-calls',raw:undefined},usage:usageShape,warnings:[]}, + {content:[{type:'text',text:'done'}],finishReason:{unified:'stop',raw:undefined},usage:usageShape,warnings:[]}, + ]}); + let reads=0; + const childTools=subagentTools({root,project:'demo',folder:'.',remote:false},{read_file:{description:'Read',parameters:{properties:{path:{type:'string'}},required:['path']},execute:async()=>{reads++;return {content:'should not run'};}}},toolModel); + const child=await childTools.run_subagent.execute({role:'reviewer',task:'inspect'} as never) as {content:string}; + assert.equal(child.content,'done');assert.equal(reads,0); + assert.match(JSON.stringify(toolModel.doGenerateCalls[1].prompt),/confined to the project/); + + const listed=await tools.list_subagents.execute({} as never) as {roles:{tools:string[]}[]}; + assert.deepEqual(listed.roles[0].tools,['read','search']); + } finally {if(previous===undefined)delete process.env.AGENT_BRAIN_BINARY;else process.env.AGENT_BRAIN_BINARY=previous;} +}); diff --git a/server/tools/subagents.ts b/server/tools/subagents.ts new file mode 100644 index 0000000..62d99b1 --- /dev/null +++ b/server/tools/subagents.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'node:fs'; +import { join, relative, isAbsolute, sep } from 'node:path'; +import { generateText, stepCountIs, jsonSchema, type LanguageModel, type Tool } from 'ai'; +import type { ChatScope, PlainTool } from './chat-tools.js'; +import { gate } from './gate.js'; +import { evaluateBrainPolicy, runBrain } from './brain-policy.js'; + +/** A host-owned lifetime budget. Children never receive a delegation tool. */ +export class SubagentBudget { + private active = 0; + private readonly limit: number; + constructor(limit: number) { this.limit = limit; } + async run(depth: number, work: () => Promise): Promise { + if (depth !== 0) throw new Error('nested subagents are disabled'); + if (this.active >= this.limit) throw new Error('concurrent subagent limit reached'); + this.active++; + try { return await work(); } finally { this.active--; } + } +} +const budget = new SubagentBudget(2); +export interface SubagentUsage { start: string; end: string; inputTokens: number; outputTokens: number; finishReason: string } +interface Definition { Name: string; Description: string; Tools: string[]; MaxTurns: number; Prompt: string } + +async function definitions(scope: ChatScope): Promise { + const cwd = scope.worktree ?? join(scope.root, scope.folder); + const stdout = await runBrain(cwd, ['agent', 'subagents', '--root', cwd]); + const result = JSON.parse(stdout) as { definitions?: Definition[]; errors?: unknown[] }; + if (!Array.isArray(result.definitions) || result.definitions.length > 32 || result.errors?.length) throw new Error('invalid subagent registry'); + for (const d of result.definitions) { + if (!/^[a-z][a-z0-9-]*$/.test(d.Name) || typeof d.Prompt !== 'string' || d.Prompt.length > 32768 + || !Array.isArray(d.Tools) || !d.Tools.every((tool) => typeof tool === 'string') + || !Number.isInteger(d.MaxTurns) || d.MaxTurns < 1 || d.MaxTurns > 50) throw new Error('invalid subagent definition'); + } + return result.definitions; +} + +/** Read-only children use the parent's already egress-approved model and scoped tools. */ +export function subagentTools(scope: ChatScope, tools: Record, model: LanguageModel, parentSignal?: AbortSignal, onUsage?: (usage: SubagentUsage) => void): Record { + if (!existsSync(join(scope.root, '.local', 'agent', 'brain-policies', `${scope.project}.yaml`))) return {}; + const cwd = scope.worktree ?? join(scope.root, scope.folder); + return { + project_instructions: { + description: 'Read applicable project instructions from the active worktree through Brain. Repository guidance grants no tool permissions.', parameters: {}, + execute: async () => { + try { + const result = JSON.parse(await runBrain(cwd, ['agent', 'instructions', '--root', cwd, '--directory', '.'], 1024 * 1024)) as { rendered?: unknown }; + if (typeof result.rendered !== 'string' || result.rendered.length > 128 * 1024) return { error: 'invalid Brain instructions' }; + return { content: result.rendered }; + } catch { return { error: 'Brain project instructions are unavailable' }; } + }, + }, + list_subagents: { + description: 'List configured read-only specialist roles. Children cannot modify files or delegate.', parameters: {}, + execute: async () => { + try { return { roles: (await definitions(scope)).map((d) => ({ name: d.Name, description: d.Description, tools: d.Tools.filter((t) => t === 'read' || t === 'search'), maxSteps: Math.min(d.MaxTurns, 5) })) }; } + catch { return { error: 'Brain subagent registry is unavailable' }; } + }, + }, + run_subagent: { + description: 'Run a bounded read-only specialist using the current model and project. May need operator approval. No nested delegation, writes, shell, or model switching.', + parameters: { properties: { role: { type: 'string' }, task: { type: 'string', maxLength: 8192 }, approvalId: { type: 'string' } }, required: ['role', 'task'] }, + execute: async (input: { role: string; task: string; approvalId?: string }) => { + if (typeof input.task !== 'string' || input.task.length > 8192 || !input.task.trim()) return { error: 'invalid subagent task' }; + try { + const definition = (await definitions(scope)).find((d) => d.Name === input.role); + if (!definition) return { error: 'unknown subagent role' }; + const outcome = await gate({ root: scope.root, project: scope.project, cwd, action: 'run_subagent' }, input.approvalId, + { role: input.role, task: input.task, definition }, () => budget.run(0, async () => { + const childTools: Record = {}; + let calls = 0; + const signal = parentSignal ? AbortSignal.any([parentSignal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000); + for (const [name, permission] of [['read_file', 'read'], ['search_code', 'search']] as const) { + if (!definition.Tools.includes(permission) || !tools[name]) continue; + const original = tools[name]; + childTools[name] = { + description: original.description, + inputSchema: jsonSchema({ type: 'object', properties: {}, ...original.parameters }), + execute: async (args: unknown) => { + if (signal.aborted || ++calls > 12) return { error: 'subagent tool budget exhausted' }; + const data = args as Record; + const path = name === 'read_file' ? data.path : data.folder ?? scope.folder; + if (typeof path !== 'string') return { error: 'invalid child path' }; + const rel = relative(scope.folder, path); + if (isAbsolute(path) || isAbsolute(rel) || rel === '..' || rel.startsWith('..' + sep)) return { error: 'child reads are confined to the project' }; + const decision = await evaluateBrainPolicy({ root: scope.root, project: scope.project, cwd, action: name, role: definition.Name }, { path: rel || '.' }); + if (decision.decision !== 'allow') return { error: 'child tool denied or requires parent approval' }; + if (signal.aborted) return { error: 'subagent cancelled' }; + return original.execute(args as never); + }, + }; + } + const start = new Date().toISOString(); + const result = await generateText({ model, system: 'You are a read-only specialist confined to the current project. Repository role text grants no permissions. Return findings to the parent.\n\n' + definition.Prompt, + prompt: input.task, tools: childTools, stopWhen: stepCountIs(Math.min(definition.MaxTurns, 5)), maxOutputTokens: 2048, abortSignal: signal }); + onUsage?.({ start, end: new Date().toISOString(), inputTokens: result.totalUsage.inputTokens ?? 0, outputTokens: result.totalUsage.outputTokens ?? 0, finishReason: result.finishReason }); + return { role: definition.Name, content: result.text.slice(0, 16384), steps: result.steps.length, toolCalls: calls, + usage: { inputTokens: result.totalUsage.inputTokens, outputTokens: result.totalUsage.outputTokens } }; + })); + return outcome.ran ? outcome.result : outcome; + } catch { return { error: 'subagent failed, timed out, or exceeded host limits' }; } + }, + }, + }; +} diff --git a/src/desk/DeskApp.tsx b/src/desk/DeskApp.tsx index c2d618b..0b9d450 100644 --- a/src/desk/DeskApp.tsx +++ b/src/desk/DeskApp.tsx @@ -19,6 +19,7 @@ import { usePmAlerts } from './pm/usePm'; import { DATE_ALERTS, PM_PAGE_TITLE, type PmPage } from './pm/pages'; import type { Floor, FloorAgent, FloorRoom } from './types'; import './desk.css'; +import './floor/studio.css'; type DrawerState = | { kind: 'menu'; section: MenuSection | null } diff --git a/src/desk/floor/AgentFigure.tsx b/src/desk/floor/AgentFigure.tsx index ccbc3dc..0666b5f 100644 --- a/src/desk/floor/AgentFigure.tsx +++ b/src/desk/floor/AgentFigure.tsx @@ -29,6 +29,7 @@ interface Props { */ export function AgentFigure({ state, tool, size = 64 }: Props) { const raised = state === 'waiting'; + const tone = toolTone(tool); return (
+
+
+

LIVE WORKSPACE

Your AI studio

Explore a project studio or select an agent to see its recorded activity.

+
+ {studio && } +
+
+
+ NEEDS YOUR ATTENTION + {allRooms.some((room) => room.pendingApprovals > 0 || room.agents.some((agent) => agent.state === 'waiting' || agent.state === 'warning')) ? allRooms.filter((room) => room.pendingApprovals > 0 || room.agents.some((agent) => agent.state === 'waiting' || agent.state === 'warning')).map((room) => ( + + )) :

No recorded approvals or agent alerts. Your workspace is clear.

} +
@@ -333,6 +348,7 @@ function Room({ room, selectedAgentId, onAgent, onRoom }: { {room.codingSession && session} {room.idle && idle} + {room.agents.length > 0 ? (
{room.agents.map((agent) => ( @@ -364,6 +380,7 @@ function Seat({ agent, selected, onClick }: { agent: FloorAgent; selected: boole {agent.tool} {label} + {agent.detail && {agent.detail}} ); } diff --git a/src/desk/floor/StudioScene.tsx b/src/desk/floor/StudioScene.tsx new file mode 100644 index 0000000..88723dd --- /dev/null +++ b/src/desk/floor/StudioScene.tsx @@ -0,0 +1,27 @@ +import { AgentFigure } from './AgentFigure'; +import { studioLayout } from './studio-layout'; +import type { FloorRoom } from '../types'; + +/** Decorative cutaway; accessible agent controls are rendered alongside it. */ +export function StudioScene({ room }: { room: FloorRoom }) { + const layout = studioLayout(room.key); + return ( + + ); +} diff --git a/src/desk/floor/studio-layout.test.ts b/src/desk/floor/studio-layout.test.ts new file mode 100644 index 0000000..125ae3f --- /dev/null +++ b/src/desk/floor/studio-layout.test.ts @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { studioLayout } from './studio-layout'; + +test('project scenery is stable and independent of project ordering or live status', () => { + const keys = ['alpha', 'beta', 'gamma', '', 'a'.repeat(1000)]; + const first = keys.map(studioLayout); + assert.deepEqual([...keys].reverse().map(studioLayout).reverse(), first); + assert.ok(first.every((layout) => ['atelier', 'library', 'lab'].includes(layout))); + assert.ok(new Set(Array.from({ length: 30 }, (_, i) => studioLayout(`project-${i}`))).size === 3); +}); diff --git a/src/desk/floor/studio-layout.ts b/src/desk/floor/studio-layout.ts new file mode 100644 index 0000000..d89fd70 --- /dev/null +++ b/src/desk/floor/studio-layout.ts @@ -0,0 +1,8 @@ +const LAYOUTS = ['atelier', 'library', 'lab'] as const; + +/** Stable scenery only: this does not infer the project's type or activity. */ +export function studioLayout(project: string): typeof LAYOUTS[number] { + let hash = 0; + for (const character of project) hash = (hash * 31 + character.codePointAt(0)!) >>> 0; + return LAYOUTS[hash % LAYOUTS.length]!; +} diff --git a/src/desk/floor/studio.css b/src/desk/floor/studio.css new file mode 100644 index 0000000..3da68b7 --- /dev/null +++ b/src/desk/floor/studio.css @@ -0,0 +1,65 @@ +/* Studio scenery is decorative; project and agent controls remain upright. */ +.studio-intro { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; padding: 16px 0 24px; } +.studio-intro h1 { font-size: clamp(26px, 4vw, 38px); letter-spacing: -.045em; margin: 4px 0 8px; } +.studio-intro p { color: var(--d-ink-2); margin: 0; max-width: 60ch; } +.studio-eyebrow { color: var(--d-accent); font-size: 10px; font-weight: 700; letter-spacing: .15em; } +.studio-toggle { border: 1px solid var(--d-line); border-radius: 10px; background: var(--d-room); color: var(--d-ink); min-height: 44px; padding: 10px 16px; cursor: pointer; font: inherit; } +.studio-toggle:hover { border-color: var(--d-accent); } +.studio-attention { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 16px; border: 1px solid var(--d-line); border-radius: 14px; background: var(--d-panel); margin-bottom: 18px; } +.studio-attention p { margin: 0; color: var(--d-ink-2); font-size: 12px; } +.studio-attention button { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; border: 1px solid var(--d-line); border-radius: 8px; background: var(--d-room); color: var(--d-ink); font: inherit; padding: 10px 12px; min-height: 44px; cursor: pointer; } +.studio-attention button span { color: var(--d-attn); font-size: 12px; } +.studio-scene, .studio-agent-detail { display: none; } +.studio-mode .floor { grid-template-columns: 1fr; } +.studio-mode .zone { background: var(--d-zone); padding: 24px; border-radius: 20px; } +.studio-mode .zone-rooms, .studio-mode .floor.is-focused .zone-rooms { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: 24px; } +.studio-mode .room { position: relative; min-width: 0; min-height: 320px; padding: 18px; border-radius: 16px; overflow: hidden; background: linear-gradient(145deg, var(--d-room), var(--d-panel)); box-shadow: 0 12px 28px color-mix(in srgb, var(--d-ink) 5%, transparent); } +.studio-mode .room-door { position: relative; z-index: 1; flex-wrap: wrap; } +.studio-mode .room-name { font-size: 15px; overflow-wrap: anywhere; } +.studio-mode .studio-scene { display: block; width: 100%; max-width: 380px; height: 180px; margin: 8px auto -12px; overflow: visible; } +.studio-slab { fill: color-mix(in srgb, var(--d-accent) 12%, var(--d-room)); stroke: var(--d-line); } +.studio-edge { fill: none; stroke: var(--d-wall); stroke-width: 3; stroke-linejoin: round; } +.studio-wall { fill: var(--d-wall); opacity: .55; } +.studio-wall-side { fill: var(--d-wall); opacity: .3; } +.studio-grid { fill: none; stroke: var(--d-accent); opacity: .12; } +.studio-window { fill: var(--d-accent); opacity: .22; } +.studio-table { fill: var(--fig-desk); stroke: var(--fig-leg); } +.studio-table-leg { stroke: var(--fig-leg); stroke-width: 4; } +.studio-screen { fill: var(--fig-term); stroke: var(--d-accent); stroke-width: 2; } +.studio-screen-text { stroke: var(--d-accent); stroke-width: 2; } +.studio-planter { fill: var(--fig-desk); } +.studio-leaves { fill: var(--d-accent); stroke: var(--d-accent); stroke-width: 2; } +.studio-mode .room-desks { position: relative; z-index: 1; gap: 8px; padding: 8px 0; } +.studio-mode .seat { flex: 1 1 100px; max-width: 160px; border: 1px solid var(--d-line); border-radius: 12px; background: var(--d-panel); padding: 10px; } +.studio-mode .seat:hover { border-color: var(--d-accent); } +.studio-mode .seat-state { font-size: 12px; color: var(--d-ink-2); } +.studio-mode .studio-agent-detail { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 11px; color: var(--d-ink-2); max-width: 100%; overflow-wrap: anywhere; } +.studio-mode .room-vacant { height: 48px; } +@media (max-width: 600px) { + .studio-mode .zone { padding: 12px; } + .studio-intro { padding-top: 8px; } + .studio-attention { align-items: stretch; flex-direction: column; } +} +.studio-view-controls { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; } +.studio-scale { display: grid; gap: 4px; font-size: 11px; color: var(--d-ink-2); } +.studio-scale select { min-height: 44px; padding: 8px 28px 8px 12px; border: 1px solid var(--d-line); border-radius: 10px; background: var(--d-room); color: var(--d-ink); font: inherit; font-size: 13px; } +.studio-mode[data-studio-scale="overview"] .zone-rooms { grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); } +.studio-mode[data-studio-scale="overview"] .studio-scene { height: 140px; } +.studio-mode[data-studio-scale="immersive"] .zone-rooms { grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); } +.studio-mode[data-studio-scale="immersive"] .studio-scene { height: 240px; max-width: 480px; } +.studio-furniture { fill: var(--fig-desk); stroke: var(--fig-leg); stroke-linejoin: round; } +.studio-shelf { fill: none; stroke: var(--d-accent); stroke-width: 4; } +.studio-scene-library .studio-window { opacity: .1; } +.studio-scene-lab .studio-slab { fill: color-mix(in srgb, var(--d-accent) 20%, var(--d-room)); } +.studio-mode .fig-skull { stroke: var(--fig-body); stroke-width: 1; } +.studio-mode .fig-torso { stroke: var(--fig-prop); stroke-width: .5; } +.studio-mode .fig-person { filter: drop-shadow(0 2px 1px rgba(0, 0, 0, .15)); } +.studio-mode .seat .fig { width: 80px; height: 80px; } +.studio-scene .fig { overflow: visible; } +.studio-scene .fig-desk, .studio-scene .fig-leg { opacity: .65; } +@media (prefers-reduced-motion: reduce) { + .studio-mode .fig *, .studio-mode .fig { animation: none !important; transition: none !important; } +} +.fig-ground { fill: var(--fig-face); opacity: .08; } +.fig-module { fill: var(--fig-body); stroke: var(--tone); stroke-width: 1.5; stroke-linejoin: round; } +.fig-insignia { fill: var(--tone); opacity: .85; } diff --git a/test/ui/studio.spec.ts b/test/ui/studio.spec.ts new file mode 100644 index 0000000..ea2eef1 --- /dev/null +++ b/test/ui/studio.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from './fixtures.js'; +import { PROJECT } from './workspace.js'; + +test('studio offers compact mode and keeps project and agent access on mobile', async ({ page, deskUrl }) => { + await page.goto(deskUrl); + await expect(page.getByRole('heading', { name: 'Your AI studio' })).toBeVisible(); + const room = page.getByRole('article', { name: `Project ${PROJECT}` }); + await expect(room).toBeVisible(); + const scale = page.getByRole('combobox', { name: 'Studio view scale' }); + await scale.selectOption('immersive'); + await expect(scale).toHaveValue('immersive'); + await room.scrollIntoViewIfNeeded(); + await page.screenshot({ path: '/tmp/agent-control-studio-desktop.png', fullPage: true }); + await scale.selectOption('overview'); + await expect(scale).toHaveValue('overview'); + await page.getByRole('button', { name: 'Switch to compact view' }).click(); + await expect(page.getByRole('button', { name: 'Switch to studio view' })).toHaveAttribute('aria-pressed', 'false'); + await page.getByRole('button', { name: 'Switch to studio view' }).click(); + await expect(page.getByRole('combobox', { name: 'Studio view scale' })).toHaveValue('overview'); + await page.getByRole('combobox', { name: 'Studio view scale' }).selectOption('immersive'); + await page.setViewportSize({ width: 320, height: 844 }); + await expect(room.getByRole('button', { name: PROJECT, exact: true })).toBeVisible(); + await expect(room.getByRole('button', { name: new RegExp(`^agent-control in ${PROJECT}`) })).toBeVisible(); + const bounds = await room.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(320); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await room.scrollIntoViewIfNeeded(); + await page.screenshot({ path: '/tmp/agent-control-studio-mobile.png', fullPage: true }); + await page.getByRole('button', { name: 'Switch to dark mode' }).click(); + await room.scrollIntoViewIfNeeded(); + await page.screenshot({ path: '/tmp/agent-control-studio-dark.png', fullPage: true }); + await room.getByRole('button', { name: PROJECT, exact: true }).click(); + await expect(page.getByRole('heading', { name: PROJECT, exact: true })).toBeVisible(); +});