import fs from 'node:fs' import path from 'node:path' import type { AgentFact, AgentRegistryEntry, FileAgentFilesConfig } from './defineAgent' import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean' import { parseAgentMarkdown } from './agentMarkdown' import { parseAgentLocalSkillMarkdown } from './skillMarkdown' import { compileOutcome, type JsonSchemaNode, type OutcomeKind } from './outcomeSchema' import { createLogger } from '@open-mercato/shared/lib/logger' const logger = createLogger('agent_orchestrator').child({ component: 'define-file-agent' }) /** * Re-export so callers that have a loaded entry can register it. File agents * register into the same in-memory registry as `defineAgent` agents. */ export { registerFileAgent } from './defineAgent' /** * Resolved content of one agent-local skill (Phase 3). Plain data so it can be * persisted to the committed manifest and returned by the `load_skill` MCP tool * at runtime without fs access. */ /** One sandboxed skill/tool script carried as plain data (Phase 5). */ export type LoadedScript = { /** Script basename without extension (`scripts/score.ts` → `score`). */ name: string /** Raw TS/JS source, run server-side in the Code Mode sandbox. */ source: string } export type LoadedSkillContent = { /** Skill id (frontmatter `id` or, when absent, the skill dir name). */ id: string /** SKILL.md body → progressive-disclosure instructions. */ instructions: string /** Optional TEMPLATE.md body (output template). */ template?: string /** Optional examples/*.md bodies (few-shot blocks), ordered by filename. */ examples: string[] /** Read-only tool ids the skill contributes to the agent allowlist. */ tools: string[] /** * Optional sandboxed helper scripts (`scripts/*.ts`), Phase 5. Carried as * plain `{ name, source }` data (NOT copied to the OpenCode container); the * agent runs them via the `run_skill_script` MCP tool, server-side in the * Code Mode `isolated-vm` sandbox (no fs/net, 30s cap, per-call ACL). */ scripts: LoadedScript[] } export type LoadedFileAgent = { /** runtime: 'opencode'; schema = compiled OUTCOME resultSchema. */ entry: AgentRegistryEntry /** * Raw JSON-Schema subset from OUTCOME.md (plain data for the committed * manifest). Absent for `kind: artifact`, whose envelope is fixed. */ outcomeSchema?: JsonSchemaNode /** OUTCOME.md kind. */ resultKind: OutcomeKind /** Rendered OpenCode agent .md (frontmatter + body). */ openCodeAgentFile: string /** Sanitized filename-id passed in the message `agent` field. */ openCodeAgentName: string /** * Resolved agent-local skill content (Phase 3). One entry per skill referenced * by AGENT.md `skills:` that resolved to an `agents//skills//` * dir. Each skill's read-only tools are also unioned into `entry.tools`. */ skillsContent: LoadedSkillContent[] /** Phase 4 sub-agent file loading; [] in Phase 1-3 (ids still carried on entry). */ subAgents: LoadedFileAgent[] } /** * OUTCOME.md authoring format (Phase 1): * * --- * kind: proposal # research | proposal | artifact * --- * ```json * { "type": "object", "required": [...], "properties": { ... } } * ``` * * Optional prose guidance after the JSON block … * * The frontmatter carries ONLY `kind`. The result JSON-Schema is authored as the * FIRST fenced ```json code block in the body (robust for a line-based parser — * no YAML dependency). Any text after the JSON block is human guidance. */ const OUTCOME_FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/ const JSON_FENCE_RE = /```json\s*\n([\s\S]*?)\n```/ type OutcomeDescriptor = { kind: OutcomeKind /** * Absent for `kind: artifact`, which has a FIXED envelope: what came back is * the file list the run's own artifact plane captured, and it is the same shape * for a drafted email and a risk report. Declaring a schema that nothing reads * would be worse than declaring none. */ schema?: JsonSchemaNode /** Human guidance after the JSON-Schema fence — injected into the agent prompt. */ prose: string } function parseOutcomeKind(frontmatterBlock: string): OutcomeKind | null { for (const line of frontmatterBlock.split('\n')) { const match = /^kind:\s*(.*)$/.exec(line.trim()) if (!match) continue const value = match[1].trim().replace(/^['"]/, '').replace(/['"]$/, '').trim() if (value === 'research' || value === 'proposal' || value === 'artifact') return value return null } return null } /** Parse OUTCOME.md into a `{ kind, schema }` descriptor. Returns null when malformed. */ function parseOutcomeMarkdown(raw: string): OutcomeDescriptor | null { const frontmatterMatch = OUTCOME_FRONTMATTER_RE.exec(raw) if (!frontmatterMatch) return null const [, frontmatterBlock, body] = frontmatterMatch const kind = parseOutcomeKind(frontmatterBlock) if (!kind) return null const fenceMatch = JSON_FENCE_RE.exec(body) // An artifact agent declares no schema, so its whole OUTCOME.md is guidance. if (!fenceMatch) { return kind === 'artifact' ? { kind, prose: body.trim() } : null } let parsed: unknown try { parsed = JSON.parse(fenceMatch[1]) } catch { return null } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null const prose = body.slice(fenceMatch.index + fenceMatch[0].length).trim() return { kind, schema: parsed as JsonSchemaNode, prose } } /** * Render the "## Outcome contract" prompt section so the agent SEES the exact * shape it must submit (otherwise it guesses and only learns the shape from * submit_outcome's validation errors). Shows the JSON-Schema + the OUTCOME.md prose. */ function renderOutcomeSection(kind: OutcomeKind, schema: JsonSchemaNode | undefined, prose: string): string { if (kind === 'artifact') { return [ '## Outcome contract', 'Write the files you produce into the run\'s `out/` directory, then pass this shape as the `outcome` argument of the submit_outcome tool:', '', '```json', JSON.stringify( { artifacts: [{ fileName: 'report.pdf', mimeType: 'application/pdf', caption: 'What this file is' }], summary: 'One sentence about what you produced.', }, null, 2, ), '```', ...(prose ? ['', prose] : []), ].join('\n') } const target = kind === 'proposal' ? 'the `proposal` object' : 'the `data` object' const schemaJson = JSON.stringify(schema, null, 2) return [ '## Outcome contract', `Your result MUST match this JSON Schema (${target}). Pass it as the \`outcome\` argument of the submit_outcome tool, as a JSON object (not a string):`, '', '```json', schemaJson, '```', ...(prose ? ['', prose] : []), ].join('\n') } function sanitizeAgentName(id: string): string { return id.replace(/[^a-z0-9_-]/gi, '_') } /** * Read the optional `agents//SAMPLE.json` example input, surfaced by the * Playground's "Insert sample" button. Pure JSON (no markdown/frontmatter) so it * needs no custom parser. Returns undefined when the file is absent or invalid * (a malformed sample must never block agent loading — the button just hides). * Must stay in sync with the generator's `discoverSampleInput`. */ function loadSampleInput(dir: string): unknown { const samplePath = path.join(dir, 'SAMPLE.json') if (!fs.existsSync(samplePath)) return undefined try { return JSON.parse(fs.readFileSync(samplePath, 'utf8')) } catch { logger.warn('malformed SAMPLE.json; ignoring', { dir }) return undefined } } const FACT_SOURCES = ['input', 'payload', 'output'] as const const FACT_FORMATS = ['text', 'number', 'boolean', 'percent'] as const /** * Read the optional `agents//FACTS.json` Caseload fact declarations * (`{ "facts": [...] }` or a bare array of `{ label, source, path, format? }`). * Returns undefined when the file is absent or invalid — a malformed file must * never block agent loading; the Caseload panel just falls back to its generic * derivation. Must stay in sync with the generator's `discoverFacts`. */ function loadFacts(dir: string): AgentFact[] | undefined { const factsPath = path.join(dir, 'FACTS.json') if (!fs.existsSync(factsPath)) return undefined try { const parsed = JSON.parse(fs.readFileSync(factsPath, 'utf8')) as unknown const entries = Array.isArray(parsed) ? parsed : parsed && typeof parsed === 'object' && Array.isArray((parsed as { facts?: unknown }).facts) ? ((parsed as { facts: unknown[] }).facts) : null if (!entries) throw new Error('expected an array or { "facts": [...] }') const facts = entries.filter((entry): entry is AgentFact => { const fact = entry as Partial | null return Boolean( fact && typeof fact.label === 'string' && fact.label.trim() && typeof fact.path === 'string' && fact.path.trim() && FACT_SOURCES.includes(fact.source as (typeof FACT_SOURCES)[number]) && (fact.format === undefined || FACT_FORMATS.includes(fact.format as (typeof FACT_FORMATS)[number])), ) }) return facts.length > 0 ? facts : undefined } catch (err) { logger.warn('malformed FACTS.json; ignoring', { dir, error: err instanceof Error ? err.message : String(err), }) return undefined } } function renderToolPermissionLine(toolName: string): string { return ` ${JSON.stringify(toolName)}: true` } /** * File-agent delegation goes through the server-side `agent_orchestrator.delegate_agent` * MCP tool (runs each sub-agent as its own authenticated run), NOT OpenCode's built-in * `task` tool — a `task` sub-session runs in a fresh session that never inherits the * run's session token, so its MCP calls fail with no_active_run. Kept in sync with the * CLI generator (`packages/cli/src/lib/generators/extensions/agent-files.ts`). */ const DELEGATE_AGENT_TOOL_ID = 'agent_orchestrator.delegate_agent' /** * The MCP server key OpenCode connects under (the `mcp.` in opencode.jsonc; * `open-mercato` in OM's container). OpenCode exposes each MCP tool as * `_`, so a file-agent's `tools` * allowlist + prompt MUST reference that id — verified against the running image. */ const OPENCODE_MCP_SERVER_KEY = 'open-mercato' /** Core MCP tools every file agent may call (terminal outcome + progressive disclosure + sandboxed scripts). */ const CORE_FILE_AGENT_TOOL_IDS = [ 'agent_orchestrator.submit_outcome', 'agent_orchestrator.load_skill', 'agent_orchestrator.run_skill_script', ] /** Map an OM tool id (`module.tool`) to OpenCode's MCP tool id (`_module_tool`). */ function toOpenCodeMcpToolId(omToolId: string): string { return `${OPENCODE_MCP_SERVER_KEY}_${omToolId.replace(/\./g, '_')}` } /** * Render the OpenCode agent .md file: YAML-ish frontmatter (description, * optional model/provider, mode, a read-only tools/permission block) plus the * body = instructions + the terminal `submit_outcome` instruction. * * The `tools` block denies everything by default and allows ONLY the agent's * declared read-only MCP tool ids plus `submit_outcome` (propose-only gate; see * findings §C8). Writes (`write`/`edit`/`bash`) are denied via `permission`. * * Sub-agent files (`mode: subagent`, Phase 4) are rendered with NO `task` * allowance and `permission.task: deny`, so they cannot delegate further (depth * cap = 1). The PRIMARY, when it declares sub-agents, additionally allows the * built-in `task` tool, whitelists ONLY its sub-agents' sanitized names under * `permission.task`, and gets a "Sub-agents" prompt section nudging parallel * fan-out (mirrors `defineAgent`'s `subAgentSection`). */ function renderOpenCodeAgentFile(args: { description: string provider?: string model?: string instructions: string tools: string[] /** `'primary'` (default) or `'subagent'` for a generated sub-agent file. */ mode?: 'primary' | 'subagent' /** Sanitized OpenCode names of this primary's reachable sub-agents (empty otherwise). */ subAgentNames?: string[] /** Full registry ids of this primary's reachable sub-agents — the `agentId` values passed to delegate_agent. */ subAgentIds?: string[] /** The OUTCOME contract — injected into the prompt so the agent sees its exact shape. */ outcomeKind: OutcomeKind outcomeSchema?: JsonSchemaNode outcomeProse: string /** File-plane opt-in (#12). When set + `OM_OPENCODE_FILES_ENABLED`, grants sandbox-scoped write/edit/read. */ files?: FileAgentFilesConfig }): string { const mode = args.mode ?? 'primary' const subAgentIds = mode === 'primary' ? (args.subAgentIds ?? []) : [] const hasSubAgents = subAgentIds.length > 0 // The agent's MCP tools = its declared read tools + the core file-agent tools // (submit_outcome / load_skill / run_skill_script). OpenCode names every MCP // tool `_`, so the allowlist key and // the prompt MUST use that form — a dotted OM id never matches and `"*": false` // would silently drop it. `task` is an OpenCode built-in (not prefixed). const omMcpToolIds = Array.from( new Set([...args.tools, ...CORE_FILE_AGENT_TOOL_IDS, ...(hasSubAgents ? [DELEGATE_AGENT_TOOL_ID] : [])]), ) const allowedTools = omMcpToolIds.map(toOpenCodeMcpToolId) const modelLine = args.provider && args.model ? `model: ${args.provider}/${args.model}` : args.model ? `model: ${args.model}` : null // OpenCode's built-in `task` tool is NEVER granted — delegation goes through the // server-side `agent_orchestrator.delegate_agent` tool instead (see DELEGATE_AGENT_TOOL_ID), // so no agent (primary or sub-agent) can spawn a `task` sub-session. const taskPermissionLines = [' task: deny'] // File plane (#12): a primary that opted in gets sandbox-scoped write/edit/read // ONLY when the global kill-switch `OM_OPENCODE_FILES_ENABLED` is on — otherwise // it renders the historical deny frontmatter (BC: default off = unchanged). Sub- // agents never get file tools (read-only, researcher). Isolation is by per-run // sandbox subdir + container lease (Phase 0); this static glob only confines the // agent to the shared workspace root, away from OpenCode internals. const filesActive = mode === 'primary' && !!args.files?.enabled && parseBooleanWithDefault(process.env.OM_OPENCODE_FILES_ENABLED, false) // The frontmatter runs INSIDE the OpenCode container, so the permission glob // uses the CONTAINER-side workspace root (OM_OPENCODE_WORKSPACE_ROOT_CONTAINER, // defaulting to OM_OPENCODE_WORKSPACE_ROOT — equal in the full-docker case). const containerWorkspaceRoot = ( process.env.OM_OPENCODE_WORKSPACE_ROOT_CONTAINER || process.env.OM_OPENCODE_WORKSPACE_ROOT || '/home/opencode/work' ).trim() const workspaceGlob = `${containerWorkspaceRoot}/**` const fileToolLines: string[] = [] const filePermissionLines: string[] = [' write: deny', ' edit: deny', ' bash: deny'] if (filesActive) { fileToolLines.push(' read: true', ' write: true', ' edit: true') if (args.files?.bash) fileToolLines.push(' bash: true') filePermissionLines.length = 0 for (const tool of ['write', 'edit', 'read']) { filePermissionLines.push(` ${tool}:`, ` ${JSON.stringify(workspaceGlob)}: allow`, ' "*": deny') } filePermissionLines.push(args.files?.bash ? ' bash: allow' : ' bash: deny') } const frontmatterLines = [ '---', `description: ${JSON.stringify(args.description)}`, ...(modelLine ? [modelLine] : []), `mode: ${mode}`, 'tools:', ' "*": false', ...allowedTools.map(renderToolPermissionLine), ...fileToolLines, 'permission:', ...filePermissionLines, ...taskPermissionLines, '---', ] const submitToolId = toOpenCodeMcpToolId('agent_orchestrator.submit_outcome') const terminalInstruction = `Finish by calling the \`${submitToolId}\` tool with a value matching the outcome contract (pass it as the \`outcome\` argument). You MUST call the tool — do not answer in prose or emit the result as a code block.` const delegateToolName = toOpenCodeMcpToolId(DELEGATE_AGENT_TOOL_ID) const subAgentSection = hasSubAgents ? `## Sub-agents\nDelegate a sub-task by calling the \`${delegateToolName}\` tool with \`{ agentId: "", input: }\`. Issue multiple \`${delegateToolName}\` calls in the SAME step to fan out in parallel, then combine their results before submitting your outcome. Available sub-agents: ${subAgentIds.join(', ')}.` : null const outcomeSection = renderOutcomeSection(args.outcomeKind, args.outcomeSchema, args.outcomeProse) const body = [ args.instructions.trim(), ...(subAgentSection ? [subAgentSection] : []), outcomeSection, terminalInstruction, ] .filter(Boolean) .join('\n\n') return `${frontmatterLines.join('\n')}\n${body}\n` } /** * Read sandboxed scripts from a `scripts/` dir (`scripts/*.ts` / `*.js`), Phase 5. * Each file's basename (without extension) becomes the script `name`; the raw * source is carried as plain data (run server-side in the sandbox, never copied * to the container). Ordered by filename for determinism. Returns [] when the * dir is absent. */ function loadScriptsDir(scriptsDir: string): LoadedScript[] { if (!fs.existsSync(scriptsDir) || !fs.statSync(scriptsDir).isDirectory()) return [] const scripts: LoadedScript[] = [] const files = fs .readdirSync(scriptsDir) .filter((name) => name.endsWith('.ts') || name.endsWith('.js')) .sort((a, b) => a.localeCompare(b)) for (const file of files) { const source = fs.readFileSync(path.join(scriptsDir, file), 'utf8') scripts.push({ name: file.replace(/\.(ts|js)$/, ''), source }) } return scripts } /** * Read one agent-local skill dir `agents//skills//`: * - `SKILL.md` (required; parsed with `parseAgentLocalSkillMarkdown`, moduleId * tolerated/absent, id defaulting to the dir name), * - optional `TEMPLATE.md`, * - optional `examples/*.md` (ordered by filename), * - optional `scripts/*.ts` (Phase 5; carried as plain source for sandboxed * execution via `run_skill_script`). * * Returns null when SKILL.md is missing or has no parseable frontmatter. */ function loadSkillDir(skillDir: string): LoadedSkillContent | null { const skillPath = path.join(skillDir, 'SKILL.md') if (!fs.existsSync(skillPath)) return null const dirName = path.basename(skillDir) const parsed = parseAgentLocalSkillMarkdown(fs.readFileSync(skillPath, 'utf8'), dirName) if (!parsed) return null const templatePath = path.join(skillDir, 'TEMPLATE.md') const template = fs.existsSync(templatePath) ? fs.readFileSync(templatePath, 'utf8').trim() || undefined : undefined const examplesDir = path.join(skillDir, 'examples') const examples: string[] = [] if (fs.existsSync(examplesDir) && fs.statSync(examplesDir).isDirectory()) { const files = fs .readdirSync(examplesDir) .filter((name) => name.endsWith('.md')) .sort((a, b) => a.localeCompare(b)) for (const file of files) { const body = fs.readFileSync(path.join(examplesDir, file), 'utf8').trim() if (body) examples.push(body) } } return { id: parsed.id, instructions: parsed.instructions, template, examples, tools: parsed.tools, scripts: loadScriptsDir(path.join(skillDir, 'scripts')), } } /** * Load `agents//tools/*.ts` local tool files (Phase 5). Honors §7.4's v1 * guidance with TWO clearly-separated forms, both propose-only-safe: * * 1. REFERENCE form (preferred): a file whose first line is a directive * `// @ref ` (or `// @ref: `). The id is unioned into * the agent's `tools` allowlist exactly like a AGENT.md `tools:` entry, so * it flows through the SAME central ACL + propose-only mutation gate (a * referenced `isMutation:true` tool is rejected at load by `defineAgent`'s * gate). Recommended — no new execution surface. * 2. LOCAL SANDBOXED form: any other `tools/*.ts` file is carried as a script * (`{ name, source }`) registered under the synthetic skill id * `__agent_tools__` and executed through the SAME `isolated-vm` sandbox as * skill scripts via `run_skill_script` (skillId `__agent_tools__`). It can * never touch fs/net/mutation or escape the sandbox, and is `isMutation:false` * at the MCP boundary — so propose-only holds without generating an * unsandboxed native `.opencode/tool/` file that would bypass the MCP ACL gate. * * Returns `{ refs, scripts }`: `refs` union into the allowlist; `scripts` carry * local sandboxed tool sources. */ const TOOL_REF_RE = /^\s*\/\/\s*@ref:?\s+(\S+)/ function loadToolFiles(agentDir: string): { refs: string[]; scripts: LoadedScript[] } { const toolsBase = path.join(agentDir, 'tools') if (!fs.existsSync(toolsBase) || !fs.statSync(toolsBase).isDirectory()) { return { refs: [], scripts: [] } } const refs: string[] = [] const scripts: LoadedScript[] = [] const files = fs .readdirSync(toolsBase) .filter((name) => name.endsWith('.ts') || name.endsWith('.js')) .sort((a, b) => a.localeCompare(b)) for (const file of files) { const source = fs.readFileSync(path.join(toolsBase, file), 'utf8') const firstLine = source.split('\n', 1)[0] ?? '' const refMatch = TOOL_REF_RE.exec(firstLine) if (refMatch) { refs.push(refMatch[1]) continue } scripts.push({ name: file.replace(/\.(ts|js)$/, ''), source }) } return { refs, scripts } } /** Synthetic skill id under which an agent's LOCAL sandboxed tool files register. */ export const AGENT_TOOLS_SKILL_ID = '__agent_tools__' /** * Resolve the agent-local skills referenced by AGENT.md `skills:`. For each id * we look for an `agents//skills//` dir whose resolved skill id * (frontmatter id or dir name) matches. Unknown ids are skipped (warned) so a * stale reference never blocks the agent. */ function loadAgentSkills(agentDir: string, skillIds: string[]): LoadedSkillContent[] { if (skillIds.length === 0) return [] const skillsBase = path.join(agentDir, 'skills') const hasSkillsDir = fs.existsSync(skillsBase) && fs.statSync(skillsBase).isDirectory() const bySkillId = new Map() if (hasSkillsDir) { for (const entry of fs.readdirSync(skillsBase, { withFileTypes: true })) { if (!entry.isDirectory()) continue const loaded = loadSkillDir(path.join(skillsBase, entry.name)) if (loaded) bySkillId.set(loaded.id, loaded) } } const resolved: LoadedSkillContent[] = [] for (const skillId of skillIds) { const skill = bySkillId.get(skillId) if (!skill) { logger.warn('file agent skill not found; skipping', { skillId, skillsBase }) continue } resolved.push(skill) } return resolved } /** * Load one sub-agent dir `agents//sub-agents//` (Phase 4). Sub-agents * are full file agents (AGENT.md + OUTCOME.md) but constrained: each is rendered * `mode: subagent`, read-only, and MUST satisfy two hard rules (matching the * in-process `delegate_agent` contract): * * 1. OUTCOME `kind` MUST be `research` (sub-agents inform; only the primary * proposes); * 2. it MUST NOT itself declare `subAgents` (depth cap = 1). * * A malformed sub-agent dir or a constraint violation THROWS with a clear * `[internal]` reason naming the dir (so the generator fails loudly), rather than * returning null — a present-but-invalid sub-agent must never be silently dropped. */ function loadSubAgentDir(dir: string): LoadedFileAgent { const agentMdPath = path.join(dir, 'AGENT.md') const outcomePath = path.join(dir, 'OUTCOME.md') if (!fs.existsSync(agentMdPath) || !fs.existsSync(outcomePath)) { throw new Error(`[internal] malformed sub-agent at ${dir}: both AGENT.md and OUTCOME.md are required`) } const agent = parseAgentMarkdown(fs.readFileSync(agentMdPath, 'utf8')) if (!agent) { throw new Error(`[internal] malformed AGENT.md at ${dir}: missing id/label/description`) } const outcome = parseOutcomeMarkdown(fs.readFileSync(outcomePath, 'utf8')) if (!outcome) { throw new Error(`[internal] malformed OUTCOME.md at ${dir}: missing kind or JSON-Schema block`) } if (outcome.kind !== 'research') { throw new Error( `[internal] sub-agent at ${dir} must be research (kind: research); sub-agents inform, only the primary proposes`, ) } if (agent.subAgents.length > 0) { throw new Error( `[internal] sub-agent at ${dir} may not declare subAgents (depth cap = 1); sub-agents may not delegate further`, ) } let resultSchema try { resultSchema = compileOutcome({ kind: outcome.kind, schema: outcome.schema }).resultSchema } catch (err) { const detail = err instanceof Error ? err.message : String(err) throw new Error(`[internal] malformed OUTCOME.md at ${dir}: ${detail}`) } const skillsContent = loadAgentSkills(dir, agent.skills) const skillTools = skillsContent.flatMap((skill) => skill.tools) const effectiveTools = Array.from(new Set([...agent.tools, ...skillTools])) const openCodeAgentName = sanitizeAgentName(agent.id) const entry: AgentRegistryEntry = { id: agent.id, moduleId: '', resultKind: outcome.kind, schema: resultSchema, tools: effectiveTools, skills: agent.skills, subAgents: [], label: agent.label, description: agent.description, instructions: agent.instructions, defaultProvider: agent.provider, defaultModel: agent.model, loop: agent.maxSteps != null ? { maxSteps: agent.maxSteps } : undefined, runtime: 'opencode', sampleInput: loadSampleInput(dir), facts: loadFacts(dir), } const openCodeAgentFile = renderOpenCodeAgentFile({ description: agent.description, provider: agent.provider, model: agent.model, instructions: agent.instructions, tools: effectiveTools, mode: 'subagent', outcomeKind: outcome.kind, outcomeSchema: outcome.schema, outcomeProse: outcome.prose, }) return { entry, outcomeSchema: outcome.schema, resultKind: outcome.kind, openCodeAgentFile, openCodeAgentName, skillsContent, subAgents: [], } } /** * Load every sub-agent under `agents//sub-agents//` (Phase 4). Each * resolved child carries its own loaded `LoadedFileAgent` (full file agent, * constrained to research + non-delegating). Returns [] when the agent has no * `sub-agents/` dir. */ function loadSubAgents(agentDir: string): LoadedFileAgent[] { const base = path.join(agentDir, 'sub-agents') if (!fs.existsSync(base) || !fs.statSync(base).isDirectory()) return [] const loaded: LoadedFileAgent[] = [] for (const entry of fs.readdirSync(base, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === '__tests__') continue loaded.push(loadSubAgentDir(path.join(base, entry.name))) } return loaded } /** * Read `agents//{AGENT.md,OUTCOME.md}` (+ skills + sub-agents), validate, * compile the OUTCOME schema, and build an `AgentRegistryEntry` with * `runtime:'opencode'`. Pure and fs-based (unit-testable against fixtures). * Returns null when the dir is not a valid agent (missing/malformed AGENT.md or * OUTCOME.md); the generator turns a null into a hard generation error naming the * dir. A present-but-invalid SUB-agent throws (so a constraint violation fails * loudly rather than being silently dropped). * * `subAgents` (loaded children) is populated from `agents//sub-agents/`; the * declared sub-agent ids are still carried on `entry.subAgents`. When sub-agents * are present, the rendered primary agent file additionally allows the `task` * tool, whitelists ONLY its sub-agents' sanitized names under `permission.task`, * and gains a "Sub-agents" prompt section. */ export function loadFileAgentDir(dir: string): LoadedFileAgent | null { const agentMdPath = path.join(dir, 'AGENT.md') const outcomePath = path.join(dir, 'OUTCOME.md') if (!fs.existsSync(agentMdPath) || !fs.existsSync(outcomePath)) return null const agentMdRaw = fs.readFileSync(agentMdPath, 'utf8') const outcomeRaw = fs.readFileSync(outcomePath, 'utf8') const agent = parseAgentMarkdown(agentMdRaw) if (!agent) return null const outcome = parseOutcomeMarkdown(outcomeRaw) if (!outcome) return null let resultSchema try { resultSchema = compileOutcome({ kind: outcome.kind, schema: outcome.schema }).resultSchema } catch { return null } // Phase 3: resolve agent-local skills referenced by AGENT.md `skills:` and // UNION each resolved skill's read-only tools into the agent allowlist (deduped), // mirroring how the in-process `defineAgent` unions skill tools. const skillsContent = loadAgentSkills(dir, agent.skills) const skillTools = skillsContent.flatMap((skill) => skill.tools) // Phase 5: load `tools/*.ts` local tool files. Reference-form ids union into the // allowlist (flow through the central ACL + propose-only gate); local sandboxed // tool sources are carried under the synthetic `__agent_tools__` skill, run via // `run_skill_script` in the same sandbox as skill scripts. const toolFiles = loadToolFiles(dir) const effectiveSkillsContent = toolFiles.scripts.length > 0 ? [ ...skillsContent, { id: AGENT_TOOLS_SKILL_ID, instructions: '', examples: [], tools: [], scripts: toolFiles.scripts }, ] : skillsContent const effectiveTools = Array.from(new Set([...agent.tools, ...skillTools, ...toolFiles.refs])) // Phase 4: load sub-agent dirs (constraints enforced — throws on violation). const subAgents = loadSubAgents(dir) const subAgentNames = subAgents.map((sub) => sub.openCodeAgentName) const openCodeAgentName = sanitizeAgentName(agent.id) const entry: AgentRegistryEntry = { id: agent.id, moduleId: '', resultKind: outcome.kind, schema: resultSchema, tools: effectiveTools, skills: agent.skills, subAgents: agent.subAgents, label: agent.label, description: agent.description, instructions: agent.instructions, defaultProvider: agent.provider, defaultModel: agent.model, loop: agent.maxSteps != null ? { maxSteps: agent.maxSteps } : undefined, runtime: 'opencode', sampleInput: loadSampleInput(dir), facts: loadFacts(dir), files: agent.files, } const openCodeAgentFile = renderOpenCodeAgentFile({ description: agent.description, provider: agent.provider, model: agent.model, instructions: agent.instructions, tools: effectiveTools, mode: 'primary', subAgentNames, subAgentIds: subAgents.map((sub) => sub.entry.id), outcomeKind: outcome.kind, outcomeSchema: outcome.schema, outcomeProse: outcome.prose, files: agent.files, }) return { entry, outcomeSchema: outcome.schema, resultKind: outcome.kind, openCodeAgentFile, openCodeAgentName, skillsContent: effectiveSkillsContent, subAgents, } }