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
9 changes: 8 additions & 1 deletion dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3004,6 +3004,13 @@ var planToolArgs = {
revisit_evidence: z2.string().trim().min(1).max(2000).optional()
};
var PlanToolSchema = z2.object(planToolArgs).strict();
var planToolInputSchema = z2.toJSONSchema(PlanToolSchema, {
io: "input",
unrepresentable: "any"
});
function planToolInput() {
return JSON.parse(JSON.stringify(planToolInputSchema));
}
async function planFromTool(args, context) {
const input = PlanToolSchema.parse(args);
return JSON.stringify({
Expand Down Expand Up @@ -4948,7 +4955,7 @@ function goalToolsV2(services) {
{
name: "update_goal_plan",
description: services.locale === "zh-CN" ? "\u4FDD\u5B58\u76EE\u6807\u7684\u6574\u4F53\u8BA1\u5212\u3001\u9636\u6BB5\u3001\u4EFB\u52A1\u548C\u9A8C\u8BC1\u8BC1\u636E\u3002\u4FDD\u6301\u6574\u4F53\u76EE\u6807\u4E0D\u53D8\uFF1B\u4F7F\u7528 get_goal \u8FD4\u56DE\u7684\u76EE\u6807 ID \u548C\u8BA1\u5212\u7248\u672C\u3002" : "Persist the overall plan, phases, tasks, verification evidence and decisions. Preserve the goal scope; use the goal ID and planRevision from get_goal. Completed work cannot be silently reopened or removed.",
input: v2ObjectSchema(planToolArgs),
input: planToolInput(),
options: { codemode: false },
execute: async (args, context) => ({ content: await planFromTool(args, context) })
},
Expand Down
36 changes: 35 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,40 @@ const planToolArgs = {
}
const PlanToolSchema = z.object(planToolArgs).strict()

/**
* JSON Schema for the V2 update_goal_plan tool input.
*
* Passing the raw zod objects from planToolArgs as the V2 tool `input` leaks
* zod internals into the schema hosts send to providers: serialized artifacts
* like `def`, `checks`, `shape`, `optional`, and null-valued keywords such as
* `maxLength: null` or `format: null`. Providers that strictly validate tool
* schemas (for example Mistral mistral-large-4) reject those requests with
* `Invalid tool schema`, breaking every session that includes the goal tools.
*
* The V2 plugin API accepts JSON Schema rather than the zod objects used by
* the V1 tool API. `io: "input"` keeps the schema provider-facing: optional
* `revisit_evidence` stays out of `required`, and `decisions` keeps its
* `default` instead of being pinned as required output. Convert once here;
* runtime argument validation is unaffected because planFromTool still parses
* through PlanToolSchema.
*/
const planToolInputSchema = z.toJSONSchema(PlanToolSchema, {
io: "input",
unrepresentable: "any",
}) as ToolSchema.ValueSchema

/**
* Deep copy of {@link planToolInputSchema} for each tool registration.
*
* Registrations share one module instance, so handing out the schema object
* itself would let any host (or test framework) that mutates a received tool
* input corrupt the schema for every other registration. The schema is pure
* JSON, so a JSON round-trip is a sufficient deep copy.
*/
function planToolInput(): ToolSchema.ValueSchema {
return JSON.parse(JSON.stringify(planToolInputSchema)) as ToolSchema.ValueSchema
}

async function planFromTool(args: unknown, context: ToolExecContext) {
const input = PlanToolSchema.parse(args)
return JSON.stringify(
Expand Down Expand Up @@ -3356,7 +3390,7 @@ function goalToolsV2(services: GoalServices): ToolV2Info[] {
{
name: "update_goal_plan",
description: services.locale === "zh-CN" ? "保存目标的整体计划、阶段、任务和验证证据。保持整体目标不变;使用 get_goal 返回的目标 ID 和计划版本。" : "Persist the overall plan, phases, tasks, verification evidence and decisions. Preserve the goal scope; use the goal ID and planRevision from get_goal. Completed work cannot be silently reopened or removed.",
input: v2ObjectSchema(planToolArgs),
input: planToolInput(),
options: { codemode: false },
execute: async (args, context) => ({ content: await planFromTool(args, context) }),
},
Expand Down
106 changes: 106 additions & 0 deletions test/server-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,112 @@ test("V2 plan tools publish structured ACP metadata and compaction retains the p
expect(JSON.stringify(compaction)).toContain("compound")
})

test("V2 update_goal_plan input is provider-safe JSON Schema", async () => {
const mock = makeMockContext()
await setupPlugin(mock as never)
const input = goalTool(mock, "update_goal_plan").input as unknown

const zodArtifactKeys = ["def", "checks", "shape", "optional", "catchall", "isFinite", "minValue", "maxValue", "isInt"]
const walk = (node: unknown, path: string, visit: (key: string, value: unknown, path: string) => void) => {
if (Array.isArray(node)) {
node.forEach((child, index) => walk(child, `${path}[${index}]`, visit))
return
}
if (node === null || typeof node !== "object") return
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
visit(key, value, `${path}.${key}`)
walk(value, `${path}.${key}`, visit)
}
}

const invalid: string[] = []
walk(input, "input", (key, value, path) => {
if (zodArtifactKeys.includes(key)) invalid.push(`zod artifact key "${key}" at ${path}`)
if (value === null) invalid.push(`null value for "${key}" at ${path}`)
})
expect(invalid).toEqual([])

const root = input as { type?: string; properties?: Record<string, unknown>; required?: string[]; additionalProperties?: boolean }
expect(root.type).toBe("object")
expect(root.additionalProperties).toBe(false)
expect(Object.keys(root.properties ?? {}).sort()).toEqual([
"expected_revision",
"goal_id",
"plan",
"reason",
"revisit_evidence",
])
expect(root.required?.sort()).toEqual(["expected_revision", "goal_id", "plan", "reason"])
})

test("V2 update_goal_plan registrations do not share a mutable input schema", async () => {
const first = makeMockContext({ auto_continue: false })
await setupPlugin(first as never)
const second = makeMockContext({ auto_continue: false })
await setupPlugin(second as never)

// Simulate a host or test framework that mutates a received tool input.
const corrupted = (goalTool(first, "update_goal_plan").input as { properties?: Record<string, unknown> })
.properties
if (corrupted) for (const key of Object.keys(corrupted)) delete corrupted[key]

const survivor = goalTool(second, "update_goal_plan").input as { properties?: Record<string, unknown> }
expect(Object.keys(survivor.properties ?? {}).sort()).toEqual([
"expected_revision",
"goal_id",
"plan",
"reason",
"revisit_evidence",
])
})

// Nested plan schema assertions ported from #68 (authored by @abeisleem, commits
// b88c9e2/e44a291) with thanks — they complement the wire-safety checks above by
// pinning the serialized nested plan structure.
test("V2 plan tool publishes JSON Schema with required fields and optional nested evidence", async () => {
const mock = makeMockContext({ auto_continue: false })
await setupPlugin(mock as never)
// Inspect the serialized wire schema, not only the outer object wrapper.
const input = JSON.parse(JSON.stringify(goalTool(mock, "update_goal_plan").input))
expect(input.required).toEqual(["goal_id", "expected_revision", "plan", "reason"])
expect(input.additionalProperties).toBe(false)
expect(input.properties.goal_id).toMatchObject({ type: "string", minLength: 1 })
expect(input.properties.expected_revision).toMatchObject({ type: "integer", minimum: 0 })
expect(input.properties.reason).toMatchObject({ type: "string", minLength: 1, maxLength: 2000 })
expect(input.properties.revisit_evidence).toMatchObject({ type: "string", minLength: 1, maxLength: 2000 })

const plan = input.properties.plan
expect(plan.required).toEqual(["summary", "completionCriteria", "phases"])
expect(plan.additionalProperties).toBe(false)
expect(plan.properties.decisions).toMatchObject({ type: "array", default: [], maxItems: 32 })
expect(plan.properties.completionCriteria).toMatchObject({ type: "array", minItems: 1, maxItems: 32 })
const phases = plan.properties.phases
expect(phases).toMatchObject({ type: "array", minItems: 1, maxItems: 64 })
const phase = phases.items
expect(phase.required).toEqual(["id", "objective", "status", "tasks"])
expect(phase.additionalProperties).toBe(false)
const tasks = phase.properties.tasks
expect(tasks).toMatchObject({ type: "array", minItems: 1, maxItems: 128 })
const task = tasks.items
expect(task.required).toEqual(["id", "description", "status"])
expect(task.additionalProperties).toBe(false)
expect(task.properties.status).toEqual({
type: "string",
enum: ["pending", "in_progress", "completed", "blocked"],
})
for (const field of [
phase.properties.verification,
phase.properties.blocker,
task.properties.evidence,
task.properties.blocker,
]) {
expect(field.anyOf).toEqual([
{ type: "string", minLength: 1, maxLength: 2000 },
{ type: "null" },
])
}
})

test("V2 setup registers /goal, /pause_goal, and /resume_goal via command transform", async () => {
const mock = makeMockContext({ auto_continue: false })
const cleanup = await setupPlugin(mock as never)
Expand Down
Loading