Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/spec/generators/__snapshots__/typescript.test.ts.snap
Original file line number Diff line number Diff line change
@@ -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;
"
`;
57 changes: 57 additions & 0 deletions src/spec/generators/tsLiteral.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
75 changes: 75 additions & 0 deletions src/spec/generators/tsLiteral.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading