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
48 changes: 24 additions & 24 deletions packages/coding-agent/src/core/tools/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,17 @@ const targetSchema = Type.String({
pattern: SUBAGENT_NAME_PATTERN.source,
});

const agentActionSchema = Type.Union(
[
Type.Object(
{
target: targetSchema,
action: Type.Literal("message"),
message: Type.String({ description: "Message for the child agent", minLength: 1 }),
wake: Type.Optional(Type.Boolean({ default: false })),
},
{ additionalProperties: false },
),
Type.Object(
{
target: targetSchema,
action: Type.Literal("interrupt"),
},
{ additionalProperties: false },
),
],
{ type: "object" },
const agentActionSchema = Type.Object(
{
target: targetSchema,
action: Type.Unsafe<AgentActionRequest["action"]>({
type: "string",
enum: ["message", "interrupt"],
}),
message: Type.Optional(Type.String({ description: "Message for the child agent", minLength: 1 })),
wake: Type.Optional(Type.Boolean({ default: false })),
},
{ additionalProperties: false },
);

const agentsSchema = Type.Object(
Expand Down Expand Up @@ -161,16 +152,25 @@ export function createAgentActionTool(
if (!SUBAGENT_NAME_PATTERN.test(request.target)) {
throw new Error(`Invalid subagent name: ${request.target}`);
}
let normalized: AgentActionRequest;
if (request.action === "interrupt") {
if ("message" in request || "wake" in request) {
throw new Error("Interrupt actions do not accept message fields");
}
} else if (request.message.trim().length === 0) {
throw new Error("Subagent message must be non-empty");
normalized = { target: request.target, action: request.action };
} else {
const message = request.message;
if (message === undefined || message.trim().length === 0) {
throw new Error("Subagent message must be non-empty");
}
normalized = {
target: request.target,
action: request.action,
message,
wake: request.wake ?? false,
};
}

const normalized: AgentActionRequest =
request.action === "message" ? { ...request, wake: request.wake ?? false } : request;
const details = await action(normalized);
return {
content: [{ type: "text", text: `Completed ${details.action} action for ${details.target}` }],
Expand Down
35 changes: 15 additions & 20 deletions packages/coding-agent/src/core/tools/update-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,28 +41,23 @@ const updatePlanItemSchema = Type.Object(
{ additionalProperties: false },
);

const updatePlanSchema = Type.Unsafe<UpdatePlanToolInput>({
type: "object",
anyOf: [
Type.Object(
{
plan: Type.Array(updatePlanItemSchema, {
minItems: 1,
description: "Full ordered plan snapshot.",
}),
explanation: Type.Optional(Type.String({ description: "Reason for a material plan change." })),
},
{ additionalProperties: false },
const updatePlanSchema = Type.Object(
{
plan: Type.Optional(
Type.Array(updatePlanItemSchema, {
minItems: 1,
description: "Full ordered plan snapshot. Provide exactly one operation: plan or clear.",
}),
),
Type.Object(
{
clear: Type.Literal(true, { description: "Explicitly abandon and clear the current plan." }),
explanation: Type.Optional(Type.String({ description: "Reason for clearing the plan." })),
},
{ additionalProperties: false },
clear: Type.Optional(
Type.Literal(true, {
description: "Explicitly abandon and clear the current plan. Provide exactly one operation: plan or clear.",
}),
),
],
});
explanation: Type.Optional(Type.String({ description: "Reason for changing or clearing the plan." })),
},
{ additionalProperties: false },
);

export const updatePlanToolSystemPromptContribution = {
snippet: "track progress with an ordered task checklist",
Expand Down
21 changes: 18 additions & 3 deletions packages/coding-agent/test/suite/subagent-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,25 @@ describe("subagent actions", () => {
const tool = createAgentActionTool(action);

expect(tool.executionMode).toBe("parallel");
expect(tool.parameters).toMatchObject({ type: "object", anyOf: expect.any(Array) });
expect(tool.parameters).toMatchObject({
type: "object",
additionalProperties: false,
properties: {
target: { type: "string" },
action: { type: "string", enum: ["message", "interrupt"] },
message: { type: "string" },
wake: { type: "boolean" },
},
});
for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) {
expect(tool.parameters).not.toHaveProperty(keyword);
}
expect(Check(tool.parameters, { target: "worker", action: "message", message: "hello" })).toBe(true);
expect(Check(tool.parameters, { target: "worker", action: "message", message: "hello", wake: true })).toBe(true);
expect(Check(tool.parameters, { target: "worker", action: "interrupt" })).toBe(true);
expect(Check(tool.parameters, { target: "Invalid", action: "interrupt" })).toBe(false);
expect(Check(tool.parameters, { target: "worker", action: "interrupt", wake: true })).toBe(false);
expect(Check(tool.parameters, { target: "worker", action: "interrupt", message: "no" })).toBe(false);
expect(Check(tool.parameters, { target: "worker", action: "interrupt", wake: true })).toBe(true);
expect(Check(tool.parameters, { target: "worker", action: "interrupt", message: "no" })).toBe(true);

const result = await executeAction(tool, { target: "worker", action: "message", message: privateMessage });
expect(action).toHaveBeenCalledWith({
Expand All @@ -106,6 +118,9 @@ describe("subagent actions", () => {
await expect(executeAction(tool, { target: "worker", action: "message", message: " " })).rejects.toThrow(
"Subagent message must be non-empty",
);
await expect(executeAction(tool, { target: "worker", action: "message" } as AgentActionRequest)).rejects.toThrow(
"Subagent message must be non-empty",
);
await expect(
executeAction(tool, { target: "worker", action: "interrupt", wake: true } as AgentActionRequest),
).rejects.toThrow("Interrupt actions do not accept message fields");
Expand Down
31 changes: 31 additions & 0 deletions packages/coding-agent/test/tool-schema-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { createAllToolDefinitions } from "../src/core/tools/index.ts";
import { createAgentActionTool, createAgentsTool, createSpawnAgentTool } from "../src/core/tools/subagents.ts";

const PROHIBITED_ROOT_KEYWORDS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const;

describe("built-in tool schema compatibility", () => {
test("publishes object-rooted schemas without top-level composition keywords", () => {
const definitions = [
...Object.values(createAllToolDefinitions("/workspace")),
createSpawnAgentTool(() => {}),
createAgentActionTool(async () => {
throw new Error("Not executed by schema test");
}),
createAgentsTool(async () => ({ timedOut: false, agents: [] })),
];

for (const definition of definitions) {
const parameters = definition.parameters as Record<string, unknown>;
expect({ name: definition.name, type: parameters.type }).toEqual({
name: definition.name,
type: "object",
});
for (const keyword of PROHIBITED_ROOT_KEYWORDS) {
expect(parameters, `${definition.name} must not use ${keyword} at the schema root`).not.toHaveProperty(
keyword,
);
}
}
});
});
93 changes: 65 additions & 28 deletions packages/coding-agent/test/update-plan-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,47 +165,42 @@ describe("update_plan tool", () => {
).rejects.toThrow("Plan item 1 status must be pending, in_progress, or completed.");
});

test("publishes a DeepSeek-compatible object schema", () => {
test("publishes a provider-compatible object schema", () => {
const definition = createUpdatePlanToolDefinition();
const parameters = definition.parameters as {
type?: string;
anyOf?: Array<{
additionalProperties?: boolean;
properties?: {
clear?: { const?: boolean };
explanation?: { type?: string };
plan?: {
minItems?: number;
items?: {
additionalProperties?: boolean;
properties?: {
status?: { enum?: string[] };
};
additionalProperties?: boolean;
properties?: {
clear?: { const?: boolean };
explanation?: { type?: string };
plan?: {
minItems?: number;
items?: {
additionalProperties?: boolean;
properties?: {
status?: { enum?: string[] };
};
};
};
required?: string[];
}>;
};
required?: string[];
};
const update = parameters.anyOf?.[0];
const clear = parameters.anyOf?.[1];

expect(parameters.type).toBe("object");
expect(parameters.anyOf).toHaveLength(2);
expect(update?.required).toEqual(["plan"]);
expect(update?.additionalProperties).toBe(false);
expect(update?.properties?.plan?.minItems).toBe(1);
expect(update?.properties?.plan?.items?.additionalProperties).toBe(false);
expect(update?.properties?.plan?.items?.properties?.status?.enum).toEqual([
expect(parameters.additionalProperties).toBe(false);
expect(parameters.required).toBeUndefined();
for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) {
expect(parameters).not.toHaveProperty(keyword);
}
expect(parameters.properties?.plan?.minItems).toBe(1);
expect(parameters.properties?.plan?.items?.additionalProperties).toBe(false);
expect(parameters.properties?.plan?.items?.properties?.status?.enum).toEqual([
"pending",
"in_progress",
"completed",
]);
expect(update?.properties?.explanation?.type).toBe("string");
expect(clear?.required).toEqual(["clear"]);
expect(clear?.additionalProperties).toBe(false);
expect(clear?.properties?.clear?.const).toBe(true);
expect(clear?.properties?.explanation?.type).toBe("string");
expect(parameters.properties?.clear?.const).toBe(true);
expect(parameters.properties?.explanation?.type).toBe("string");
});

test("sends the object schema through a DeepSeek interaction", async () => {
Expand Down Expand Up @@ -241,6 +236,48 @@ describe("update_plan tool", () => {
expect(result.stopReason).toBe("stop");
});

test("sends the object schema through an OpenAI Responses interaction", async () => {
const model = getModel("openai", "gpt-5-mini");
expect(model).toBeDefined();
if (!model) throw new Error("Expected the OpenAI model fixture");
let payload: unknown;

const result = await streamSimple(
model,
{
messages: [{ role: "user", content: "Plan this task", timestamp: Date.now() }],
tools: [createUpdatePlanTool()],
},
{
apiKey: "test-key",
fetch: async () =>
new Response(
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
})}\n\n`,
{ status: 200, headers: { "content-type": "text/event-stream" } },
),
onPayload: (requestPayload) => {
payload = requestPayload;
},
},
).result();

const sentPayload = payload as {
tools?: Array<{ name?: string; parameters?: Record<string, unknown> & { type?: string } }>;
};
const updatePlan = sentPayload.tools?.find((tool) => tool.name === "update_plan");
expect(updatePlan?.parameters?.type).toBe("object");
for (const keyword of ["oneOf", "anyOf", "allOf", "enum", "const", "not"]) {
expect(updatePlan?.parameters).not.toHaveProperty(keyword);
}
expect(result.stopReason).toBe("stop");
});

test("marks both tool representations as sequential", () => {
expect(createUpdatePlanToolDefinition().executionMode).toBe("sequential");
expect(createUpdatePlanTool().executionMode).toBe("sequential");
Expand Down