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
32 changes: 31 additions & 1 deletion packages/agent-directory-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ test("createAgentDefinition posts to the workflow-agent-directory definitions en
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
// The real create route serializes a `text` DB column, always
// a string on the wire — never the JS number literal CL-6480
// let this schema wrongly accept.
currentVersion: "1",
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
);
Expand All @@ -57,6 +61,32 @@ test("createAgentDefinition posts to the workflow-agent-directory definitions en
systemPrompt: "You are a careful research assistant.",
});
expect(result.id).toBe("def_1");
expect(result.currentVersion).toBe("1");
expect(result.modelNote).toBeNull();
});

test("createAgentDefinition rejects a response whose currentVersion is a number, not a string", async () => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
)) as unknown as typeof fetch;

await expect(
createAgentDefinition(testConfig(fetchImpl), {
name: "x",
handle: "x",
systemPrompt: "x",
}),
).rejects.toThrow(/did not match the expected shape/);
});

test("createAgentDefinition throws CreateAgentDefinitionError on a 400", async () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/agent-directory-tools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,19 @@ export interface CreatedAgentDefinition {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly currentVersion: number;
/** The create route serializes `workflow_definition.current_version`,
* a `text` DB column, verbatim — always a string on the wire, never
* a number (CL-6480: parsing this as `"number"` made every genuine
* success fail this schema and read as a create failure). */
readonly currentVersion: string;
readonly status: string;
readonly skills: readonly string[];
/** Set when `model` was requested but the tenant's catalog didn't
* offer it, so the route substituted its default (or left the
* definition modelless) instead of baking in a name that can never
* resolve (CL-6477). `null` when the requested model — or its
* absence — needed no substitution. */
readonly modelNote: string | null;
}

export interface ListedAgentDefinition {
Expand Down Expand Up @@ -93,9 +103,10 @@ const CreatedAgentDefinitionResponse = type({
id: "string",
name: "string",
description: "string | null",
currentVersion: "number",
currentVersion: "string",
status: "string",
skills: "string[]",
modelNote: "string | null",
});

/** Thrown when the create-agent route rejects the request — a bad
Expand Down
72 changes: 68 additions & 4 deletions packages/agent-directory-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ test("create_agent's description does not say a human must approve before anythi
);
});

test("create_agent's modelPreference field tells the model to omit it rather than guess a name", () => {
const bundle = agentDirectoryTools(testEnv());
const definition = bundle.definitions[1] as unknown as {
inputSchema: {
properties: { modelPreference: { description: string } };
};
};
const description =
definition.inputSchema.properties.modelPreference.description;
expect(description).toMatch(/omit/i);
expect(description).toMatch(/do not (guess|invent)/i);
});

test("create_agent's input schema requires name and systemPrompt only", () => {
const bundle = agentDirectoryTools(testEnv());
const definition = bundle.definitions[1] as unknown as {
Expand Down Expand Up @@ -142,9 +155,10 @@ test("create_agent creates then invites by default, in one call sequence", async
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
currentVersion: "1",
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
);
Expand Down Expand Up @@ -189,9 +203,10 @@ test("create_agent with invite: false creates but never calls the invite route",
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
currentVersion: "1",
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
);
Expand Down Expand Up @@ -225,9 +240,10 @@ test("create_agent reports a create-succeeded/invite-failed half-failure honestl
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
currentVersion: "1",
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
);
Expand Down Expand Up @@ -265,9 +281,10 @@ test("create_agent maps modelPreference to the create route's model field", asyn
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: 1,
currentVersion: "1",
status: "deployed",
skills: [],
modelNote: null,
}),
{ status: 201 },
);
Expand Down Expand Up @@ -299,6 +316,53 @@ test("create_agent maps modelPreference to the create route's model field", asyn
}
});

test("create_agent surfaces a model fallback note in its content, and still completes the invite, rather than producing a silently dead agent", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL) => {
if (String(url).endsWith("/definitions")) {
return new Response(
JSON.stringify({
id: "def_1",
name: "Research Buddy",
description: null,
currentVersion: "1",
status: "deployed",
skills: [],
modelNote:
'Requested model "gpt-4o" is not in this workbench\'s catalog; used the workspace default "ollama/llama3" instead.',
}),
{ status: 201 },
);
}
return new Response(
JSON.stringify({
address: "ins_1@acme.example",
definitionId: "def_1",
handle: "research-buddy",
}),
{ status: 201 },
);
}) as unknown as typeof fetch;
try {
const bundle = agentDirectoryTools(testEnv());
const result = await bundle.run(
callFor(CREATE_AGENT_TOOL, {
name: "Research Buddy",
systemPrompt: "You are a careful research assistant.",
modelPreference: "gpt-4o",
}),
new AbortController().signal,
);
expect(result.isError).toBeFalsy();
expect(result.content).toMatch(/Created "Research Buddy"/);
expect(result.content).toMatch(/invited/);
expect(result.content).toMatch(/gpt-4o/);
expect(result.content).toMatch(/ollama\/llama3/);
} finally {
globalThis.fetch = originalFetch;
}
});

test("create_agent surfaces the create route's own rejection honestly on failure", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
Expand Down
24 changes: 19 additions & 5 deletions packages/agent-directory-tools/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,14 +161,22 @@ async function runCreateAgent(
return errorResult(call.id, err);
}

// Set when the requested `modelPreference` fell outside the tenant's
// catalog and the route substituted its default (or left the
// definition modelless) instead of baking in a name that can never
// resolve (CL-6477) — surfaced on every branch below so the model
// relays the substitution to the user rather than claiming the
// model it originally asked for.
const modelSuffix = created.modelNote !== null ? ` ${created.modelNote}` : "";

// `invite` defaults to `true` — never require the model to pass it,
// only to opt out explicitly.
const shouldInvite = call.arguments["invite"] !== false;
if (!shouldInvite) {
return {
callId: call.id,
isError: false,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}). It is not in this channel — invite it explicitly if you want it here.`,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}). It is not in this channel — invite it explicitly if you want it here.${modelSuffix}`,
};
}

Expand All @@ -177,7 +185,7 @@ async function runCreateAgent(
return {
callId: call.id,
isError: false,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}) and invited it into this channel.`,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}) and invited it into this channel.${modelSuffix}`,
};
} catch (err) {
// The agent was genuinely created — that half-success must never
Expand All @@ -194,7 +202,7 @@ async function runCreateAgent(
return {
callId: call.id,
isError: false,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}), but could not invite it into this channel: ${reason}.`,
content: `Created "${created.name}" (use this id for routines/dispatch: ${created.id}), but could not invite it into this channel: ${reason}.${modelSuffix}`,
};
}
}
Expand Down Expand Up @@ -254,8 +262,14 @@ export const agentDirectoryTools = defineTool<WorkflowAgentDirectoryEnv>({
modelPreference: {
type: "string",
description:
"A canonical model name for the new agent, or omitted " +
"to use the workspace's catalog default.",
"A canonical model name from this workspace's own " +
"connected catalog — check list_agents or a models " +
"listing tool for real names first. Do not guess or " +
"invent a name (e.g. a well-known provider model like " +
'"gpt-4o") on the assumption it is available: a name ' +
"outside this workspace's catalog is never used and " +
"falls back to the workspace default instead. Omit " +
"this field entirely to use that default.",
},
invite: {
type: "boolean",
Expand Down
44 changes: 35 additions & 9 deletions packages/agent-directory/src/workflow-create-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,11 +162,16 @@ export function createWorkflowAgentCreateRoutes(
);
}

// Resolved once, unconditionally: every branch below already needed
// it (the pin check when pins are named, the baseline lookup when
// they aren't), and it is now also the source of truth for `model`
// validation just below.
const inventory = await deps.capabilityInventory.resolve({
tenantId: scope.tenantId,
principalId: scope.principalId,
});

if (body.toolPackagePins !== undefined && body.toolPackagePins.length > 0) {
const inventory = await deps.capabilityInventory.resolve({
tenantId: scope.tenantId,
principalId: scope.principalId,
});
// Throws `CapabilityOutOfInventoryError`, caught by `app.onError`
// above — fail closed against exactly the inventory this call
// just fetched, never a stale or wider one, for every named pin.
Expand Down Expand Up @@ -199,17 +204,37 @@ export function createWorkflowAgentCreateRoutes(
systemPrompt: body.systemPrompt,
skills,
};
if (body.model !== undefined) coreInput.model = body.model;

// `body.model` is free text a language-model tool call supplied —
// untrusted input at a trust boundary (AGENTS.md). A name the
// tenant's own catalog doesn't offer can never resolve at launch,
// so it is never baked in verbatim: fall back to the tenant's
// catalog default and say so, rather than creating a dead agent
// (CL-6477). A name the catalog does offer is used exactly as
// asked, no fallback consulted.
let modelNote: string | null = null;
if (body.model !== undefined) {
const knownModel = inventory.models.some(
(entry) => entry.canonicalName === body.model,
);
if (knownModel) {
coreInput.model = body.model;
} else {
const fallback = await deps.tenantDefaultModel?.(scope.tenantId);
modelNote =
fallback !== undefined
? `Requested model "${body.model}" is not in this workbench's catalog; used the workspace default "${fallback}" instead.`
: `Requested model "${body.model}" is not in this workbench's catalog, and the workspace has no default model to fall back to.`;
if (fallback !== undefined) coreInput.model = fallback;
}
}

if (body.toolPackagePins !== undefined && body.toolPackagePins.length > 0) {
coreInput.toolPackagePins = body.toolPackagePins;
} else {
// No pins named: the specialist still gets the baseline set this
// tenant can resolve, so a created "research agent" can actually
// search, remember, and ask (CL-6206).
const inventory = await deps.capabilityInventory.resolve({
tenantId: scope.tenantId,
principalId: scope.principalId,
});
const baseline = baselineAgentToolPins(inventory);
if (baseline.length > 0) coreInput.toolPackagePins = baseline;
}
Expand All @@ -236,6 +261,7 @@ export function createWorkflowAgentCreateRoutes(
currentVersion: row.currentVersion,
status: row.status,
skills,
modelNote,
},
201,
);
Expand Down
Loading
Loading