From 19fea0f51aa82b479df3c2d8a5d6587789a8ab62 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 3 Nov 2026 16:18:54 +0300 Subject: [PATCH] feat(spec): S19 agentDir round trip (specToAgentDir / agentDirToSpec) specToAgentDir(spec) renders a v2 spec as agent-directory files (agent.yaml, instructions.md, instructions/.md, skills//SKILL.md, subagents//) plus a dropped list of every field a directory cannot hold; agentDirToSpec(dir) reads a data-config directory back into a validated v2 spec without importing any of its code, reporting directory content a spec cannot hold (memory/*, schedules/*, channels/*, auth/approve files and the approve, store, toolConcurrency, projectInstructions, provider, engine, output config keys). Code configs throw LOUSHO_SPEC_UNSUPPORTED; a config with no model throws LOUSHO_SPEC_INVALID. The yaml-inline fixture gains a 'model' line so it can be read as a spec. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- api/index.api.md | 32 ++ .../__fixtures__/yaml-inline/agent.yaml | 1 + src/agentDir/index.ts | 2 + src/agentDir/spec.test.ts | 233 +++++++++ src/agentDir/spec.ts | 487 ++++++++++++++++++ 5 files changed, 755 insertions(+) create mode 100644 src/agentDir/spec.test.ts create mode 100644 src/agentDir/spec.ts diff --git a/api/index.api.md b/api/index.api.md index 0ffd9a381..06303dd55 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -135,6 +135,15 @@ export interface AgentDirPermissionRule { when?: Record | PermissionRule['when']; } +// @public +export function agentDirToSpec(dir: string): Promise; + +// @public +export interface AgentDirToSpecResult { + dropped: DroppedField[]; + spec: AgentSpecV2; +} + // @public export interface AgentDrift { instructions: boolean; @@ -1970,6 +1979,12 @@ export interface DriftEntry { field: 'tools' | 'args' | 'steps' | 'finishReason' | 'tokens'; } +// @public +export interface DroppedField { + field: string; + reason: string; +} + // @public type EditorShapeStep = StepNode | SequenceNode | ParallelNode | OneOfNode | ForEachNode | EvaluatorNode | BestOfAllNode | ToolNode | UIComponentNode | ConditionNode | LoopNode; @@ -2821,6 +2836,14 @@ export const GenAiOperation: { readonly INVOKE_WORKFLOW: "invoke_workflow"; }; +// @public +interface GeneratedFile { + // (undocumented) + content: string; + // (undocumented) + path: string; +} + // @public export interface GenerateHookContext extends HookContext { emit?: (event: HookEventPayload) => void; @@ -6615,6 +6638,15 @@ export interface SpecSchema extends SafeParser { // @public export function specToAgent(spec: AgentSpec, options?: Pick): SpecAgent; +// @public +export function specToAgentDir(spec: AgentSpecV2): SpecToAgentDirResult; + +// @public +export interface SpecToAgentDirResult { + dropped: DroppedField[]; + files: GeneratedFile[]; +} + // @public export function specV2ToAgent(spec: AgentSpecV2, host: SpecHost): Promise; diff --git a/src/agentDir/__fixtures__/yaml-inline/agent.yaml b/src/agentDir/__fixtures__/yaml-inline/agent.yaml index b4bd5cb5f..0460f8f7a 100644 --- a/src/agentDir/__fixtures__/yaml-inline/agent.yaml +++ b/src/agentDir/__fixtures__/yaml-inline/agent.yaml @@ -1,3 +1,4 @@ description: Instructions live in the config, no other files instructions: You are the YAML fixture agent. +model: openai/gpt-4o-mini maxSteps: 2 diff --git a/src/agentDir/index.ts b/src/agentDir/index.ts index e0563ee8f..ada875183 100644 --- a/src/agentDir/index.ts +++ b/src/agentDir/index.ts @@ -1,6 +1,8 @@ export { loadAgentDir, resolveAgentDir } from './loadAgentDir'; export { loadChannels } from './loadChannels'; export { loadSchedules } from './loadSchedules'; +export { agentDirToSpec, specToAgentDir } from './spec'; +export type { AgentDirToSpecResult, DroppedField, SpecToAgentDirResult } from './spec'; export type { AgentDirConfig, AgentDirManifest, diff --git a/src/agentDir/spec.test.ts b/src/agentDir/spec.test.ts new file mode 100644 index 000000000..fc0d3282e --- /dev/null +++ b/src/agentDir/spec.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'node:path'; +import { cpSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { agentDirToSpec, specToAgentDir } from './spec'; +import { importModule } from './importModule'; +import type { AgentSpecV2 } from '../spec/v2/types'; + +vi.mock('./importModule', async (importOriginal) => ({ + ...(await importOriginal()), + importModule: vi.fn(async () => { + throw new Error('agentDirToSpec must never import a file'); + }), +})); + +const fixture = (name: string): string => path.join(__dirname, '__fixtures__', name); +const registryDir = (name: string): string => path.join(__dirname, '..', '..', 'registry', name); + +/** A fresh temp directory. */ +function tmpDir(): string { + return mkdtempSync(path.join(tmpdir(), 'lousho-spec-dir-')); +} + +/** Writes `files` under `dir` (creating parent folders) and returns `dir`. */ +function writeFiles(dir: string, files: readonly { path: string; content: string }[]): string { + for (const file of files) { + const target = path.join(dir, ...file.path.split('/')); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, file.content); + } + return dir; +} + +/** Writes `files` under a fresh temp directory and returns the directory. */ +function writeDir(files: readonly { path: string; content: string }[]): string { + return writeFiles(tmpDir(), files); +} + +const MODEL = 'openai/gpt-4o-mini'; + +describe('specToAgentDir / agentDirToSpec', () => { + it('round-trips a minimal spec (name, model, instructions)', async () => { + const spec: AgentSpecV2 = { version: 2, name: 'mini', model: MODEL, instructions: 'You are minimal.' }; + const { files, dropped } = specToAgentDir(spec); + expect(dropped).toEqual([]); + const result = await agentDirToSpec(writeDir(files)); + expect(result).toEqual({ spec, dropped: [] }); + }); + + it('round-trips a rich spec: every directory-expressible key', async () => { + const spec: AgentSpecV2 = { + version: 2, + name: 'rich', + description: 'Exercises every directory-expressible key', + model: MODEL, + instructions: 'You are the rich fixture.', + instructionsByFamily: { openai: 'Work one tool call at a time.' }, + maxSteps: 7, + permissionMode: 'plan', + permissions: [ + { id: 'p1', tool: 'shell', when: { command: '\\brm\\b' }, action: 'deny', reason: 'No deletes' }, + { id: 'p2', tool: ['write_file', 'edit_file'], when: { lines: { lte: 200 } }, action: 'ask', ttlMs: 30_000 }, + ], + hooks: [{ id: 'h1', file: 'hooks.ts' }], + approvalTtlMs: 60_000, + limits: { maxCostUsd: 0.05 }, + modelSettings: { temperature: 0.2, maxTokens: 1024 }, + retry: { maxRetries: 4, backoff: { initialMs: 1000 }, timeoutMs: 30_000 }, + compaction: { thresholdPercent: 0.8 }, + skills: [ + { inline: { name: 'alpha', description: 'First skill', content: '# Alpha\nDo alpha things.' } }, + { inline: { name: 'beta', description: 'Second skill', content: 'Beta steps.' } }, + ], + tools: [{ file: 'tools/lookup.ts' }], + subagents: { + helper: { + inline: { name: 'helper', description: 'Looks things up', model: MODEL, instructions: 'You look things up.' }, + }, + }, + }; + const dir = writeDir(specToAgentDir(spec).files); + // A { file } tool ref writes nothing: the file is expected to exist in the directory. + writeFiles(dir, [{ path: 'tools/lookup.ts', content: 'export default {};\n' }]); + const result = await agentDirToSpec(dir); + expect(result).toEqual({ spec, dropped: [] }); + }); + + it('lists exactly the keys a directory cannot hold in dropped', () => { + const spec: AgentSpecV2 = { + version: 2, + name: 'dropped', + model: MODEL, + instructions: 'x', + guardrails: [{ id: 'g1', name: 'pii' }], + mcpServers: { local: { command: 'run-it' } }, + tools: [{ builtin: 'web_fetch' }], + }; + const { dropped } = specToAgentDir(spec); + expect(dropped).toHaveLength(3); + expect(dropped.map((d) => d.field).sort()).toEqual(['guardrails', 'mcpServers', 'tools.0']); + expect(dropped.find((d) => d.field === 'tools.0')?.reason).toBe("agent directories cannot reference 'builtin' tools"); + expect(dropped.find((d) => d.field === 'guardrails')?.reason).toBe("agent directories have no 'guardrails' key"); + }); + + it('reports the other non-directory rows: instructions file, skill dirs, bad tool paths, extra hooks, ref sub-agents', () => { + const spec: AgentSpecV2 = { + version: 2, + name: 'more-drops', + model: MODEL, + instructions: { file: 'prompts/system.md' }, + skills: [{ dir: 'skills/pack' }, { registry: 'changelog' }], + tools: [{ file: 'lib/tool.ts' }, { file: 'tools/ok.ts', approval: 'always' }, { hosted: 'web_search' }], + hooks: [ + { id: 'h1', file: 'hooks.ts' }, + { id: 'h2', file: 'more-hooks.ts' }, + { id: 'h3', inline: { name: 'audit', point: 'preToolCall', code: 'return;' } }, + ], + subagents: { remote: { remote: { url: 'https://agents.example.com/a', description: 'A deployed agent' } } }, + memory: [{ name: 'notes', scope: 'global', provider: { type: 'in-memory' } }], + }; + const { files, dropped } = specToAgentDir(spec); + // The one allowed hooks file still lands in agent.yaml. + expect(files.find((f) => f.path === 'agent.yaml')?.content).toContain('hooks: hooks.ts'); + expect(dropped.map((d) => d.field).sort()).toEqual( + [ + 'instructions', + 'skills.0', + 'skills.1', + 'tools.0', + 'tools.1', + 'tools.2', + 'hooks.1', + 'hooks.2', + 'subagents.remote', + 'memory', + ].sort() + ); + }); + + it('throws LOUSHO_SPEC_UNSUPPORTED for a code config directory', async () => { + const dir = writeDir([{ path: 'agent.ts', content: 'export default {};\n' }]); + await expect(agentDirToSpec(dir)).rejects.toMatchObject({ + code: 'LOUSHO_SPEC_UNSUPPORTED', + message: expect.stringContaining('is a code config'), + }); + }); + + it('throws LOUSHO_SPEC_INVALID when the config has no model', async () => { + const dir = writeDir([{ path: 'agent.json', content: '{ "instructions": "hi" }\n' }]); + await expect(agentDirToSpec(dir)).rejects.toMatchObject({ + code: 'LOUSHO_SPEC_INVALID', + message: expect.stringContaining("has no 'model'"), + }); + }); + + it('reads a YAML-config directory without importing anything', async () => { + const spy = vi.mocked(importModule); + spy.mockClear(); + const { spec, dropped } = await agentDirToSpec(fixture('yaml-inline')); + expect(spy).not.toHaveBeenCalled(); + expect(dropped).toEqual([]); + expect(spec).toMatchObject({ + version: 2, + name: 'yaml-inline', + description: 'Instructions live in the config, no other files', + model: 'openai/gpt-4o-mini', + instructions: 'You are the YAML fixture agent.', + maxSteps: 2, + }); + }); + + it('reads registry/inbox-triage and names the files a spec cannot hold', async () => { + const dir = path.join(tmpDir(), 'inbox-triage'); + // Copy first: a test never writes into registry/. + cpSync(registryDir('inbox-triage'), dir, { recursive: true }); + const { spec, dropped } = await agentDirToSpec(dir); + expect(dropped.map((d) => d.field).sort()).toEqual([ + 'approve.ts', + 'channels/inbound.ts', + 'memory/senders.ts', + 'schedules/poll-inbox.ts', + ]); + expect(spec).toMatchObject({ + version: 2, + name: 'inbox-triage', + model: 'openrouter/openai/gpt-4o-mini', + maxSteps: 15, + permissionMode: 'default', + tools: [{ file: 'tools/inbox.ts' }, { file: 'tools/replies.ts' }], + hooks: [{ id: 'h1', file: 'hooks.ts' }], + limits: { maxCostUsd: 0.05 }, + compaction: { thresholdPercent: 0.8 }, + permissions: [ + { id: 'p1', tool: 'delete_message', action: 'deny' }, + { id: 'p2', tool: 'send_reply', action: 'ask' }, + { id: 'p3', action: 'allow' }, + ], + }); + expect(spec.instructionsByFamily?.openai).toContain('Work the loop one tool call at a time'); + }); + + it('lists config keys a spec cannot hold in dropped', async () => { + const dir = writeDir([ + { + path: 'agent.json', + content: JSON.stringify({ + model: 'openai/gpt-4o-mini', + instructions: 'hi', + approve: 'approve.ts', + store: { dir: './.lousho' }, + toolConcurrency: 2, + projectInstructions: true, + engine: 'pi', + }), + }, + ]); + const { dropped } = await agentDirToSpec(dir); + expect(dropped.map((d) => d.field)).toEqual(['approve', 'store', 'toolConcurrency', 'projectInstructions', 'engine']); + expect(dropped[0].reason).toContain('approver'); + }); + + it('reads a skill folder with extra files back as { dir }', async () => { + const dir = writeDir([ + { path: 'agent.yaml', content: 'model: openai/gpt-4o-mini\n' }, + { path: 'instructions.md', content: 'hi\n' }, + { path: 'skills/my-skill/SKILL.md', content: '---\nname: my-skill\ndescription: Has extras\n---\n\nContent.\n' }, + { path: 'skills/my-skill/cheatsheet.md', content: 'extra\n' }, + ]); + const { spec, dropped } = await agentDirToSpec(dir); + expect(dropped).toEqual([]); + expect(spec.skills).toEqual([{ dir: 'skills/my-skill' }]); + }); +}); diff --git a/src/agentDir/spec.ts b/src/agentDir/spec.ts new file mode 100644 index 000000000..ab4000fce --- /dev/null +++ b/src/agentDir/spec.ts @@ -0,0 +1,487 @@ +/** + * The AgentSpec v2 ⇄ agent-directory round trip. + * + * `specToAgentDir(spec)` renders a validated spec as agent-directory files + * (paths relative to the directory, '/' separators) plus an honest + * `dropped` list of the fields a directory cannot hold; `agentDirToSpec(dir)` + * reads a data-config directory (agent.json / agent.yaml / agent.yml, or no + * config) back into a spec, also reporting the directory content a spec + * cannot hold. For specs that use only directory-expressible keys the round + * trip is the identity. + * + * SECURITY: `agentDirToSpec` never imports a file - loading a directory runs + * its code. It reads files only (fs, `yaml`, `JSON.parse`, `loadSkills`, + * which is markdown). A code config (`agent.ts`, `agent.js`, ...) is refused + * with LOUSHO_SPEC_UNSUPPORTED instead. + * + * Node-only: it reads the filesystem, so it is exported from the package + * root, never from a browser-safe entry. + */ +import path from 'node:path'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { SDKError } from '../execution/errors'; +import type { GeneratedFile } from '../spec/generators/claude-code'; +import type { PermissionRuleSpec } from '../spec/v2/permissions'; +import { parseAgentSpecV2 } from '../spec/v2/parse'; +import type { SkillRef } from '../spec/v2/skills'; +import { TOOL_REF_KINDS, type ToolRef } from '../spec/v2/tools'; +import type { AgentSpecV2, InlineAgentSpec } from '../spec/v2/types'; +import type { Skill } from '../skills/defineSkill'; +import { loadSkills } from '../skills/loadSkills'; +import { isToolFileName } from './collectTools'; +import { isDirectory, isFile, listSorted, readText } from './fsUtil'; +import { findConfigFile, isCodeConfigFile } from './readConfig'; +import { listSubagentDirs } from './subagents'; +import { fail, validateConfig, type AgentDirConfig, type AgentDirPermissionRule } from './validateConfig'; + +/** A spec field a directory cannot hold, and why. Shown by the Code tab and the import preview. */ +export interface DroppedField { + /** Dotted path, e.g. 'guardrails', 'tools.2', 'subagents.billing'. */ + field: string; + /** Plain words, e.g. "agent directories have no 'guardrails' key". */ + reason: string; +} + +/** The result of {@link specToAgentDir}: the agent directory's files plus what was left out. */ +export interface SpecToAgentDirResult { + /** Files of the agent directory (paths relative to the directory, '/' separators). */ + files: GeneratedFile[]; + /** The spec fields a directory cannot hold, and why. */ + dropped: DroppedField[]; +} + +/** The result of {@link agentDirToSpec}: the directory read back as a spec plus what was left out. */ +export interface AgentDirToSpecResult { + /** The validated AgentSpec v2 the directory describes. */ + spec: AgentSpecV2; + /** The directory content a spec cannot hold, and why. */ + dropped: DroppedField[]; +} + +/** The spec fields the directory writers read; an inline sub-agent spec is the same minus the top-level-only keys. */ +type DirSpec = Omit; + +/** What the writers accumulate: the generated files, the dropped fields and the agent.yaml object. */ +interface DirOut { + files: GeneratedFile[]; + dropped: DroppedField[]; + /** The config object's keys, serialized as agent.yaml in AGENT_YAML_ORDER once every writer ran. */ + config: Record; +} + +function dropField(out: DirOut, field: string, reason: string): void { + out.dropped.push({ field, reason }); +} + +function writeFile(out: DirOut, filePath: string, content: string): void { + out.files.push({ path: filePath, content }); +} + +/** The config keys that copy over unchanged (permissions and hooks are reshaped by their own writers). */ +const CONFIG_SCALAR_KEYS = [ + 'name', + 'description', + 'model', + 'maxSteps', + 'permissionMode', + 'approvalTtlMs', + 'limits', + 'modelSettings', + 'retry', + 'compaction', +] as const; + +/** agent.yaml key order, fixed so the output does not depend on the spec's key order. */ +const AGENT_YAML_ORDER = [ + 'name', + 'description', + 'model', + 'maxSteps', + 'permissionMode', + 'permissions', + 'hooks', + 'approvalTtlMs', + 'limits', + 'modelSettings', + 'retry', + 'compaction', +] as const; + +/** Spec keys a directory cannot express at all; each is reported as dropped. */ +const NOT_A_DIR_KEY = [ + 'mcpServers', + 'guardrails', + 'handoffs', + 'memory', + 'triggers', + 'output', + 'reasoning', + 'fallbackModels', + 'rateLimit', + 'promptCaching', + 'toolSearch', + 'codeMode', + 'askQuestion', + 'subagentOptions', + 'maxSubagentDepth', + 'maxHandoffs', + 'abortListeners', + 'flow', + 'evals', + 'promptRef', +] as const; + +function writeScalars(spec: DirSpec, out: DirOut): void { + const data = spec as unknown as Record; + for (const key of CONFIG_SCALAR_KEYS) { + if (data[key] !== undefined) out.config[key] = data[key]; + } +} + +function writePermissions(spec: DirSpec, out: DirOut): void { + if (spec.permissions === undefined) return; + // A directory rule is the same object minus the canvas `id` (reading assigns 'p1', 'p2', ...). + out.config.permissions = spec.permissions.map(({ id: _id, ...rule }: PermissionRuleSpec) => rule); +} + +function writeHooks(spec: DirSpec, out: DirOut): void { + const hooks = spec.hooks ?? []; + const firstFile = hooks.findIndex((hook) => 'file' in hook); + if (firstFile >= 0) out.config.hooks = (hooks[firstFile] as { file: string }).file; + hooks.forEach((_hook, index) => { + if (index !== firstFile) dropField(out, `hooks.${index}`, 'an agent directory holds one hooks file'); + }); +} + +function writeInstructions(spec: DirSpec, out: DirOut): void { + if (typeof spec.instructions === 'string') { + writeFile(out, 'instructions.md', `${spec.instructions}\n`); + } else if (spec.instructions !== undefined) { + dropField(out, 'instructions', `instructions file '${spec.instructions.file}' is not copied`); + } +} + +function writeFamily(spec: DirSpec, out: DirOut): void { + for (const [family, text] of Object.entries(spec.instructionsByFamily ?? {})) { + writeFile(out, `instructions/${family}.md`, `${text}\n`); + } +} + +function writeSkills(spec: DirSpec, out: DirOut): void { + (spec.skills ?? []).forEach((ref, index) => { + if ('inline' in ref) { + const { name, description, content } = ref.inline; + writeFile(out, `skills/${name}/SKILL.md`, `---\n${stringifyYaml({ name, description })}---\n\n${content}\n`); + } else { + dropField(out, `skills.${index}`, 'skill folders are not copied'); + } + }); +} + +/** The kind key a ToolRef carries (its single discriminator), for the dropped reason. */ +function toolRefKind(ref: ToolRef): string { + return TOOL_REF_KINDS.find((kind) => kind in ref) ?? 'file'; +} + +/** A `{ file }` ref under tools/ needs nothing written; other refs, and ref options a directory lacks, are dropped. */ +function writeTools(spec: DirSpec, out: DirOut): void { + (spec.tools ?? []).forEach((ref, index) => { + if ('file' in ref) { + if (!ref.file.startsWith('tools/')) dropField(out, `tools.${index}`, `tool file '${ref.file}' is not under 'tools/'`); + for (const key of ['approval', 'deferLoading'] as const) { + if (ref[key] !== undefined) dropField(out, `tools.${index}`, `agent directories cannot carry '${key}' on a tool`); + } + } else { + dropField(out, `tools.${index}`, `agent directories cannot reference '${toolRefKind(ref)}' tools`); + } + }); +} + +function writeSubagents(spec: DirSpec, out: DirOut): void { + for (const [name, entry] of Object.entries(spec.subagents ?? {})) { + if ('inline' in entry) { + const inner = specToDir(entry.inline); + for (const file of inner.files) writeFile(out, `subagents/${name}/${file.path}`, file.content); + for (const dropped of inner.dropped) dropField(out, `subagents.${name}.${dropped.field}`, dropped.reason); + } else { + dropField(out, `subagents.${name}`, `agent directories cannot reference '${'ref' in entry ? 'ref' : 'remote'}' sub-agents`); + } + } +} + +function writeUnsupported(spec: DirSpec, out: DirOut): void { + const data = spec as unknown as Record; + for (const key of NOT_A_DIR_KEY) { + if (data[key] !== undefined) dropField(out, key, `agent directories have no '${key}' key`); + } +} + +/** The directory writers, run in order: each is one section of the mapping table. */ +const TO_DIR: ReadonlyArray<(spec: DirSpec, out: DirOut) => void> = [ + writeScalars, + writePermissions, + writeHooks, + writeInstructions, + writeFamily, + writeSkills, + writeTools, + writeSubagents, + writeUnsupported, +]; + +/** `out.config` as agent.yaml text, keys in AGENT_YAML_ORDER. */ +function agentYaml(config: Record): string { + const ordered: Record = {}; + for (const key of AGENT_YAML_ORDER) { + if (config[key] !== undefined) ordered[key] = config[key]; + } + return stringifyYaml(ordered); +} + +/** The files of `spec`'s agent directory and what was left out; shared by the export and the sub-agent recursion. */ +function specToDir(spec: DirSpec): SpecToAgentDirResult { + const out: DirOut = { files: [], dropped: [], config: {} }; + for (const write of TO_DIR) write(spec, out); + return { files: [{ path: 'agent.yaml', content: agentYaml(out.config) }, ...out.files], dropped: out.dropped }; +} + +/** + * Files of the agent directory for `spec` (paths relative to the directory, + * '/' separators), and what was left out. The caller writes the files; + * `{ file }` tool refs under `tools/` write nothing because the file is + * expected to exist already. Everything a directory cannot express is + * listed in `dropped`, never silently lost. + */ +export function specToAgentDir(spec: AgentSpecV2): SpecToAgentDirResult { + return specToDir(spec); +} + +/** What the readers accumulate: the spec object under construction and the dropped list. */ +interface DirIn { + /** The agent directory, resolved absolute. */ + dir: string; + /** The validated config object (`{}` when the directory has no config file). */ + config: AgentDirConfig; + /** The spec under construction; starts as `{ version: 2 }`. */ + spec: Record; + dropped: DroppedField[]; +} + +function drop(ctx: DirIn, field: string, reason: string): void { + ctx.dropped.push({ field, reason }); +} + +/** `text` minus one trailing newline (LF or CRLF) - the inverse of the write side's added '\n'. */ +function withoutTrailingNewline(text: string): string { + return text.replace(/\r?\n$/, ''); +} + +/** The config keys that copy into the spec unchanged (`name` defaults to the directory's basename). */ +const DIR_SCALAR_KEYS = [ + 'description', + 'model', + 'maxSteps', + 'permissionMode', + 'approvalTtlMs', + 'limits', + 'modelSettings', + 'retry', + 'compaction', +] as const; + +async function readScalars(ctx: DirIn): Promise { + ctx.spec.name = ctx.config.name ?? path.basename(ctx.dir); + const config = ctx.config as Record; + for (const key of DIR_SCALAR_KEYS) { + if (config[key] !== undefined) ctx.spec[key] = config[key]; + } +} + +const MARKDOWN = /\.md$/i; + +/** `instructions.md` xor the config's `instructions` (both, or an empty file, are directory errors). */ +async function readInstructions(ctx: DirIn): Promise { + const file = path.join(ctx.dir, 'instructions.md'); + if (await isFile(file)) { + const text = await readText(file); + if (text.trim() === '') { + throw new SDKError(`agentDirToSpec: ${file} is empty. Write the agent's system prompt in it.`, 'LOUSHO_AGENT_DIR_INVALID'); + } + if (ctx.config.instructions !== undefined) { + throw new SDKError( + `agentDirToSpec: ${file}: instructions are given twice - here and as 'instructions' in the config file. Keep one of them.`, + 'LOUSHO_AGENT_DIR_INVALID' + ); + } + ctx.spec.instructions = withoutTrailingNewline(text); + } else if (ctx.config.instructions !== undefined) { + ctx.spec.instructions = ctx.config.instructions; + } +} + +async function readFamily(ctx: DirIn): Promise { + const instructionsDir = path.join(ctx.dir, 'instructions'); + const byFamily: Record = {}; + for (const name of await listSorted(instructionsDir, (e) => e.isFile && MARKDOWN.test(e.name))) { + const stem = name.replace(MARKDOWN, ''); + if (stem !== '') byFamily[stem] = withoutTrailingNewline(await readText(path.join(instructionsDir, name))); + } + if (Object.keys(byFamily).length > 0) ctx.spec.instructionsByFamily = byFamily; +} + +/** One config permission rule as a spec rule: the id comes back as 'p', `tool` arrays copy. */ +function permissionSpecOf(rule: AgentDirPermissionRule, index: number): PermissionRuleSpec { + return { + id: `p${index + 1}`, + tool: Array.isArray(rule.tool) ? [...rule.tool] : (rule.tool as string), + ...(rule.when !== undefined && { when: rule.when as PermissionRuleSpec['when'] }), + action: rule.action, + ...(rule.reason !== undefined && { reason: rule.reason }), + ...(rule.ttlMs !== undefined && { ttlMs: rule.ttlMs }), + }; +} + +async function readPermissions(ctx: DirIn): Promise { + const rules = ctx.config.permissions; + if (rules !== undefined) ctx.spec.permissions = rules.map(permissionSpecOf); +} + +/** The config's `hooks`: a file path becomes `[{ id: 'h1', file }]`; a data config's inline hooks cannot. */ +async function readHooks(ctx: DirIn): Promise { + const hooks = ctx.config.hooks; + if (hooks === undefined) return; + if (typeof hooks === 'string') { + ctx.spec.hooks = [{ id: 'h1', file: hooks }]; + } else { + drop(ctx, 'hooks', "the config's 'hooks' holds inline hook objects; a spec references a hooks file"); + } +} + +async function readTools(ctx: DirIn): Promise { + const names = await listSorted(path.join(ctx.dir, 'tools'), (e) => e.isFile && isToolFileName(e.name)); + if (names.length > 0) ctx.spec.tools = names.map((name) => ({ file: `tools/${name}` })); +} + +/** One loaded skill as a SkillRef: a folder holding only SKILL.md (or a loose .md) is inline; with extra files it is `{ dir }`. */ +async function skillRefOf(skill: Skill): Promise { + const inline = { inline: { name: skill.name, description: skill.description, content: skill.content } }; + if (skill.directory === undefined) return inline; + const entries = await listSorted(skill.directory, () => true); + return entries.length === 1 && entries[0] === 'SKILL.md' ? inline : { dir: `skills/${path.basename(skill.directory)}` }; +} + +async function readSkills(ctx: DirIn): Promise { + const skillsDir = path.join(ctx.dir, 'skills'); + if (!(await isDirectory(skillsDir))) return; + const refs: SkillRef[] = []; + for (const skill of await loadSkills(skillsDir)) refs.push(await skillRefOf(skill)); + if (refs.length > 0) ctx.spec.skills = refs; +} + +async function readSubagents(ctx: DirIn): Promise { + const names = await listSubagentDirs(ctx.dir); + if (names.length === 0) return; + const subagents: Record = {}; + for (const name of names) { + const child = await agentDirToSpec(path.join(ctx.dir, 'subagents', name)); + const { version: _version, ...inline } = child.spec; + subagents[name] = { inline: inline as InlineAgentSpec }; + for (const dropped of child.dropped) drop(ctx, `subagents.${name}.${dropped.field}`, dropped.reason); + } + ctx.spec.subagents = subagents; +} + +/** Directory code a spec cannot hold: `memory/*`, `schedules/*`, `channels/*` and root `auth.*` / `approve.*` files. */ +async function readDropped(ctx: DirIn): Promise { + for (const dirName of ['memory', 'schedules', 'channels'] as const) { + for (const name of await listSorted(path.join(ctx.dir, dirName), (e) => e.isFile)) { + drop(ctx, `${dirName}/${name}`, `${dirName}/${name} is code; a spec cannot hold it`); + } + } + for (const stem of ['auth', 'approve'] as const) { + for (const name of await listSorted(ctx.dir, (e) => e.isFile && new RegExp(`^${stem}\\.[cm]?[jt]s$`).test(e.name))) { + drop(ctx, name, `${name} is code; a spec cannot hold it`); + } + } + for (const key of CODE_ONLY_CONFIG_KEYS) { + if ((ctx.config as Record)[key] !== undefined) drop(ctx, key, configDropReason(key)); + } +} + +/** Config keys whose value a spec cannot hold, with the reason each gets when present in the config. */ +const CODE_ONLY_CONFIG_KEYS = ['approve', 'store', 'toolConcurrency', 'projectInstructions', 'provider', 'engine', 'output'] as const; + +function configDropReason(key: (typeof CODE_ONLY_CONFIG_KEYS)[number]): string { + switch (key) { + case 'approve': + return `'approve' names a code file; a spec cannot hold an approver`; + case 'store': + return `'store' wires a file store; a spec cannot hold one`; + case 'provider': + return `a spec names a 'provider/model' string, not a provider instance`; + case 'engine': + return `'engine' makes a Pi sub-agent; an inline spec cannot say that`; + case 'output': + return `a directory 'output' is a Standard Schema; a spec's is JSON Schema data`; + default: + return `a spec has no '${key}' key`; + } +} + +/** The directory readers, run in order: each is one section of the mapping table. */ +const FROM_DIR: ReadonlyArray<(ctx: DirIn) => Promise> = [ + readScalars, + readInstructions, + readFamily, + readPermissions, + readHooks, + readTools, + readSkills, + readSubagents, + readDropped, +]; + +/** Parses a data config file (JSON or YAML) and validates its shape; parse errors reuse `fail()` like readConfig's. */ +async function readDataConfig(file: string | undefined): Promise { + if (file === undefined) return {}; + const kind = file.endsWith('.json') ? 'JSON' : 'YAML'; + let data: unknown; + try { + data = kind === 'JSON' ? JSON.parse(await readText(file)) : parseYaml(await readText(file)); + } catch (error) { + return fail(file, `invalid ${kind} (${(error as Error).message}).`); + } + return validateConfig(file, data); +} + +/** + * Reads a DATA-config agent directory (agent.json / agent.yaml / agent.yml, + * or none) into a v2 spec, and reports as `dropped` the directory content a + * spec cannot hold (`memory/*`, `schedules/*`, `channels/*`, `auth.*`, + * `approve.*` files and the `approve`, `store`, `toolConcurrency`, + * `projectInstructions`, `provider`, `engine`, `output` config keys). + * `name` defaults to the directory's basename, like `loadAgentDir`. The + * result is validated with `agentSpecV2Schema` before it is returned. + * Never imports a file: a code config (`agent.ts`, `agent.js`, ...) throws + * LOUSHO_SPEC_UNSUPPORTED. + */ +export async function agentDirToSpec(dir: string): Promise { + const resolved = path.resolve(dir); + if (!(await isDirectory(resolved))) { + throw new SDKError(`agentDirToSpec: '${resolved}' is not a directory. Pass the path of an agent directory.`, 'LOUSHO_AGENT_DIR_INVALID'); + } + const configFile = await findConfigFile(resolved); + if (configFile !== undefined && isCodeConfigFile(configFile)) { + throw new SDKError( + `agentDirToSpec: ${configFile} is a code config; only agent.json/agent.yaml directories can be read as a spec`, + 'LOUSHO_SPEC_UNSUPPORTED' + ); + } + const config = await readDataConfig(configFile); + if (config.model === undefined) { + throw new SDKError(`agentDirToSpec: ${resolved}: agent directory has no 'model'; a v2 spec needs one`, 'LOUSHO_SPEC_INVALID'); + } + const ctx: DirIn = { dir: resolved, config, spec: { version: 2 }, dropped: [] }; + for (const read of FROM_DIR) await read(ctx); + return { spec: parseAgentSpecV2(ctx.spec, resolved), dropped: ctx.dropped }; +}