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
28 changes: 28 additions & 0 deletions workers/api/src/lib/agent-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,34 @@ export function sanitizeToolList(value: unknown): string[] | undefined {
}

const KNOWN_SURFACES = new Set<AgentSurface>(["apply", "coding", "insurance", "repo"]);
const KNOWN_RUNTIMES = new Set<Exclude<AgentRuntimeKind, null>>(["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<string, unknown>;
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<AgentRuntimeKind, null>)
? (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 {
Expand Down
84 changes: 84 additions & 0 deletions workers/api/src/lib/agent-definition.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
69 changes: 69 additions & 0 deletions workers/api/src/lib/agent-definition.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const identityIn = (o.identity && typeof o.identity === "object" ? o.identity : {}) as Record<string, unknown>;

const identity: AgentIdentity = {
personality: boundedString(identityIn.personality, MAX_PERSONALITY),
goal: boundedString(identityIn.goal, MAX_GOAL),
guardrails: defaultGuardrails(identityIn.guardrails as Partial<Guardrails> | 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;
}
Loading