From 9cd5c9a03d1fb9beeca9b2021e8ce4b06d3ac4a2 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 3 Nov 2026 10:45:51 +0300 Subject: [PATCH] feat(spec): S18 specToCode TypeScript generator specToCode(spec) renders a validated AgentSpec v2 as a readable, deterministic TypeScript ES module: a createAgent({...}) call with plain data inlined via toTsLiteral and the reference sections written as calls to the package-root spec resolvers (resolveToolRef, resolveSkillRef, memoryFromSpec, subagentFromSpec, handoffFromSpec, guardrailsFromSpec, permissionsFromSpec, hooksFromSpec, outputFromSpec, resolveMcpServers, jsonSchemaToStandardSchema, specV2ToAgent) against a module-level host. Only the resolvers actually used are imported. Handoffs resolve subagent targets through a const subagents record and a host resolveHandoffAgent that throws for targets the file does not define; triggers/abortListeners/flow/evals end the module as a commented 'export const spec'. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../__snapshots__/typescript.test.ts.snap | 101 ++++++ src/spec/generators/tsLiteral.test.ts | 57 +++ src/spec/generators/tsLiteral.ts | 75 ++++ src/spec/generators/typescript.test.ts | 189 ++++++++++ src/spec/generators/typescript.ts | 334 ++++++++++++++++++ src/utils/plainErrors.test.ts | 5 + 6 files changed, 761 insertions(+) create mode 100644 src/spec/generators/__snapshots__/typescript.test.ts.snap create mode 100644 src/spec/generators/tsLiteral.test.ts create mode 100644 src/spec/generators/tsLiteral.ts create mode 100644 src/spec/generators/typescript.test.ts create mode 100644 src/spec/generators/typescript.ts diff --git a/src/spec/generators/__snapshots__/typescript.test.ts.snap b/src/spec/generators/__snapshots__/typescript.test.ts.snap new file mode 100644 index 000000000..765f84f4a --- /dev/null +++ b/src/spec/generators/__snapshots__/typescript.test.ts.snap @@ -0,0 +1,101 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`specToCode > renders a spec with every section 1`] = ` +"// Generated by lousho from AgentSpec v2 'a'. Edit the spec, not this file. +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createAgent, guardrailsFromSpec, handoffFromSpec, hooksFromSpec, jsonSchemaToStandardSchema, memoryFromSpec, outputFromSpec, permissionsFromSpec, resolveMcpServers, resolveSkillRef, resolveToolRef, specV2ToAgent, subagentFromSpec } from 'lousho'; + +/** Where relative paths in the spec start, and how named secrets resolve (environment variables here). */ +const host = { baseDir: path.dirname(fileURLToPath(import.meta.url)), resolveSecret: (name: string) => process.env[name], resolveHandoffAgent: async (name: string) => subagents[name] }; + +const subagents = { + helper: await subagentFromSpec('helper', { + inline: { + name: 'helper', + model: 'mock/m', + instructions: 'Help.', + description: 'Helps.', + }, + }, { ...host, buildAgent: (s) => specV2ToAgent({ version: 2, ...s }, host) }), +}; + +export const agent = createAgent({ + name: 'a', + description: 'Does everything.', + model: 'mock/m', + fallbackModels: ['mock/f1', 'mock/f2'], + instructions: \`You are a test agent.\`, + prompt: 'support@prod', + modelSettings: { maxTokens: 64, temperature: 0.2 }, + reasoning: 'low', + tools: Object.assign({}, ...(await Promise.all([ + resolveToolRef({ builtin: 'current_date' }, { ...host, agentName: 'a' }, 'tools.0'), + resolveToolRef({ hosted: 'web_search' }, { ...host, agentName: 'a' }, 'tools.1'), + ]))), + mcpServers: await resolveMcpServers({ + docs: { + url: 'https://x.test/mcp', + headers: { Authorization: { env: 'HDR' }, 'X-Key': { secret: 'tok' } }, + }, + }, host), + skills: (await Promise.all([ + resolveSkillRef({ + inline: { name: 'changelog', description: 'Write changelogs.', content: '# C' }, + }, host), + ])).flat(), + memory: await Promise.all([ + memoryFromSpec({ + name: 'notes', + scope: 'session', + provider: { type: 'in-memory' }, + itemJsonSchema: { type: 'object', properties: { text: { type: 'string' } } }, + itemKey: 'text', + }, { ...host, toStandardSchema: jsonSchemaToStandardSchema }), + ]), + subagents, + subagentOptions: { maxConcurrent: 2 }, + maxSubagentDepth: 2, + handoffs: await Promise.all([ + handoffFromSpec({ agent: 'helper', description: 'Go helper.' }, host, 0), + ]), + maxHandoffs: 4, + guardrails: guardrailsFromSpec([ + { + id: 'g1', + name: 'deny-topics', + on: 'input', + options: { topics: ['weapons'] }, + }, + ]), + permissions: permissionsFromSpec([{ id: 'p1', tool: '*', action: 'allow' }]), + permissionMode: 'plan', + approvalTtlMs: 60000, + askQuestion: true, + hooks: await hooksFromSpec([ + { + id: 'h1', + inline: { name: 'noop', point: 'postGenerate', code: 'return;' }, + }, + ], host), + output: outputFromSpec({ + jsonSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }), + limits: { maxSteps: 7 }, + maxSteps: 9, + compaction: { thresholdPercent: 0.5 }, + retry: { maxRetries: 4, backoff: { initialMs: 10 } }, + rateLimit: { requestsPerMinute: 12 }, + promptCaching: 'auto', + toolSearch: { maxResults: 3 }, + codeMode: { timeoutMs: 1000 }, +}); + +// triggers, abortListeners, flow and evals are run by lousho dev / Studio, not by createAgent(): +export const spec = { triggers: [{ id: 't1', type: 'chat' }] } as const; +" +`; diff --git a/src/spec/generators/tsLiteral.test.ts b/src/spec/generators/tsLiteral.test.ts new file mode 100644 index 000000000..5e373dbc4 --- /dev/null +++ b/src/spec/generators/tsLiteral.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { toTsLiteral, tsObjectKey } from './tsLiteral'; + +describe('toTsLiteral', () => { + it('prints strings single-quoted and escapes backslashes, quotes and newlines', () => { + expect(toTsLiteral('plain', 0)).toBe("'plain'"); + expect(toTsLiteral("it's \\ fine", 0)).toBe("'it\\'s \\\\ fine'"); + expect(toTsLiteral('a\nb', 0)).toBe("'a\\nb'"); + }); + + it('prints null, numbers and booleans as their JS source', () => { + expect(toTsLiteral(null, 0)).toBe('null'); + expect(toTsLiteral(42, 0)).toBe('42'); + expect(toTsLiteral(0.5, 0)).toBe('0.5'); + expect(toTsLiteral(true, 0)).toBe('true'); + expect(toTsLiteral(false, 0)).toBe('false'); + }); + + it('keeps identifier-safe keys bare and quotes the rest', () => { + expect(toTsLiteral({ a: 1, 'a-b': 2, 'with space': 3, _ok: 4, $d: 5 }, 0)).toBe( + "{ a: 1, 'a-b': 2, 'with space': 3, _ok: 4, $d: 5 }" + ); + }); + + it('prints nested objects and arrays inline while they fit in 80 columns', () => { + expect(toTsLiteral({ list: [1, { two: 2 }], empty: {}, none: [] }, 0)).toBe('{ list: [1, { two: 2 }], empty: {}, none: [] }'); + }); + + it('wraps an object one property per line past 80 columns, counting the indent', () => { + const value = { first: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', second: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' }; + expect(toTsLiteral(value, 0)).toBe( + "{\n first: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n second: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',\n}" + ); + }); + + it('wraps an array one element per line and indents nested blocks under it', () => { + const value = ['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'cccccccc']; + expect(toTsLiteral(value, 2)).toBe( + "[\n 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',\n 'cccccccc',\n ]" + ); + }); + + it('wraps a nested value inside a wrapped object without losing inner indentation', () => { + const value = { outer: { a: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', b: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' } }; + expect(toTsLiteral(value, 0)).toBe( + "{\n outer: {\n a: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\n b: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',\n },\n}" + ); + }); +}); + +describe('tsObjectKey', () => { + it('returns identifier-safe keys bare and quotes the rest', () => { + expect(tsObjectKey('name')).toBe('name'); + expect(tsObjectKey('a-b')).toBe("'a-b'"); + expect(tsObjectKey('2bad')).toBe("'2bad'"); + }); +}); diff --git a/src/spec/generators/tsLiteral.ts b/src/spec/generators/tsLiteral.ts new file mode 100644 index 000000000..7d5fc453f --- /dev/null +++ b/src/spec/generators/tsLiteral.ts @@ -0,0 +1,75 @@ +/** + * toTsLiteral(): a plain-data value as a TypeScript literal — the printer + * specToCode() uses for the spec keys createAgent() takes as data. Strings + * are single-quoted, identifier-safe object keys stay bare, and arrays and + * objects wrap to one element per line once the inline form would pass 80 + * columns. Browser-safe: string building only, no `node:*` imports. + */ + +/** A string that may stay a bare object key: a TypeScript identifier. */ +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** The column an inline array/object may fill before it wraps. */ +const INLINE_LIMIT = 80; + +/** The characters a single-quoted literal cannot hold verbatim. */ +function printString(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`); + return `'${escaped}'`; +} + +/** A non-object value: strings quoted, everything else its JS source form. */ +function printScalar(value: unknown): string { + if (typeof value === 'string') return printString(value); + if (typeof value === 'bigint') return `${value}n`; + if (typeof value === 'number' && Object.is(value, -0)) return '-0'; + return String(value); +} + +/** + * An object key in a generated literal: bare when `key` is an identifier, + * single-quoted otherwise ('a-b', '2bad', keys with spaces). + */ +export function tsObjectKey(key: string): string { + return IDENTIFIER.test(key) ? key : printString(key); +} + +/** The one-line form of any value: `{ a: 1 }` / `[1, 2]` for objects and arrays. */ +function printInline(value: unknown): string { + if (typeof value !== 'object' || value === null) return printScalar(value); + if (Array.isArray(value)) { + return value.length === 0 ? '[]' : `[${value.map(printInline).join(', ')}]`; + } + const entries = Object.entries(value); + return entries.length === 0 ? '{}' : `{ ${entries.map(([k, v]) => `${tsObjectKey(k)}: ${printInline(v)}`).join(', ')} }`; +} + +/** The wrapped form of an array/object: one element per line at `indent + 2`, closing bracket at `indent`. */ +function printBlock(value: object, indent: number): string { + const pad = ' '.repeat(indent); + const inner = ' '.repeat(indent + 2); + if (Array.isArray(value)) { + const items = value.map((item) => `${inner}${toTsLiteral(item, indent + 2)},`); + return `[\n${items.join('\n')}\n${pad}]`; + } + const entries = Object.entries(value).map(([k, v]) => `${inner}${tsObjectKey(k)}: ${toTsLiteral(v, indent + 2)},`); + return `{\n${entries.join('\n')}\n${pad}}`; +} + +/** + * `value` as a TypeScript literal whose first line sits at `indent` columns: + * the inline form when `indent + length` fits in 80 columns, otherwise the + * wrapped form (scalars never wrap). Deterministic: the same value and + * indent always print the same text. + */ +export function toTsLiteral(value: unknown, indent: number): string { + if (typeof value !== 'object' || value === null) return printScalar(value); + const inline = printInline(value); + return indent + inline.length <= INLINE_LIMIT ? inline : printBlock(value, indent); +} diff --git a/src/spec/generators/typescript.test.ts b/src/spec/generators/typescript.test.ts new file mode 100644 index 000000000..84e22946e --- /dev/null +++ b/src/spec/generators/typescript.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest'; +import * as ts from 'typescript'; +import type { AgentSpecV2 } from '../v2/types'; +import { specToCode } from './typescript'; + +function spec(overrides: Partial = {}): AgentSpecV2 { + return { version: 2, name: 'a', model: 'mock/m', instructions: 'You are a test agent.', ...overrides }; +} + +const minimal = spec(); + +/** A spec touching every section specToCode renders (S16b's full-section spec, inline). */ +const full = spec({ + description: 'Does everything.', + fallbackModels: ['mock/f1', 'mock/f2'], + promptRef: 'support@prod', + modelSettings: { maxTokens: 64, temperature: 0.2 }, + reasoning: 'low', + tools: [{ builtin: 'current_date' }, { hosted: 'web_search' }], + mcpServers: { + docs: { url: 'https://x.test/mcp', headers: { Authorization: { env: 'HDR' }, 'X-Key': { secret: 'tok' } } }, + }, + skills: [{ inline: { name: 'changelog', description: 'Write changelogs.', content: '# C' } }], + memory: [ + { + name: 'notes', + scope: 'session', + provider: { type: 'in-memory' }, + itemJsonSchema: { type: 'object', properties: { text: { type: 'string' } } }, + itemKey: 'text', + }, + ], + subagents: { + helper: { inline: { name: 'helper', model: 'mock/m', instructions: 'Help.', description: 'Helps.' } }, + }, + subagentOptions: { maxConcurrent: 2 }, + maxSubagentDepth: 2, + handoffs: [{ agent: 'helper', description: 'Go helper.' }], + maxHandoffs: 4, + guardrails: [{ id: 'g1', name: 'deny-topics', on: 'input', options: { topics: ['weapons'] } }], + permissions: [{ id: 'p1', tool: '*', action: 'allow' }], + permissionMode: 'plan', + approvalTtlMs: 60_000, + askQuestion: true, + hooks: [{ id: 'h1', inline: { name: 'noop', point: 'postGenerate', code: 'return;' } }], + output: { jsonSchema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } }, + limits: { maxSteps: 7 }, + maxSteps: 9, + compaction: { thresholdPercent: 0.5 }, + retry: { maxRetries: 4, backoff: { initialMs: 10 } }, + rateLimit: { requestsPerMinute: 12 }, + promptCaching: 'auto', + toolSearch: { maxResults: 3 }, + codeMode: { timeoutMs: 1000 }, + triggers: [{ id: 't1', type: 'chat' }], +}); + +/** Instructions carrying a backtick, a `${x}` interpolation lookalike and a real newline. */ +const trickyInstructions = 'Line one `tick` ${notAVar}\nLine two \\ backslash'; +const tricky = spec({ instructions: trickyInstructions }); +const fileInstructions = spec({ instructions: { file: 'prompts/main.md' } }); + +/** The import lines the generated module prints, in order. */ +function importLines(code: string): string[] { + return code.split('\n').filter((line) => line.startsWith('import ')); +} + +describe('specToCode', () => { + it('renders a minimal spec as the header, host line and a createAgent call', () => { + expect(specToCode(minimal)).toMatchInlineSnapshot(` + "// Generated by lousho from AgentSpec v2 'a'. Edit the spec, not this file. + import path from 'node:path'; + import { fileURLToPath } from 'node:url'; + import { createAgent } from 'lousho'; + + /** Where relative paths in the spec start, and how named secrets resolve (environment variables here). */ + const host = { baseDir: path.dirname(fileURLToPath(import.meta.url)), resolveSecret: (name: string) => process.env[name] }; + + export const agent = createAgent({ + name: 'a', + model: 'mock/m', + instructions: \`You are a test agent.\`, + }); + " + `); + }); + + it('renders a spec with every section', () => { + expect(specToCode(full)).toMatchSnapshot(); + }); + + it('is deterministic and follows KEY_ORDER regardless of input key order', () => { + const shuffled = { + version: 2, + limits: { maxSteps: 7 }, + tools: [{ builtin: 'current_date' }], + description: 'Shuffled.', + instructions: 'You are a test agent.', + model: 'mock/m', + name: 'a', + } as AgentSpecV2; + const ordered = spec({ description: 'Shuffled.', tools: [{ builtin: 'current_date' }], limits: { maxSteps: 7 } }); + const code = specToCode(shuffled); + expect(code).toBe(specToCode(shuffled)); + expect(code).toBe(specToCode(ordered)); + const optionIndex = (key: string) => code.indexOf(`\n ${key}:`); + expect(optionIndex('name')).toBeGreaterThanOrEqual(0); + expect(optionIndex('name')).toBeLessThan(optionIndex('description')); + expect(optionIndex('description')).toBeLessThan(optionIndex('model')); + expect(optionIndex('model')).toBeLessThan(optionIndex('tools')); + expect(optionIndex('tools')).toBeLessThan(optionIndex('limits')); + }); + + it('imports only the resolvers the spec uses', () => { + const code = specToCode(spec({ tools: [{ builtin: 'current_date' }] })); + expect(importLines(code)).toEqual([ + "import path from 'node:path';", + "import { fileURLToPath } from 'node:url';", + "import { createAgent, resolveToolRef } from 'lousho';", + ]); + }); + + it('renders instructions as a template literal that evaluates to the original text', () => { + const code = specToCode(tricky); + const start = code.indexOf('instructions: `') + 'instructions: `'.length; + const end = code.lastIndexOf('`,\n});'); + expect(start).toBeGreaterThanOrEqual('instructions: `'.length); + expect(end).toBeGreaterThan(start); + const body = code.slice(start, end); + const evaluated = new Function(`return \`${body}\`;`)() as string; + expect(evaluated).toBe(trickyInstructions); + }); + + it("imports readFile for { file } instructions and reads under host.baseDir", () => { + const code = specToCode(fileInstructions); + expect(importLines(code)[0]).toBe("import { readFile } from 'node:fs/promises';"); + expect(code).toContain("instructions: await readFile(path.join(host.baseDir, 'prompts/main.md'), 'utf8')"); + }); + + it('appends the instructionsByFamily tail matching the model and skips it otherwise', () => { + const matching = specToCode( + spec({ model: 'mock/claude-x', instructions: 'Base.', instructionsByFamily: { claude: 'Claude tail.' } }) + ); + expect(matching).toContain('instructions: `Base.\nClaude tail.`'); + const other = specToCode(spec({ model: 'mock/m', instructions: 'Base.', instructionsByFamily: { claude: 'Claude tail.' } })); + expect(other).toContain('instructions: `Base.`'); + }); + + it('resolves handoffs to spec subagents through a subagents const and the host resolver', () => { + const code = specToCode( + spec({ + subagents: { helper: { inline: { name: 'helper', model: 'mock/m', instructions: 'Help.', description: 'Helps.' } } }, + handoffs: [{ agent: 'helper' }], + }) + ); + expect(code).toContain('resolveHandoffAgent: async (name: string) => subagents[name]'); + expect(code).toContain('const subagents = {'); + expect(code).toContain('\n subagents,\n'); + }); + + it('renders a throwing resolveHandoffAgent when a handoff target is not defined in the file', () => { + const code = specToCode(spec({ handoffs: [{ agent: 'ext' }] })); + expect(code).toContain("throw new Error(`handoff target '${name}' is not defined in this file`)"); + }); + + it('ends with a commented spec export when the spec carries triggers', () => { + const code = specToCode(spec({ triggers: [{ id: 't1', type: 'chat' }] })); + expect(code).toContain('// triggers, abortListeners, flow and evals are run by lousho dev / Studio, not by createAgent():'); + expect(code.trimEnd().endsWith("export const spec = { triggers: [{ id: 't1', type: 'chat' }] } as const;")).toBe(true); + }); + + it('honours options.packageName for the SDK import', () => { + const code = specToCode(minimal, { packageName: '@scope/sdk' }); + expect(importLines(code)).toContain("import { createAgent } from '@scope/sdk';"); + }); + + it.each([ + ['minimal', minimal], + ['full', full], + ['tricky instructions', tricky], + ['file instructions', fileInstructions], + ])('generates a module with no syntax diagnostics (%s)', (_label, input) => { + const result = ts.transpileModule(specToCode(input), { + reportDiagnostics: true, + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }); + expect(result.diagnostics ?? []).toEqual([]); + }); +}); diff --git a/src/spec/generators/typescript.ts b/src/spec/generators/typescript.ts new file mode 100644 index 000000000..4cafbb5c5 --- /dev/null +++ b/src/spec/generators/typescript.ts @@ -0,0 +1,334 @@ +/** + * specToCode(): renders a validated AgentSpec v2 as the readable TypeScript + * ES module the Code tab shows and "Eject to project" writes — a + * `createAgent({...})` call equivalent to specV2ToAgent(spec, host): plain + * data is written inline and the reference forms (tools, skills, memory, + * subagents, handoffs, guardrails, permissions, hooks, output, MCP servers) + * are written as calls to the spec resolvers the package root exports, + * against a module-level `host`. Same spec in → same module out. + * Browser-safe: string building only, no `node:*` imports (the imports the + * *generated* module needs are emitted as text). + */ +import { familyInstructions } from '../v2/familyInstructions'; +import type { AgentSpecV2 } from '../v2/types'; +import { toTsLiteral, tsObjectKey } from './tsLiteral'; + +/** specToCode() options. */ +export interface SpecToCodeOptions { + /** Import specifier the SDK is imported from in the generated module. Default 'lousho'. */ + packageName?: string; +} + +/** + * The order createAgent() options are written in, fixed so the output does + * not depend on the spec's key order: the data keys and the section keys + * interleaved in spec order. `prompt` is emitted from `spec.promptRef` and + * `instructions` folds `instructionsByFamily` in; neither spec key is + * emitted on its own. + */ +const KEY_ORDER = [ + 'name', + 'description', + 'model', + 'fallbackModels', + 'instructions', + 'prompt', + 'modelSettings', + 'reasoning', + 'tools', + 'mcpServers', + 'skills', + 'memory', + 'subagents', + 'subagentOptions', + 'maxSubagentDepth', + 'handoffs', + 'maxHandoffs', + 'guardrails', + 'permissions', + 'permissionMode', + 'approvalTtlMs', + 'askQuestion', + 'hooks', + 'output', + 'limits', + 'maxSteps', + 'compaction', + 'retry', + 'rateLimit', + 'promptCaching', + 'toolSearch', + 'codeMode', +] as const; + +/** A KEY_ORDER entry. */ +type Key = (typeof KEY_ORDER)[number]; + +/** Spec keys createAgent() does not take; they end the module as a `spec` export. */ +const SPEC_ONLY_KEYS = ['triggers', 'abortListeners', 'flow', 'evals'] as const; + +/** The two host fields every generated module sets, as they appear inside `{ ... }`. */ +const HOST_FIELDS = + 'baseDir: path.dirname(fileURLToPath(import.meta.url)), resolveSecret: (name: string) => process.env[name]'; + +/** What a renderer may record while printing: the package-root resolvers the module imports. */ +interface RenderContext { + /** Package-root export names the generated module must import (sorted when printed). */ + imports: Set; + /** Whether `const subagents` is emitted before `createAgent` (handoffs present and subagents exist). */ + subagentsConst: boolean; +} + +/** Renders the value expression of one option, or undefined when the spec key is absent. */ +type SectionRenderer = (spec: AgentSpecV2, ctx: RenderContext) => string | undefined; + +/** A `Promise.all([` block at option indent: one call per line at indent 4, `]` back at indent 2. */ +function promiseAll(calls: readonly string[]): string { + return calls.length === 0 ? 'Promise.all([])' : `Promise.all([\n${calls.map((call) => ` ${call},`).join('\n')}\n ])`; +} + +/** `spec.tools` → `Object.assign({}, ...(await Promise.all([resolveToolRef(, ...)])))`, like the tools compiler. */ +function renderTools(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.tools === undefined) return undefined; + ctx.imports.add('resolveToolRef'); + const agentName = toTsLiteral(spec.name, 0); + const calls = spec.tools.map( + (ref, index) => `resolveToolRef(${toTsLiteral(ref, 4)}, { ...host, agentName: ${agentName} }, 'tools.${index}')` + ); + return `Object.assign({}, ...(await ${promiseAll(calls)}))`; +} + +/** `spec.mcpServers` → `await resolveMcpServers(, host)`. */ +function renderMcpServers(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.mcpServers === undefined) return undefined; + ctx.imports.add('resolveMcpServers'); + return `await resolveMcpServers(${toTsLiteral(spec.mcpServers, 2)}, host)`; +} + +/** `spec.skills` → `(await Promise.all([resolveSkillRef(, host), …])).flat()`. */ +function renderSkills(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.skills === undefined) return undefined; + ctx.imports.add('resolveSkillRef'); + return `(await ${promiseAll(spec.skills.map((ref) => `resolveSkillRef(${toTsLiteral(ref, 4)}, host)`))}).flat()`; +} + +/** `spec.memory` → `await Promise.all([memoryFromSpec(, { ...host, toStandardSchema })])`, as the memory compiler wires it. */ +function renderMemory(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.memory === undefined) return undefined; + ctx.imports.add('memoryFromSpec'); + ctx.imports.add('jsonSchemaToStandardSchema'); + const calls = spec.memory.map( + (entry) => `memoryFromSpec(${toTsLiteral(entry, 4)}, { ...host, toStandardSchema: jsonSchemaToStandardSchema })` + ); + return `await ${promiseAll(calls)}`; +} + +/** `spec.subagents` as the record `createAgent({ subagents })` takes, at `indent` columns, built through subagentFromSpec + specV2ToAgent. */ +function subagentsRecord(spec: AgentSpecV2, ctx: RenderContext, indent: number): string { + ctx.imports.add('subagentFromSpec'); + ctx.imports.add('specV2ToAgent'); + const inner = ' '.repeat(indent + 2); + const entries = Object.entries(spec.subagents ?? {}).map( + ([name, entry]) => + `${inner}${tsObjectKey(name)}: await subagentFromSpec(${toTsLiteral(name, 0)}, ${toTsLiteral( + entry, + indent + 2 + )}, { ...host, buildAgent: (s) => specV2ToAgent({ version: 2, ...s }, host) }),` + ); + return entries.length === 0 ? '{}' : `{\n${entries.join('\n')}\n${' '.repeat(indent)}}`; +} + +/** `spec.subagents` → `subagents: { ... }` inline (the shorthand form is emitted by {@link optionLine} when the const exists). */ +function renderSubagents(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + return spec.subagents === undefined ? undefined : subagentsRecord(spec, ctx, 2); +} + +/** `spec.handoffs` → `await Promise.all([handoffFromSpec(, host, ), …])`. */ +function renderHandoffs(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.handoffs === undefined) return undefined; + ctx.imports.add('handoffFromSpec'); + const calls = spec.handoffs.map((entry, index) => `handoffFromSpec(${toTsLiteral(entry, 4)}, host, ${index})`); + return `await ${promiseAll(calls)}`; +} + +/** `spec.guardrails` → `guardrailsFromSpec()` (synchronous, no await). */ +function renderGuardrails(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.guardrails === undefined) return undefined; + ctx.imports.add('guardrailsFromSpec'); + return `guardrailsFromSpec(${toTsLiteral(spec.guardrails, 2)})`; +} + +/** `spec.permissions` → `permissionsFromSpec()` (synchronous, no await). */ +function renderPermissions(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.permissions === undefined) return undefined; + ctx.imports.add('permissionsFromSpec'); + return `permissionsFromSpec(${toTsLiteral(spec.permissions, 2)})`; +} + +/** `spec.hooks` → `await hooksFromSpec(, host)`. */ +function renderHooks(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.hooks === undefined) return undefined; + ctx.imports.add('hooksFromSpec'); + return `await hooksFromSpec(${toTsLiteral(spec.hooks, 2)}, host)`; +} + +/** `spec.output` → `outputFromSpec()` (synchronous, no await). */ +function renderOutput(spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (spec.output === undefined) return undefined; + ctx.imports.add('outputFromSpec'); + return `outputFromSpec(${toTsLiteral(spec.output, 2)})`; +} + +/** A template-literal body that evaluates to `text`: `\`, `` ` `` and `${` escaped, `\r` kept as an escape. */ +function escapeTemplate(text: string): string { + return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${').replace(/\r/g, '\\r'); +} + +/** The family tail matching `spec.model`, resolved at generation time (the model is fixed in the file). */ +function familyTail(spec: AgentSpecV2): string | undefined { + const joined = familyInstructions('', spec.instructionsByFamily, spec.model); + return joined === '' ? undefined : joined.slice(1); +} + +/** + * `spec.instructions`: a string becomes a template literal with the matching + * `instructionsByFamily` tail folded in (exactly what S16a's data section + * computes); `{ file }` becomes `await readFile(path.join(host.baseDir, …))` + * plus the tail as a string concatenation. + */ +function renderInstructions(spec: AgentSpecV2): string | undefined { + if (spec.instructions === undefined) return undefined; + if (typeof spec.instructions === 'string') { + return `\`${escapeTemplate(familyInstructions(spec.instructions, spec.instructionsByFamily, spec.model))}\``; + } + const read = `await readFile(path.join(host.baseDir, ${toTsLiteral(spec.instructions.file, 0)}), 'utf8')`; + const tail = familyTail(spec); + return tail === undefined ? read : `(${read}) + ${toTsLiteral(`\n${tail}`, 0)}`; +} + +/** `spec.promptRef` → `prompt: ''` (createAgent resolves the registry ref). */ +function renderPrompt(spec: AgentSpecV2): string | undefined { + return spec.promptRef === undefined ? undefined : toTsLiteral(spec.promptRef, 2); +} + +/** The option renderers of every key that is not printed as plain data, keyed by the KEY_ORDER name. */ +const SECTION_RENDERERS: Partial> = { + instructions: renderInstructions, + prompt: renderPrompt, + tools: renderTools, + mcpServers: renderMcpServers, + skills: renderSkills, + memory: renderMemory, + subagents: renderSubagents, + handoffs: renderHandoffs, + guardrails: renderGuardrails, + permissions: renderPermissions, + hooks: renderHooks, + output: renderOutput, +}; + +/** A data key: the spec value printed as a literal (S01b stores createAgent's own option forms). */ +function dataValue(spec: AgentSpecV2, key: Key): string | undefined { + const value = (spec as unknown as Record)[key]; + return value === undefined ? undefined : toTsLiteral(value, 2); +} + +/** The full ` key: value` line for one KEY_ORDER entry, or undefined when the spec does not carry it. */ +function optionLine(key: Key, spec: AgentSpecV2, ctx: RenderContext): string | undefined { + if (key === 'subagents' && ctx.subagentsConst) { + return spec.subagents === undefined ? undefined : ' subagents'; + } + const value = (SECTION_RENDERERS[key] ?? ((s) => dataValue(s, key)))(spec, ctx); + return value === undefined ? undefined : ` ${key}: ${value}`; +} + +/** Whether the generated host needs `resolveHandoffAgent`: a non-empty handoffs list. */ +function needsHandoffResolver(spec: AgentSpecV2): boolean { + return spec.handoffs !== undefined && spec.handoffs.length > 0; +} + +/** The body lines of a `resolveHandoffAgent` that cannot serve every target from `subagents`. */ +function throwingHandoffResolver(hasSubagents: boolean): string[] { + const message = `throw new Error(\`handoff target '\${name}' is not defined in this file\`);`; + return hasSubagents + ? [' const agent = subagents[name];', ` if (agent === undefined) ${message}`, ' return agent;'] + : [` ${message}`]; +} + +/** + * The `const host = { ... };` line(s): one line normally; when handoffs need + * `resolveHandoffAgent`, either the short `subagents[name]` form (every + * target names a subagent) or a multi-line host whose resolver looks the + * name up and throws for a target this file does not define. + */ +function hostLines(spec: AgentSpecV2, subagentsConst: boolean): string[] { + if (!needsHandoffResolver(spec)) return [`const host = { ${HOST_FIELDS} };`]; + const subagentNames = new Set(Object.keys(spec.subagents ?? {})); + if (subagentsConst && (spec.handoffs ?? []).every((entry) => subagentNames.has(entry.agent))) { + return [`const host = { ${HOST_FIELDS}, resolveHandoffAgent: async (name: string) => subagents[name] };`]; + } + return [ + 'const host = {', + ` ${HOST_FIELDS},`, + ' resolveHandoffAgent: async (name: string) => {', + ...throwingHandoffResolver(subagentsConst), + ' },', + '};', + ]; +} + +/** + * The trailing `export const spec = { … } as const;` for the spec keys that + * are not agent options (triggers, abortListeners, flow, evals), preceded by + * the comment explaining who runs them. Undefined when none are present. + */ +function specKeysTrailer(spec: AgentSpecV2): string[] | undefined { + const data = spec as unknown as Record; + const present = SPEC_ONLY_KEYS.filter((key) => data[key] !== undefined); + if (present.length === 0) return undefined; + const picked = Object.fromEntries(present.map((key) => [key, data[key]])); + return [ + '// triggers, abortListeners, flow and evals are run by lousho dev / Studio, not by createAgent():', + `export const spec = ${toTsLiteral(picked, 0)} as const;`, + ]; +} + +/** + * `spec` as a TypeScript ES module that builds the same agent with + * `createAgent({...})` — specV2ToAgent's compile written as readable code. + * Deterministic: two calls with the same spec return identical strings, and + * the option order follows KEY_ORDER regardless of the input's key order. + * `options.packageName` overrides the specifier the SDK is imported from + * (default 'lousho'). + */ +export function specToCode(spec: AgentSpecV2, options?: SpecToCodeOptions): string { + const packageName = options?.packageName ?? 'lousho'; + const ctx: RenderContext = { imports: new Set(), subagentsConst: needsHandoffResolver(spec) && spec.subagents !== undefined }; + + const optionLines: string[] = []; + for (const key of KEY_ORDER) { + const line = optionLine(key, spec, ctx); + if (line !== undefined) optionLines.push(`${line},`); + } + const subagentsLines = ctx.subagentsConst ? [`const subagents = ${subagentsRecord(spec, ctx, 0)};`, ''] : []; + + const resolvers = [...ctx.imports].sort(); + const lines = [ + `// Generated by lousho from AgentSpec v2 '${spec.name.replace(/[\r\n]+/g, ' ')}'. Edit the spec, not this file.`, + ...(typeof spec.instructions === 'object' ? [`import { readFile } from 'node:fs/promises';`] : []), + `import path from 'node:path';`, + `import { fileURLToPath } from 'node:url';`, + `import { createAgent${resolvers.length > 0 ? `, ${resolvers.join(', ')}` : ''} } from ${toTsLiteral(packageName, 0)};`, + '', + `/** Where relative paths in the spec start, and how named secrets resolve (environment variables here). */`, + ...hostLines(spec, ctx.subagentsConst), + '', + ...subagentsLines, + `export const agent = createAgent({`, + ...optionLines, + `});`, + ]; + const trailer = specKeysTrailer(spec); + if (trailer !== undefined) lines.push('', ...trailer); + return `${lines.join('\n')}\n`; +} diff --git a/src/utils/plainErrors.test.ts b/src/utils/plainErrors.test.ts index f47ac5e7b..7a06b491f 100644 --- a/src/utils/plainErrors.test.ts +++ b/src/utils/plainErrors.test.ts @@ -46,6 +46,11 @@ const ALLOWED: Array<{ file: string; message: string; reason: string }> = [ message: '`telegram file download failed:', reason: 'a string literal that becomes generated-project source; a scaffolded app must not import SDK internals such as SDKError', }, + { + file: 'spec/generators/typescript.ts', + message: '\\`handoff target', + reason: 'a string literal that becomes generated-module source; a generated spec module must not import SDK internals such as SDKError', + }, ]; function sourceFiles(dir: string): string[] {