From 89186626d08dff9bdc1cbfe966fe089039614fea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 19:44:04 +0000 Subject: [PATCH] fix(server): t3_queue advertises null for beforeRunId so agents can move a message to the end The advertised input schema drops the null branch of every optional property, because null there normally just means "omitted". For t3_queue's beforeRunId null is a value of its own: it moves the message to the end of the queue. Agents saw {"type":"string"}, could not send null, and sent the string "null", which is read as a run id. A null branch that carries a description now survives compaction, and beforeRunId describes its null. The tool-list byte budget grows by 56. Fixes pingdotgg/t3code#30303 Co-Authored-By: Claude Fable 5.2 --- apps/server/src/mcp/advertisedJsonSchema.test.ts | 13 ++++++++++++- apps/server/src/mcp/advertisedJsonSchema.ts | 8 ++++---- apps/server/src/mcp/toolkits/thread/tools.ts | 4 +++- apps/server/src/provider/agentContextBudget.json | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/advertisedJsonSchema.test.ts b/apps/server/src/mcp/advertisedJsonSchema.test.ts index 09a90556a9dd0..3a96fe299b144 100644 --- a/apps/server/src/mcp/advertisedJsonSchema.test.ts +++ b/apps/server/src/mcp/advertisedJsonSchema.test.ts @@ -3,14 +3,18 @@ import * as Schema from "effect/Schema"; import { Tool } from "effect/ai"; import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; +import { ThreadToolkit } from "./toolkits/thread/tools.ts"; -it("drops JSON noise, merges tagged unions, and keeps null where it is required", () => { +it("drops JSON noise, merges tagged unions, and keeps null where it is required or described", () => { const Every = Schema.Struct({ ms: Schema.Int }); const tool = Tool.make("example", { parameters: Schema.Struct({ threadId: Schema.optional(Schema.NullOr(Schema.String)), beforeRunId: Schema.NullOr(Schema.String), limit: Schema.optional(Schema.Int), + after: Schema.optional( + Schema.Union([Schema.String, Schema.Null.annotate({ description: "Last." })]), + ), every: Schema.Union([Every, Schema.fromJsonString(Every)]), target: Schema.optional( Schema.NullOr( @@ -29,6 +33,7 @@ it("drops JSON noise, merges tagged unions, and keeps null where it is required" threadId: { type: "string" }, beforeRunId: { anyOf: [{ type: "string" }, { type: "null" }] }, limit: { type: "integer" }, + after: { anyOf: [{ type: "string" }, { type: "null", description: "Last." }] }, every: { type: "object", properties: { ms: { type: "integer" } }, required: ["ms"] }, target: { type: "object", @@ -43,3 +48,9 @@ it("drops JSON noise, merges tagged unions, and keeps null where it is required" required: ["beforeRunId", "every"], }); }); + +it("advertises null for t3_queue's beforeRunId, which moves a message to the end", () => { + expect(advertisedJsonSchema(ThreadToolkit.tools.t3_queue)).toMatchObject({ + properties: { beforeRunId: { anyOf: [{ type: "string" }, { type: "null" }] } }, + }); +}); diff --git a/apps/server/src/mcp/advertisedJsonSchema.ts b/apps/server/src/mcp/advertisedJsonSchema.ts index 10001307f2d6c..8c3a59d3c4241 100644 --- a/apps/server/src/mcp/advertisedJsonSchema.ts +++ b/apps/server/src/mcp/advertisedJsonSchema.ts @@ -97,10 +97,10 @@ const compact = (schema: Json, optional: boolean): Json => { }; /** - * The input schema T3 advertises for a tool. Drops JSON noise that every thread pays for: - * `additionalProperties: true`, safe-integer bounds, nested `anyOf`, JSON-string fallbacks, and - * the `null` branch of optional properties. A tagged union of objects becomes one object. - * Decoding still uses the full schema, so each tag's own required fields still apply. + * The input schema T3 advertises for a tool, without JSON noise every thread pays for. A tagged + * union of objects becomes one object; decoding still uses the full schema. An optional property + * loses its bare `null` branch: where `null` means more than "omitted", describe it + * (`Schema.Null.annotate({ description })`) and it stays. */ export const advertisedJsonSchema = (tool: Tool.Any) => compact(Tool.getJsonSchema(tool), false) as ReturnType; diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 4c59ea4f2028f..c2a352b9b4b95 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -96,7 +96,9 @@ const QueueTool = Tool.make("t3_queue", { threadId: Schema.optional(ThreadId), queuedRunId: Schema.optional(RunId), text: Schema.optional(Schema.String.check(Schema.isMaxLength(100000))), - beforeRunId: Schema.optional(Schema.NullOr(RunId)), + beforeRunId: Schema.optional( + Schema.Union([RunId, Schema.Null.annotate({ description: "To the end." })]), + ), targetRunId: Schema.optional(RunId), cursor: Schema.optional(NonNegativeInt), limit: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 }))), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e7901a8656ebe..ff951d8d97ffe 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5594, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 48094 + "mcpToolListJsonBytes": 48150 }