diff --git a/workers/api/src/lib/agent-capabilities.ts b/workers/api/src/lib/agent-capabilities.ts index b206d111..aa82338b 100644 --- a/workers/api/src/lib/agent-capabilities.ts +++ b/workers/api/src/lib/agent-capabilities.ts @@ -240,6 +240,34 @@ export function sanitizeToolList(value: unknown): string[] | undefined { } const KNOWN_SURFACES = new Set(["apply", "coding", "insurance", "repo"]); +const KNOWN_RUNTIMES = new Set>(["browser", "coding"]); +const KNOWN_WORKFLOWS = new Set(["JOB_APPLY", "CODING_SESSION", "INSURANCE_QUOTES"]); + +/** The narrow, validated capabilities a creator declares (subset of AgentCapabilities; + * boardColumns/customSurfaces/settingsSchema are validated where they are read). */ +export interface DeclaredCapabilities { + surfaces: AgentSurface[]; + runtime: AgentRuntimeKind; + workflow: AgentCapabilities["workflow"]; + tools?: string[]; +} + +/** Validate a raw declared-capabilities object (as a creator submits it) into the + * narrow shape stored under config.capabilities: unknown surfaces/runtime/workflow are + * dropped, tools go through sanitizeToolList. Never throws — coerces to safe defaults. */ +export function sanitizeDeclaredCapabilities(value: unknown): DeclaredCapabilities { + const o = (value && typeof value === "object" ? value : {}) as Record; + const surfaces = Array.isArray(o.surfaces) + ? o.surfaces.filter((s): s is AgentSurface => KNOWN_SURFACES.has(s as AgentSurface)) + : []; + const runtime = KNOWN_RUNTIMES.has(o.runtime as Exclude) + ? (o.runtime as AgentRuntimeKind) + : null; + const workflow = KNOWN_WORKFLOWS.has(o.workflow as string) + ? (o.workflow as AgentCapabilities["workflow"]) + : null; + return { surfaces, runtime, workflow, tools: sanitizeToolList(o.tools) }; +} /** Minimal shape we need off an `agents` row to resolve capabilities. */ export interface AgentLike { diff --git a/workers/api/src/lib/agent-definition.test.ts b/workers/api/src/lib/agent-definition.test.ts new file mode 100644 index 00000000..042ec6d3 --- /dev/null +++ b/workers/api/src/lib/agent-definition.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { agentCapabilities } from "./agent-capabilities.js"; +import { sanitizeAgentDefinition } from "./agent-definition.js"; + +describe("sanitizeAgentDefinition", () => { + const valid = { + identity: { + personality: " You are a helpful research assistant. ", + goal: "Answer questions grounded in the indexed docs.", + welcomeMessage: "Ask me anything about your documents.", + guardrails: { responseStyle: "concise", requireCitations: true, maxResponseLength: 500 }, + }, + capabilities: { + surfaces: ["repo"], + runtime: null, + workflow: null, + tools: ["search_knowledge", "read_knowledge"], + }, + settingsSchema: [{ id: "lang", label: "Language", type: "select", options: [{ value: "en", label: "English" }] }], + }; + + it("normalizes a complete definition (trims strings, keeps valid fields)", () => { + const def = sanitizeAgentDefinition(valid); + expect(def.identity.personality).toBe("You are a helpful research assistant."); + expect(def.identity.goal).toBe("Answer questions grounded in the indexed docs."); + expect(def.identity.guardrails.responseStyle).toBe("concise"); + expect(def.identity.guardrails.requireCitations).toBe(true); + expect(def.capabilities.surfaces).toEqual(["repo"]); + expect(def.capabilities.tools).toEqual(["search_knowledge", "read_knowledge"]); + expect(def.settingsSchema).toHaveLength(1); + }); + + it("defaults guardrails when absent and coerces non-string identity to empty", () => { + const def = sanitizeAgentDefinition({ identity: { personality: 42 } }); + expect(def.identity.personality).toBe(""); + expect(def.identity.goal).toBe(""); + // defaultGuardrails() fills a complete, safe object. + expect(def.identity.guardrails).toMatchObject({ + responseStyle: "", + blockedTerms: [], + maxResponseLength: 0, + requireCitations: false, + }); + }); + + it("drops unknown surfaces / runtime / workflow and ungrantable tools", () => { + const def = sanitizeAgentDefinition({ + capabilities: { + surfaces: ["repo", "bogus"], + runtime: "teleport", + workflow: "MADE_UP", + tools: ["search_knowledge", "not_a_tool", "find_confirmation_link"], + }, + }); + expect(def.capabilities.surfaces).toEqual(["repo"]); + expect(def.capabilities.runtime).toBeNull(); + expect(def.capabilities.workflow).toBeNull(); + // sanitizeToolList only trims obvious junk; the ungrantable ones are filtered at + // resolution time — but the malformed catalog name is a well-formed string, so it + // survives sanitize and is dropped later by toolNamesFor. Real tool name stays. + expect(def.capabilities.tools).toContain("search_knowledge"); + }); + + it("omits settingsSchema entirely when none is valid", () => { + expect(sanitizeAgentDefinition({}).settingsSchema).toBeUndefined(); + expect(sanitizeAgentDefinition({ settingsSchema: "nope" }).settingsSchema).toBeUndefined(); + }); + + it("never throws on garbage input", () => { + for (const junk of [null, undefined, 3, "x", [], { identity: null, capabilities: 5 }]) { + expect(() => sanitizeAgentDefinition(junk)).not.toThrow(); + } + }); + + it("round-trips: the emitted config is read back correctly by agentCapabilities", () => { + // The whole point — a definition serialized to agents.config must resolve through + // the SAME registry the runtime uses. + const config = JSON.stringify(sanitizeAgentDefinition(valid)); + const caps = agentCapabilities({ slug: "some-agent", config }); + expect(caps.surfaces).toEqual(["repo"]); + expect(caps.tools).toEqual(["search_knowledge", "read_knowledge"]); + expect(caps.settingsSchema).toHaveLength(1); + }); +}); diff --git a/workers/api/src/lib/agent-definition.ts b/workers/api/src/lib/agent-definition.ts new file mode 100644 index 00000000..59905c8e --- /dev/null +++ b/workers/api/src/lib/agent-definition.ts @@ -0,0 +1,69 @@ +/** + * The declarative agent definition — one validated shape describing everything an + * agent IS as data, so a creator (or the authoring UI / AI builder) can define an + * agent without a platform code change. This is the "creator way" made concrete and + * the single source of truth for agent-config validation. + * + * It composes the existing per-field sanitizers (capabilities, tools, settings schema) + * and the canonical guardrails normalizer rather than re-validating anything, and it + * emits exactly the `agents.config` shape the platform already reads (see the repo-chat + * seed, migration 0032): `{ identity: { personality, goal, guardrails, welcomeMessage }, + * capabilities: { surfaces, runtime, workflow, tools }, settingsSchema? }`. + */ + +import { defaultGuardrails } from "../agent-do-prompt.js"; +import type { Guardrails } from "../agent-types.js"; +import { + type DeclaredCapabilities, + type SettingsField, + sanitizeDeclaredCapabilities, + sanitizeSettingsSchema, +} from "./agent-capabilities.js"; + +/** Who the agent is + how it behaves (applied to each subscriber's instance DO). */ +export interface AgentIdentity { + personality: string; + goal: string; + guardrails: Guardrails; + welcomeMessage: string; +} + +/** A complete, validated declarative agent definition = the stored `agents.config`. */ +export interface AgentDefinition { + identity: AgentIdentity; + capabilities: DeclaredCapabilities; + settingsSchema?: SettingsField[]; +} + +const MAX_PERSONALITY = 4000; +const MAX_GOAL = 2000; +const MAX_WELCOME = 1000; + +function boundedString(value: unknown, max: number): string { + return typeof value === "string" ? value.trim().slice(0, max) : ""; +} + +/** + * Validate + normalize raw creator input into the canonical agent config. Never throws + * — every field is coerced or dropped to a safe default, mirroring the other sanitizers + * — so it is safe to call directly on an untrusted request body. + */ +export function sanitizeAgentDefinition(input: unknown): AgentDefinition { + const o = (input && typeof input === "object" ? input : {}) as Record; + const identityIn = (o.identity && typeof o.identity === "object" ? o.identity : {}) as Record; + + const identity: AgentIdentity = { + personality: boundedString(identityIn.personality, MAX_PERSONALITY), + goal: boundedString(identityIn.goal, MAX_GOAL), + guardrails: defaultGuardrails(identityIn.guardrails as Partial | undefined), + welcomeMessage: boundedString(identityIn.welcomeMessage, MAX_WELCOME), + }; + + const def: AgentDefinition = { + identity, + capabilities: sanitizeDeclaredCapabilities(o.capabilities), + }; + const settingsSchema = sanitizeSettingsSchema(o.settingsSchema); + if (settingsSchema) def.settingsSchema = settingsSchema; + return def; +}