diff --git a/apps/agent-forge/server/author/draft/__tests__/schema.test.ts b/apps/agent-forge/server/author/draft/__tests__/schema.test.ts new file mode 100644 index 00000000..62447b08 --- /dev/null +++ b/apps/agent-forge/server/author/draft/__tests__/schema.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + authorDraftSchema, + authorPatchSchema, + DRAFT_OMITTED_KEYS, + FIELD_DESCRIPTIONS, + NULLABLE_PATCH_KEYS, +} from '../schema'; + +/** + * The draft/patch zod contracts of the Author's propose tools: which keys + * a draft must, may and must not carry; the patch's optional/nullable + * rules; and the `.describe()` coverage the model-facing JSON Schema + * depends on (a spec key added without a FIELD_DESCRIPTIONS row fails + * here, which is the point - the model only ever sees descriptions). + */ + +const MINIMAL_DRAFT = { + name: 'Support triage', + description: 'Routes support mail to the right queue.', + model: 'anthropic/claude-sonnet-5-5', + instructions: 'You triage support mail and pick the queue.', +}; + +describe('authorDraftSchema', () => { + it('accepts a minimal valid draft', () => { + const parsed = authorDraftSchema.safeParse(MINIMAL_DRAFT); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data).toEqual(MINIMAL_DRAFT); + } + }); + + it.each(['name', 'description', 'model', 'instructions'])('requires %s', (key) => { + const draft = { ...MINIMAL_DRAFT } as Record; + delete draft[key]; + expect(authorDraftSchema.safeParse(draft).success).toBe(false); + }); + + it.each([...DRAFT_OMITTED_KEYS, 'labels', 'bogusField'])('rejects the never-settable key %s', (key) => { + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, [key]: 'x' }).success).toBe(false); + }); + + it('requires description, capped at 500 characters', () => { + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: '' }).success).toBe(false); + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: 'x'.repeat(501) }).success).toBe(false); + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, description: 'x'.repeat(500) }).success).toBe(true); + }); + + it('takes instructions as a string only, never { file }', () => { + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, instructions: { file: 'prompt.md' } }).success).toBe(false); + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, instructions: '' }).success).toBe(false); + }); + + it('validates fields with the spec rules (model id shape, name shape)', () => { + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, model: 'claude' }).success).toBe(false); + expect(authorDraftSchema.safeParse({ ...MINIMAL_DRAFT, name: '!!!' }).success).toBe(false); + }); +}); + +describe('authorPatchSchema', () => { + it('accepts an empty patch and every draft key on its own', () => { + expect(authorPatchSchema.safeParse({}).success).toBe(true); + for (const key of Object.keys(authorDraftSchema.shape)) { + const patch: Record = {}; + patch[key] = undefined; + expect(authorPatchSchema.safeParse(patch).success).toBe(true); + } + }); + + it.each(['version', 'evals', 'flow', 'bogusField'])('rejects the unknown key %s', (key) => { + expect(authorPatchSchema.safeParse({ [key]: 'x' }).success).toBe(false); + }); + + it('keeps null in the parsed patch so the apply step can reset the field', () => { + const parsed = authorPatchSchema.safeParse({ reasoning: null }); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.reasoning).toBeNull(); + }); + + it('accepts null on every spec-optional field except description', () => { + for (const key of NULLABLE_PATCH_KEYS.filter((key) => key in authorDraftSchema.shape)) { + expect(authorPatchSchema.safeParse({ [key]: null }).success).toBe(true); + } + }); + + it.each(['description', 'name', 'model', 'instructions'])('rejects null on %s', (key) => { + expect(authorPatchSchema.safeParse({ [key]: null }).success).toBe(false); + }); + + it('still validates a provided value (clearing a list, replacing a field)', () => { + expect(authorPatchSchema.safeParse({ tools: [] }).success).toBe(true); + expect(authorPatchSchema.safeParse({ tools: 'many' }).success).toBe(false); + expect(authorPatchSchema.safeParse({ model: 'claude' }).success).toBe(false); + }); +}); + +describe('NULLABLE_PATCH_KEYS', () => { + it('covers the spec-optional keys a reset makes sense for', () => { + for (const key of ['reasoning', 'tools', 'triggers', 'mcpServers', 'promptCaching', 'fallbackModels']) { + expect(NULLABLE_PATCH_KEYS).toContain(key); + } + }); + + it('excludes description and the required fields', () => { + for (const key of ['description', 'name', 'model', 'instructions', 'version']) { + expect(NULLABLE_PATCH_KEYS).not.toContain(key); + } + }); +}); + +describe('describe coverage', () => { + it('FIELD_DESCRIPTIONS covers exactly the draft keys', () => { + expect(Object.keys(FIELD_DESCRIPTIONS).sort()).toEqual(Object.keys(authorDraftSchema.shape).sort()); + }); + + it('authorPatchSchema has the same keys as the draft schema', () => { + expect(Object.keys(authorPatchSchema.shape).sort()).toEqual(Object.keys(authorDraftSchema.shape).sort()); + }); + + it.each(Object.keys(authorDraftSchema.shape))('draft field %s is described', (key) => { + expect(authorDraftSchema.shape[key]?.description).toBe(FIELD_DESCRIPTIONS[key]); + }); + + it.each(Object.keys(authorPatchSchema.shape))('patch field %s is described', (key) => { + expect(authorPatchSchema.shape[key]?.description).toBe(FIELD_DESCRIPTIONS[key]); + }); +}); diff --git a/apps/agent-forge/server/author/draft/__tests__/toSpec.test.ts b/apps/agent-forge/server/author/draft/__tests__/toSpec.test.ts new file mode 100644 index 00000000..eb76081a --- /dev/null +++ b/apps/agent-forge/server/author/draft/__tests__/toSpec.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import type { AgentSpecV2 } from 'lousho'; +import { isValidAgentId } from '../../../../shared/agentId'; +import type { AuthorDraft, AuthorPatch } from '../schema'; +import { applyAuthorPatch, deriveAgentId, draftInvalidFailure, draftToSpec } from '../toSpec'; + +/** + * The draft → spec build rules: `{ version: 2, ...draft }` plus the + * chat-trigger backstop for creates; the keep / replace / clear / reset + * patch semantics for edits; the DRAFT_INVALID failure copy; and the + * name → agent-id derivation. + */ + +const DRAFT: AuthorDraft = { + name: 'Support triage', + description: 'Routes support mail to the right queue.', + model: 'anthropic/claude-sonnet-5-5', + instructions: 'You triage support mail and pick the queue.', +}; + +const BASE: AgentSpecV2 = { + version: 2, + name: 'Support triage', + description: 'Routes support mail to the right queue.', + model: 'anthropic/claude-sonnet-5-5', + instructions: 'You triage support mail and pick the queue.', + tools: [{ builtin: 'web_fetch' }], + reasoning: 'low', + triggers: [{ id: 't1', type: 'chat' }], +}; + +describe('draftToSpec', () => { + it('builds a v2 spec and appends a chat trigger', () => { + const result = draftToSpec(DRAFT); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.spec.version).toBe(2); + expect(result.spec.name).toBe('Support triage'); + expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]); + } + }); + + it('keeps a chat trigger the draft already has, exactly once', () => { + const result = draftToSpec({ ...DRAFT, triggers: [{ id: 't1', type: 'chat' }] }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]); + }); + + it('appends the chat trigger with the first unused t id', () => { + const result = draftToSpec({ + ...DRAFT, + triggers: [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }], + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.spec.triggers).toEqual([ + { id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }, + { id: 't2', type: 'chat' }, + ]); + } + }); + + it("does not mutate the draft's triggers list when appending the chat trigger", () => { + const triggers: AuthorDraft['triggers'] = [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }]; + const draft: AuthorDraft = { ...DRAFT, triggers }; + const result = draftToSpec(draft); + expect(result.ok).toBe(true); + expect(draft.triggers).toBe(triggers); + expect(draft.triggers).toEqual([{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Weekly sweep.' }]); + }); + + it('reports issues as path: message with [n] array indexes', () => { + const result = draftToSpec({ + ...DRAFT, + guardrails: [{ id: 'g1', name: 'not-a-guardrail' as never, on: 'output' }], + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues.some((issue) => issue.path === 'guardrails[0].name')).toBe(true); + } + }); +}); + +describe('applyAuthorPatch', () => { + const apply = (patch: AuthorPatch) => applyAuthorPatch(BASE, patch); + + it('{} keeps the base unchanged (the chat trigger stays)', () => { + const result = apply({}); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec).toEqual(BASE); + }); + + it('a provided list replaces the list wholesale', () => { + const result = apply({ tools: [{ builtin: 'todo' }, { builtin: 'day_name' }] }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.tools).toEqual([{ builtin: 'todo' }, { builtin: 'day_name' }]); + }); + + it('[] clears a list', () => { + const result = apply({ tools: [] }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.tools).toEqual([]); + }); + + it('null resets an optional field to default (the key is gone)', () => { + const result = apply({ reasoning: null }); + expect(result.ok).toBe(true); + if (result.ok) expect('reasoning' in result.spec).toBe(false); + }); + + it('a provided object replaces the object', () => { + const result = apply({ modelSettings: { temperature: 0.5 } }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.modelSettings).toEqual({ temperature: 0.5 }); + }); + + it('null on triggers deletes the list; the chat-trigger backstop re-adds it', () => { + const result = apply({ triggers: null }); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]); + }); + + it('adds a chat trigger when the base somehow has none', () => { + const result = applyAuthorPatch({ ...BASE, triggers: undefined }, {}); + expect(result.ok).toBe(true); + if (result.ok) expect(result.spec.triggers).toEqual([{ id: 't1', type: 'chat' }]); + }); + + it('uses the first unused t when other trigger ids exist', () => { + const result = applyAuthorPatch({ ...BASE, triggers: [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }] }, {}); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.spec.triggers?.[result.spec.triggers.length - 1]).toEqual({ id: 't2', type: 'chat' }); + } + }); + + it("does not mutate the base's triggers list when appending the chat trigger", () => { + const triggers: AgentSpecV2['triggers'] = [{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }]; + const base: AgentSpecV2 = { ...BASE, triggers }; + const result = applyAuthorPatch(base, {}); + expect(result.ok).toBe(true); + expect(base.triggers).toBe(triggers); + expect(base.triggers).toEqual([{ id: 't1', type: 'cron', cron: '0 9 * * 1', prompt: 'Sweep.' }]); + }); + + it('reports spec issues with [n] paths', () => { + const result = apply({ guardrails: [{ id: 'g1', name: 'not-a-guardrail' as never }] }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues.some((issue) => issue.path === 'guardrails[0].name')).toBe(true); + } + }); +}); + +describe('draftInvalidFailure', () => { + it('returns the DRAFT_INVALID failure with the contract copy', () => { + const failure = draftInvalidFailure('proposeAgent', [ + { path: 'model', message: 'is required' }, + { path: 'guardrails[0].name', message: "unknown guardrail 'x'" }, + ]); + expect(failure).toEqual({ + ok: false, + code: 'DRAFT_INVALID', + error: "The draft does not match AgentSpec v2: model: is required; guardrails[0].name: unknown guardrail 'x'.", + userMessage: 'The draft had a few invalid settings. The Author is fixing them.', + next: 'Fix the fields listed and call proposeAgent again. Use listTools, listGuardrails and listTriggerTypes for valid values.', + }); + }); + + it('lists at most ten issues', () => { + const issues = Array.from({ length: 12 }, (_, i) => ({ path: `f${i}`, message: 'bad' })); + const failure = draftInvalidFailure('proposeAgentUpdate', issues); + expect(failure.error).toContain('f9: bad'); + expect(failure.error).not.toContain('f10'); + expect(failure.next).toContain('call proposeAgentUpdate again'); + }); +}); + +describe('deriveAgentId', () => { + const cases: Array<[string, ReadonlySet, string]> = [ + ['Support Triage!', new Set(), 'support-triage'], + ['Support Triage!', new Set(['support-triage']), 'support-triage-2'], + ['Support Triage!', new Set(['support-triage', 'support-triage-2']), 'support-triage-3'], + ['', new Set(), 'agent'], + ['!!!', new Set(), 'agent'], + ['', new Set(['agent']), 'agent-2'], + ['Ünïcödé Ægent', new Set(), 'n-c-d-gent'], + ['x'.repeat(60), new Set(), 'x'.repeat(48)], + ['--lead and trail--', new Set(), 'lead-and-trail'], + ['123 Numbers', new Set(), '123-numbers'], + ]; + + it.each(cases)('deriveAgentId(%j) -> %s', (name, taken, expected) => { + expect(deriveAgentId(name, taken)).toBe(expected); + }); + + it('every derived id passes isValidAgentId', () => { + for (const [name, taken] of cases) { + const id = deriveAgentId(name, taken as ReadonlySet); + expect(isValidAgentId(id)).toBe(true); + expect(isValidAgentId(deriveAgentId(name, new Set([...(taken as ReadonlySet), id])))).toBe(true); + } + }); +}); diff --git a/apps/agent-forge/server/author/draft/schema.ts b/apps/agent-forge/server/author/draft/schema.ts new file mode 100644 index 00000000..81ff6546 --- /dev/null +++ b/apps/agent-forge/server/author/draft/schema.ts @@ -0,0 +1,178 @@ +/** + * The zod schemas of what the Author may send (PLAN §8.2): + * `authorDraftSchema` is the `draft` of `proposeAgent` - AgentSpec v2 + * projected down to the fields a model may set (`version`, `evals` and + * `flow` are never the model's), with `description` required and + * `instructions` always a string. `authorPatchSchema` is the `patch` of + * `proposeAgentUpdate`: every draft key optional ("omitted = keep"), the + * spec-optional ones nullable ("null = reset to default"). + * + * Both are derived from the SDK's `agentSpecV2Schema` - never re-declared + * - so the Author cannot drift from what a saved spec may hold, and every + * field carries a `.describe()` (FIELD_DESCRIPTIONS) that the tool input's + * JSON Schema surfaces to the model. + */ +import { z } from 'zod'; +import { agentSpecV2Schema } from 'lousho'; +import type { AgentSpecV2 } from 'lousho'; + +/** The spec keys the Author never sets: `version` is forced to 2, `evals` and `flow` are not in v1 of the Author. */ +export const DRAFT_OMITTED_KEYS = ['version', 'evals', 'flow'] as const; + +/** + * `agentSpecV2Schema` as the strict zod object it is at run time. Its + * published type (SpecObjectSchema) is structural so the SDK's + * declarations do not depend on the installed zod major; Studio pins the + * same zod the SDK builds with, so composing it as a zod object is safe. + */ +const specSchema = agentSpecV2Schema as unknown as z.ZodObject; + +/** + * One sentence per draftable field - what the field does, when to set it, + * and which list tool to copy values from. Must cover exactly the keys of + * `authorDraftSchema`: the model only ever sees these descriptions, so a + * spec key added without a row fails schema.test.ts until one is written. + */ +export const FIELD_DESCRIPTIONS: Readonly> = { + name: 'Short human name, unique among agents. Letters, digits, spaces, dots, underscores, dashes; up to 64 characters.', + description: 'One plain sentence saying what the agent does and for whom.', + model: 'Model id as provider/model, copied from listModels.', + fallbackModels: 'Model ids to try in order when the main model fails. Only when the user asks.', + instructions: + "The agent's system prompt: the job, the steps, the tools each step uses, and what it must never do.", + instructionsByFamily: + 'Extra instructions appended when the model id contains the key, e.g. { "claude": "..." }. Rarely needed.', + promptRef: 'Prompt registry reference name@label. instructions stays as the fallback. Only when the user asks.', + modelSettings: 'Sampling settings such as temperature and maxOutputTokens. Only when the user asks.', + reasoning: 'Reasoning effort off, low, medium or high, or { budgetTokens }. Only when the user asks.', + tools: + 'Tool references: { builtin }, { registry }, { integration }, { openapi }, { hosted } or { hub }. The smallest set that covers the instructions.', + mcpServers: 'MCP servers by key. Secrets as { "secret": NAME } or { "env": VAR }, never literal values.', + skills: 'Skills to attach: { dir }, { registry } or { inline: { name, description, content } }.', + memory: 'Memory slots with name, scope, provider, recall and expose.', + subagents: 'Subagents by name: { ref: agentId }, { inline: spec } or { remote: { url, description } }.', + subagentOptions: '{ parallel, readOnly } for subagent calls.', + maxSubagentDepth: 'How deep subagents may nest. Default 1.', + handoffs: 'Agents this one can hand the conversation to: { agent, description?, inputFilter? }.', + maxHandoffs: 'Maximum handoffs per run. Default 5.', + guardrails: 'Guardrails with id, name from listGuardrails, on input/output/both, and options.', + permissions: 'Permission rules with id, tool glob, optional when matcher, and action allow, deny, ask or auto.', + permissionMode: 'Default permission mode. Leave unset unless the user asks.', + approvalTtlMs: 'How long a pending approval stays answerable, in milliseconds.', + askQuestion: 'true lets the agent ask the user a question mid-run.', + hooks: 'Hooks: { id, file } or { id, inline: { name, point, code } }. Inline code runs sandboxed.', + output: "{ jsonSchema } when another system reads the agent's answer as JSON.", + limits: 'Run limits such as maxCostUsd or maxDurationMs. Only when the user asks.', + maxSteps: 'Maximum model steps per run.', + compaction: 'true or { thresholdPercent } to summarise long conversations.', + retry: 'false, or { maxRetries, backoff, timeoutMs } for provider retries.', + rateLimit: '{ requestsPerMinute, tokensPerMinute }.', + promptCaching: 'false turns provider prompt caching off. Default on.', + toolSearch: '{ enabled, maxResults } to load tools on demand when there are many.', + codeMode: '{ enabled } to let the model call tools from code.', + triggers: 'What starts the agent: chat, cron, webhook, channel or agent. Always keep a chat trigger.', + abortListeners: 'Rules that cancel queued or running runs of a trigger when a matching event arrives.', +}; + +/** The longest instructions text the Author may write in one draft. */ +const INSTRUCTIONS_MAX_CHARS = 50_000; + +/** The draft's description cap: one sentence, stricter than the spec's 1024. */ +const DESCRIPTION_MAX_CHARS = 500; + +/** + * `agentSpecV2Schema`'s fields minus {@link DRAFT_OMITTED_KEYS}, with the + * two overrides the Author contract adds: `description` is required and + * capped (the spec makes it optional at 1024, but the Author always + * writes the one-sentence pitch a subagent or handoff target needs), and + * `instructions` is a plain string - the Author never writes `{ file }`. + * Every field keeps the spec's own validation (`name`, `model`, ... are + * required exactly where the spec requires them) and is `.describe()`d. + */ +function draftFieldSchemas(): Record { + const specFields = specSchema.omit({ version: true, evals: true, flow: true }).shape; + return Object.fromEntries( + Object.entries(specFields).map(([key, field]) => { + const schema = + key === 'description' + ? z.string().min(1, 'must not be empty').max(DESCRIPTION_MAX_CHARS, `must be at most ${DESCRIPTION_MAX_CHARS} characters`) + : key === 'instructions' + ? z.string().min(1, 'must not be empty').max(INSTRUCTIONS_MAX_CHARS, `must be at most ${INSTRUCTIONS_MAX_CHARS} characters`) + : field; + return [key, schema.describe(FIELD_DESCRIPTIONS[key])]; + }) + ); +} + +/** The described draft fields both schemas are built from. */ +const DRAFT_FIELDS = draftFieldSchemas(); + +/** + * `AgentSpecV2` as the Author may set it: every key except + * {@link DRAFT_OMITTED_KEYS}, `description` required and `instructions` + * narrowed to a string. + */ +type AuthorDraftFields = Omit & { + /** One plain sentence saying what the agent does and for whom; required of a draft. */ + description: string; + /** The system prompt, always as text. */ + instructions: string; +}; + +/** + * The keys a patch may `null` ("reset to default"): every optional + * top-level key of `agentSpecV2Schema` except `description` - a null + * description is a mistake, not a reset, since the Author always keeps + * the one-sentence pitch true. Computed from the schema's shape so an + * optional field the spec gains is resettable automatically. + */ +export const NULLABLE_PATCH_KEYS: readonly string[] = Object.entries(specSchema.shape) + .filter(([key, field]) => key !== 'description' && field.isOptional()) + .map(([key]) => key); + +/** + * What `proposeAgent` takes as `draft`: a whole new agent, strict so an + * unknown key fails rather than silently landing in the spec. + */ +export const authorDraftSchema = z.object(DRAFT_FIELDS).strict() as unknown as z.ZodObject< + z.ZodRawShape, + 'strict', + z.ZodTypeAny, + AuthorDraftFields, + AuthorDraftFields +>; + +/** A draft the Author sent, validated: the spec minus the model-never-sets keys. */ +export type AuthorDraft = z.infer; + +/** The `AgentSpecV2` keys declared optional (the ones {@link NULLABLE_PATCH_KEYS} computes). */ +type OptionalSpecKeys = { [K in keyof AgentSpecV2]-?: undefined extends AgentSpecV2[K] ? K : never }[keyof AgentSpecV2]; + +/** + * `AuthorDraft` as a patch: every key optional; the spec-optional fields + * (all but `description`) also take `null`, meaning "reset to default". + */ +type AuthorPatchFields = { + [K in keyof AuthorDraftFields]?: K extends Exclude + ? AuthorDraftFields[K] | null + : AuthorDraftFields[K]; +}; + +/** + * What `proposeAgentUpdate` takes as `patch`: every draft key optional + * (omitted = keep), the {@link NULLABLE_PATCH_KEYS} ones also `null`. + * The parsed patch preserves `null` values; `applyAuthorPatch` reads them. + */ +export const authorPatchSchema = z + .object( + Object.fromEntries( + Object.entries(DRAFT_FIELDS).map(([key, field]) => [ + key, + (NULLABLE_PATCH_KEYS.includes(key) ? field.nullable() : field).optional().describe(FIELD_DESCRIPTIONS[key]), + ]) + ) + ) + .strict() as unknown as z.ZodObject; + +/** A patch the Author sent, validated: partial draft fields plus `null` resets. */ +export type AuthorPatch = z.infer; diff --git a/apps/agent-forge/server/author/draft/toSpec.ts b/apps/agent-forge/server/author/draft/toSpec.ts new file mode 100644 index 00000000..ad5bf7c2 --- /dev/null +++ b/apps/agent-forge/server/author/draft/toSpec.ts @@ -0,0 +1,159 @@ +/** + * Turning what the Author sent into a validated `AgentSpecV2` (PLAN §8.2): + * `draftToSpec` for creates, `applyAuthorPatch` for the keep / replace / + * clear / reset patch semantics of edits, `draftInvalidFailure` for the + * `DRAFT_INVALID` tool result, and `deriveAgentId` for name → id. + * + * Both builders add the chat-trigger backstop (every agent can be tried + * from the chat pane) and validate with the SDK's `agentSpecV2Schema` - + * the same gate a saved spec passes, so a proposal can never hold a spec + * Studio could not store. Catalog checks (is the model real, is the tool + * known) and diffs are the propose tools' job, not this layer's. + */ +import { agentSpecV2Schema } from 'lousho'; +import type { AgentSpecV2 } from 'lousho'; +import type { AuthorToolFailure } from '../../../shared/authorWire'; +import { toolFailure } from '../tools/failure'; +import type { AuthorDraft, AuthorPatch } from './schema'; + +/** One spec validation problem, flattened to `path: message` (array indexes as `[n]`). */ +export interface SpecIssue { + path: string; + message: string; +} + +/** + * What `draftToSpec` / `applyAuthorPatch` yield: the validated spec, or + * the issues a propose tool turns into a `DRAFT_INVALID` failure with + * {@link draftInvalidFailure}. + */ +export type SpecBuildResult = { ok: true; spec: AgentSpecV2 } | { ok: false; issues: SpecIssue[] }; + +/** An issue as the published `SafeParser` reports it (structural, like draftRoutes' SpecSchemaIssue). */ +interface SpecSchemaIssue { + readonly message: string; + readonly path?: ReadonlyArray; +} + +/** `['tools', 2, 'builtin']` → `tools[2].builtin`; an empty path is `(root)`. */ +function flattenPath(issue: SpecSchemaIssue): string { + let path = ''; + for (const segment of issue.path ?? []) { + const part = typeof segment === 'object' ? segment.key : segment; + path += typeof part === 'number' ? `[${part}]` : path === '' ? String(part) : `.${String(part)}`; + } + return path === '' ? '(root)' : path; +} + +/** `agentSpecV2Schema.safeParse` of a candidate spec as a {@link SpecBuildResult}. */ +function validateSpec(candidate: unknown): SpecBuildResult { + const parsed = agentSpecV2Schema.safeParse(candidate); + if (parsed.success) return { ok: true, spec: parsed.data }; + return { + ok: false, + issues: parsed.error.issues.map((issue) => ({ path: flattenPath(issue), message: issue.message })), + }; +} + +/** + * Every agent keeps a chat trigger: when `spec.triggers` is a list without + * a `type: 'chat'` entry, replaces it with a copy ending in + * `{ id: 't', type: 'chat' }` where `n` is the first `t` id not taken + * (a copy, not a push - `spec` is a shallow spread of the caller's draft + * or base spec, so pushing would write into their `triggers` array); + * an absent `triggers` becomes just the chat trigger. A `triggers` that + * is not a list is left alone - the schema reports it. + */ +function ensureChatTrigger(spec: Record): void { + const triggers = spec.triggers; + if (triggers === undefined) { + spec.triggers = [{ id: 't1', type: 'chat' }]; + return; + } + if (!Array.isArray(triggers)) return; + const hasChat = triggers.some( + (trigger) => typeof trigger === 'object' && trigger !== null && (trigger as { type?: unknown }).type === 'chat' + ); + if (hasChat) return; + const usedIds = new Set( + triggers.map((trigger) => (typeof trigger === 'object' && trigger !== null ? (trigger as { id?: unknown }).id : undefined)) + ); + let n = 1; + while (usedIds.has(`t${n}`)) n += 1; + spec.triggers = [...triggers, { id: `t${n}`, type: 'chat' }]; +} + +/** + * `proposeAgent`'s draft as a spec: `{ version: 2, ...draft }` plus the + * chat-trigger backstop, validated. The draft schema already checked the + * shape; this validation is what makes the result storable. + */ +export function draftToSpec(draft: AuthorDraft): SpecBuildResult { + const candidate: Record = { version: 2, ...draft }; + ensureChatTrigger(candidate); + return validateSpec(candidate); +} + +/** + * `proposeAgentUpdate`'s patch over the current spec (PLAN §8.2): an + * omitted key keeps the base value, `null` deletes the key (reset to + * default) and any other value replaces it - a list replaces the whole + * list, `[]` clears it. The merge gets the chat-trigger backstop and the + * same validation a draft gets, so a patch can never produce a spec the + * schema rejects. + */ +export function applyAuthorPatch(base: AgentSpecV2, patch: AuthorPatch): SpecBuildResult { + const merged: Record = { ...base }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + if (value === null) delete merged[key]; + else merged[key] = value; + } + ensureChatTrigger(merged); + return validateSpec(merged); +} + +/** + * The `DRAFT_INVALID` failure for a rejected build: the first ten issues + * as `path: message` for the model, one plain sentence for the person + * (the card stays hidden while the Author fixes its own draft). + */ +export function draftInvalidFailure( + toolName: 'proposeAgent' | 'proposeAgentUpdate', + issues: SpecIssue[] +): AuthorToolFailure { + const detail = issues + .slice(0, 10) + .map((issue) => `${issue.path}: ${issue.message}`) + .join('; '); + return toolFailure( + 'DRAFT_INVALID', + `The draft does not match AgentSpec v2: ${detail}.`, + 'The draft had a few invalid settings. The Author is fixing them.', + `Fix the fields listed and call ${toolName} again. Use listTools, listGuardrails and listTriggerTypes for valid values.` + ); +} + +/** The longest a derived agent id's slug may be, before the collision suffix. */ +const AGENT_ID_SLUG_MAX = 48; + +/** + * A draft's human name as an agent id: lowercase, runs of non `[a-z0-9]` + * collapsed to `-`, edge `-`s trimmed, capped at 48 characters, `'agent'` + * when nothing usable remains - then `-2`, `-3`, ... while `taken` holds + * the id. Every output is a valid {@link isValidAgentId} id. + */ +export function deriveAgentId(name: string, taken: ReadonlySet): string { + const slug = + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, AGENT_ID_SLUG_MAX) + .replace(/-+$/g, '') || 'agent'; + let id = slug; + for (let suffix = 2; taken.has(id); suffix += 1) { + id = `${slug}-${suffix}`; + } + return id; +}