diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 395d9dfe04fe5..50d6201a7a827 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1583,6 +1583,17 @@ it.effect("lists pull request watch tools only to threads that are not delegated const body = (yield* listed.json) as { readonly result: { readonly tools: ReadonlyArray<{ readonly name: string }> }; }; + expect(body.result.tools.find((tool) => tool.name === "link_pull_request")).toMatchObject( + { + inputSchema: { + properties: { + threadId: { description: "Thread. Default: this thread." }, + repository: { description: "With `number`, when no `url`." }, + host: { description: "Default: this project's host." }, + }, + }, + }, + ); return body.result.tools.map((tool) => tool.name); }); diff --git a/apps/server/src/mcp/advertisedJsonSchema.ts b/apps/server/src/mcp/advertisedJsonSchema.ts index 10001307f2d6c..a2aa7e08a72b8 100644 --- a/apps/server/src/mcp/advertisedJsonSchema.ts +++ b/apps/server/src/mcp/advertisedJsonSchema.ts @@ -11,7 +11,7 @@ const isBareAnyOf = (value: Json): value is { anyOf: Array } => Predicate.isObject(value) && Array.isArray(value.anyOf) && Object.keys(value).length === 1; // A `Schema.fromJsonString` fallback. Advertised beside object shapes, it invites stringified input. -const isJsonStringSchema = (value: Json) => +export const isJsonStringSchema = (value: Json) => Predicate.isObject(value) && value.type === "string" && value.contentMediaType === "application/json"; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index b2aaed70322cf..e82e1bc6ce363 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -27,9 +27,11 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; +import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; +import * as SchemaAST from "effect/SchemaAST"; import { McpAttachmentInput } from "./attachment/input.ts"; -import { McpSchema, McpServer } from "effect/ai"; +import { McpSchema, McpServer, Tool } from "effect/ai"; import { FetchHttpClient } from "effect/http"; import { @@ -1293,6 +1295,101 @@ it.effect("only the caller that prepared a pending upload can discard it", () => ), ); +import { advertisedJsonSchema, isJsonStringSchema } from "../advertisedJsonSchema.ts"; +import { AttachmentToolkit } from "./attachment/tools.ts"; +import { DeviceToolkit } from "./device/tools.ts"; +import { EnvironmentToolkit } from "./environment/tools.ts"; +import { HtmlToolkit } from "./html/tools.ts"; +import { OrchestratorToolkit } from "./orchestrator/tools.ts"; +import { PreviewToolkit } from "./preview/tools.ts"; +import { PreviewControlsToolkit } from "./previewControls/tools.ts"; +import { ProjectToolkit } from "./project/tools.ts"; +import { PullRequestsToolkit } from "./pullRequests/tools.ts"; +import { WorktreeToolkit } from "./worktree/tools.ts"; + +// advertisedJsonSchema drops `Schema.fromJsonString` union branches, and their text with them. +const isJsonStringFallback = (ast: SchemaAST.AST) => + isJsonStringSchema(Schema.toJsonSchemaDocument(Schema.make(ast)).schema); + +// An outer description replaces the one on its encoded side, and on a union's only kept +// branch, so only the outermost counts. +const sourceDescriptions = ( + ast: SchemaAST.AST, + path: string, + out: Array, + described = false, +): void => { + const description = SchemaAST.resolveDescription(ast); + if (!described && description !== undefined) out.push([path, description]); + const covered = described || description !== undefined; + const end = ast.encoding?.at(-1)?.to; + if (end) return sourceDescriptions(end, path, out, covered); + if (SchemaAST.isObjects(ast)) { + for (const property of ast.propertySignatures) + sourceDescriptions(property.type, `${path}.${String(property.name)}`, out); + for (const index of ast.indexSignatures) sourceDescriptions(index.type, `${path}.*`, out); + } else if (SchemaAST.isUnion(ast)) { + const kept = ast.types.filter((member) => !isJsonStringFallback(member)); + const branches = kept.filter( + (member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member), + ); + for (const member of kept) + sourceDescriptions(member, path, out, branches.length === 1 && covered); + } else if (SchemaAST.isArrays(ast)) { + for (const item of ast.rest) sourceDescriptions(item, `${path}[]`, out); + } +}; + +const advertisedDescriptions = (schema: unknown, path: string, out: Set): void => { + if (Array.isArray(schema)) { + for (const member of schema) advertisedDescriptions(member, path, out); + return; + } + if (!Predicate.isObject(schema)) return; + if (typeof schema.description === "string") out.add(`${path}: ${schema.description}`); + if (Predicate.isObject(schema.properties)) + for (const [name, property] of Object.entries(schema.properties)) + advertisedDescriptions(property, `${path}.${name}`, out); + advertisedDescriptions(schema.anyOf, path, out); + advertisedDescriptions(schema.items, `${path}[]`, out); + advertisedDescriptions(schema.additionalProperties, `${path}.*`, out); +}; + +// Restoring these is a context-budget call: about 3.6 KB on every thread's tool list. +const awaitingBudgetCall = new Set([ + "Linked environment to act in. Omit for this one. Ids are then that environment's.", + "Linked environment to run in. Provider and model default to yours; others come from `orchestrator_capabilities` with this `environmentId`.", + "With `environmentId`: its project to run in. Default: the one with this project's repository.", + "The linked environment to forget, from t3_environment_links.", +]); + +it("advertises every parameter description written in the tool schemas", () => { + const tools: Record = { + ...AttachmentToolkit.tools, + ...DeviceToolkit.tools, + ...EnvironmentToolkit.tools, + ...HtmlToolkit.tools, + ...OrchestratorToolkit.tools, + ...PreviewToolkit.tools, + ...PreviewControlsToolkit.tools, + ...ProjectToolkit.tools, + ...PullRequestsToolkit.tools, + ...ThreadToolkit.tools, + ...WorktreeToolkit.tools, + }; + const missing: string[] = []; + for (const [name, tool] of Object.entries(tools)) { + const source: Array = []; + sourceDescriptions(tool.parametersSchema.ast, name, source); + const advertised = new Set(); + advertisedDescriptions(advertisedJsonSchema(tool), name, advertised); + for (const [path, text] of source) + if (!awaitingBudgetCall.has(text) && !advertised.has(`${path}: ${text}`)) + missing.push(`${path}: ${text}`); + } + expect(missing).toEqual([]); +}); + it.effect( "t3_thread_list settled:false with an empty statuses list lists the unsettled threads", () => { diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 376817db10079..e8e83deb646dd 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -45,27 +45,21 @@ const watchDependencies = [...dependencies, PullRequestWatchService.PullRequestW export const PullRequestTargetInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - threadId: Schema.optional( - ThreadId.annotate({ - description: "Thread. Default: this thread.", - }), - ), + threadId: Schema.optional(ThreadId).annotate({ + description: "Thread. Default: this thread.", + }), url: Schema.optional(TrimmedNonEmptyString), - repository: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "With `number`, when no `url`.", - }), - ), + repository: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "With `number`, when no `url`.", + }), number: Schema.optional( PositiveInt.annotate({ description: "With `repository`, when no `url`.", }), ), - host: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "Default: this project's host.", - }), - ), + host: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "Default: this project's host.", + }), }); export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; @@ -260,7 +254,7 @@ const LinkPullRequestsTool = Tool.make("link_pull_requests", { "Link several PRs to a thread. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }), urls: Schema.Array(TrimmedNonEmptyString).check(Schema.isMinLength(1)), }), success: LinkPullRequestsResult, @@ -292,7 +286,7 @@ const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", { "Unlink a thread's PRs whose last known state is in `states`. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }), states: Schema.Array(PullRequestState).check(Schema.isMinLength(1)), }), success: UnlinkPullRequestsResult, @@ -310,7 +304,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { "List a thread's PRs, with last known state and stack order (bottom to top). Not an end-of-work check.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + threadId: Schema.optional(ThreadId).annotate({ description: "Thread. Default: this thread." }), states: Schema.optional(Schema.Array(PullRequestState)), cursor: Schema.optional(Schema.String), }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 65f2b6b6c451e..79d477e25f6c0 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48094 + "mcpToolListJsonBytes": 51027 } diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index b70386d67c0a8..c91ecd26d579f 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -28,7 +28,7 @@ Cases are JSON lines in `cases/`, one file per area. The case schema in `run.ts` - `experience`: the real mistake, in a sentence. `source`: where it happened. - `setup`: the fake thread, its `world` (repo, branch, files, PRs, pages), `priorTurns`, and `mocks` for tool results. With `world.backgroundTasks`, `t3_background_wait` answers from T3's own handler and ThreadManagementService, so its result and errors are the checkout's; a prior wait with `"result": "default"` gets that result too. - `setup.priorTurns[].toolCalls[].resultFile`: load a repository-relative file as the tool result instead of copying its contents into the case. An empty `prompt` resumes a top-level thread after its last prior tool result without adding a user message. Side-chat and subagent setups require a nonempty prompt. -- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `shellMust`, `textMust`, `finalTextMust` (the last text, once the turn ended), and `linked`. A call's `args` match partially: `~text` is a case-insensitive substring, `"<=N"` / `">=N"` bound a number, `null` matches a missing key, and keys may be dotted paths. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Secrets cases use `secret` (see [Secrets cases](#secrets-cases)). +- `expect`: the checks, such as `calls`, `callsMustNot`, `maxCalls`, `order`, `shellMust`, `textMust`, `finalTextMust` (the last text, once the turn ended), and `linked`. A call's `args` match partially: `~text` is a case-insensitive substring, `/pattern/` is a case-sensitive regular expression, `"<=N"` / `">=N"` bound a number, `null` matches a missing key, and keys may be dotted paths. Device cases also use `device` to check every child brief, forbid raw UI commands, bound failed device actions per user turn, and require artifact paths in completed final answers. Secrets cases use `secret` (see [Secrets cases](#secrets-cases)). Check that the case builds with `--dry-run --case `. Then run it on the text before your fix, where it must fail, and on the text after, where it must pass. diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 2e14e45f7f250..f01b612acb3a4 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -37,3 +37,5 @@ {"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}} {"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} {"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."} +{"id": "schedule-webhook-prompt-payload-field", "area": "pull-requests", "kind": "bad", "pattern": "webhook prompt without the payload placeholder syntax", "experience": "schedule_task's input schema lost its webhook prompt variables, so a Codex agent asked to quote them from its tool list found none.", "rule": "Webhook prompts can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-3", "title": "Review pushed commits", "schedule": {"type": "webhook"}, "enabled": true, "bindToCurrentThread": true, "webhookUrl": "https://hooks.example.test/t3/def"}}, "world": {"repo": "acme/app", "files": {"README.md": "# acme/app\nA small web app.\n", "src/index.ts": "export const start = () => {};\n"}, "facts": "The checkout holds only README.md and src/index.ts. No other file exists, including any T3 Code source or docs."}}, "prompt": "Set up a webhook task for acme/app: each push GitHub sends should start a run in this thread that reviews the commits on the pushed branch. Put the pushed branch, the `ref` field of GitHub's push payload, into the task's prompt so each run gets it filled in. No signature for now. End your reply with one line: `Placeholders: `", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "webhook", "prompt": "/\\{\\{\\s*body\\.ref\\s*\\}\\}/"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "finalTextMust": ["(?:^|\\n)Placeholders: (?=[^\\n]*\\{\\{\\s*body\\.ref\\s*\\}\\})`?\\{\\{\\s*(?:(?:body|headers|query)(?:\\.[\\w-]+)*|request)\\s*\\}\\}`?(?:, `?\\{\\{\\s*(?:(?:body|headers|query)(?:\\.[\\w-]+)*|request)\\s*\\}\\}`?)*\\.?\\s*$"], "maxCalls": {"schedule_task": 1}}, "source": {"pr": "https://github.com/pingdotgg/t3code/pull/23285", "run": "Real Codex thread on a main test server, 2026-11-11: asked to quote the webhook prompt variables from its tool list, it answered (no description)."}, "notes": "Graded on one saved prompt, matched case-sensitively as T3 renders it, and one fixed-shape last line, not prose: a list of valid placeholders that includes {{body.ref}}. One save only, so a later save cannot drop the placeholder after the first one passed; a correct second save is a false fail, which is accepted. The world pins the checkout's files so the simulated shell cannot supply T3's template docs. schedule-fixed-time-no-placeholder is the control."} +{"id": "schedule-fixed-time-no-placeholder", "area": "pull-requests", "kind": "control", "pattern": "ordinary schedule with no request to fill in", "experience": "Control for schedule-webhook-prompt-payload-field: a fixed-time task has no request, so its prompt needs no placeholder.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Failing PR checks", "enabled": true, "bindToCurrentThread": true, "schedule": {"type": "fixed_time", "timeOfDay": "08:30", "weekdays": [1, 2, 3, 4, 5]}, "nextRunAt": "2026-11-03T08:30:00-08:00", "lastRunStatus": "never"}}, "world": {"repo": "acme/app", "files": {"README.md": "# acme/app\nA small web app.\n", "src/index.ts": "export const start = () => {};\n"}, "facts": "The checkout holds only README.md and src/index.ts. No other file exists, including any T3 Code source or docs."}}, "prompt": "Every weekday at 08:30, start a run in this thread that lists acme/app's open PRs with failing checks. End your reply with one line: `Placeholders: `", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "schedule.timeOfDay": "08:30"}}], "callsMustNot": [{"tool": "schedule_task", "args": {"prompt": "~{{"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "finalTextMust": ["(?:^|\\n)Placeholders: `?[Nn]one`?\\.?\\s*$"]}, "source": {"pr": "https://github.com/pingdotgg/t3code/pull/23285"}, "notes": "Catches a description that makes agents put placeholders into prompts that have no request."} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index ec1a121fee485..dead5b74c19ab 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -87,6 +87,15 @@ it("grades Codex shell and subagent calls with the shared rules", () => { input: { ...args, label: args.label.slice(1) }, })); const run = (calls: SampleRun["calls"]) => ({ calls, text: "", stopReason: "end_turn" }); + // A `/regex/` arg is case-sensitive, unlike `~text`: webhook placeholders are. + const placeholder = { + calls: [{ tool: "schedule_task", args: { prompt: "/\\{\\{body\\.ref\\}\\}/" } }], + }; + const saved = (prompt: string) => run([{ step: 0, tool: "schedule_task", input: { prompt } }]); + assert.deepEqual(failed(placeholder, saved("Review {{body.ref}}.")), []); + assert.deepEqual(failed(placeholder, saved("Review {{BODY.REF}}.")), [ + `calls schedule_task ${JSON.stringify(placeholder.calls[0]!.args)}`, + ]); assert.deepEqual(failed({ distinctCalls }, run(cards)), []); assert.deepEqual( failed( diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 764b39b1e4b96..2a93e9bf93fc6 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -1738,6 +1738,9 @@ const matches = (expected: unknown, actual: unknown): boolean => { if (typeof expected === "string" && expected.startsWith("~")) { return valueText(actual).toLowerCase().includes(expected.slice(1).toLowerCase()); } + if (typeof expected === "string" && expected.length > 2 && /^\/.*\/$/s.test(expected)) { + return new RegExp(expected.slice(1, -1)).test(valueText(actual)); + } if (Array.isArray(expected)) { return ( Array.isArray(actual) && expected.every((item) => actual.some((at) => matches(item, at))) diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index ad52b213f10fe..04c7c1afde5ed 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -639,19 +639,17 @@ export const DeviceToolListResult = Schema.Struct({ export type DeviceToolListResult = typeof DeviceToolListResult.Type; export const DeviceToolOpenInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device id from `device_list`. Default: the booted or last used device.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device id from `device_list`. Default: the booted or last used device.", + }), platform: Schema.optional( DevicePlatform.annotate({ description: "Needed with no `deviceId` when both platforms exist.", }), ), - hostId: Schema.optional( - DeviceHostId.annotate({ description: "Host from `device_list`. Default local." }), - ), + hostId: Schema.optional(DeviceHostId).annotate({ + description: "Host from `device_list`. Default local.", + }), }); export type DeviceToolOpenInput = typeof DeviceToolOpenInput.Type; @@ -667,11 +665,9 @@ export const DeviceToolOpenResult = Schema.Struct({ export type DeviceToolOpenResult = typeof DeviceToolOpenResult.Type; export const DeviceToolTargetInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device. Default: the last one opened here.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device. Default: the last one opened here.", + }), hostId: Schema.optional(DeviceHostId), }); export type DeviceToolTargetInput = typeof DeviceToolTargetInput.Type; @@ -688,11 +684,9 @@ export const DeviceToolScreenshotResult = Schema.Struct({ export type DeviceToolScreenshotResult = typeof DeviceToolScreenshotResult.Type; export const DeviceToolCloseInput = Schema.Struct({ - deviceId: Schema.optional( - DeviceId.annotate({ - description: "Device to close. None: close all here.", - }), - ), + deviceId: Schema.optional(DeviceId).annotate({ + description: "Device to close. None: close all here.", + }), hostId: Schema.optional(DeviceHostId), shutdown: Schema.optional( Schema.Boolean.annotate({ diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 99081420d91cc..e926a1990e118 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -54,7 +54,17 @@ export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(Schema : "clientRequestId must contain valid paired Unicode surrogates.", ), ) - .annotate({ description: "Reuse it to retry the same call. A new call gets a new key." }); + .pipe( + Schema.annotateEncoded({ + description: "Reuse it to retry the same call. A new call gets a new key.", + }), + ); + +const OptionalOrchestratorMcpClientRequestId = Schema.optional( + OrchestratorMcpClientRequestId, +).annotate({ + description: "Reuse it to retry the same call. A new call gets a new key.", +}); // OpenCode 1.15 serializes nested MCP union objects as JSON strings; decode that form at the boundary. const OrchestratorMcpScheduleFromJsonString = Schema.fromJsonString( @@ -68,7 +78,7 @@ const OrchestratorMcpSchedule = Schema.Union([ OrchestratorMcpScheduleFromJsonString, ]).annotate({ description: - "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, {type:'once',runAt}, or {type:'webhook'}.", + "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, {type:'once',runAt}, or {type:'webhook'}. Webhook prompts can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}.", }); const OrchestratorMcpTargetOptionsFromRecord = Schema.Record( @@ -95,16 +105,12 @@ export const OrchestratorMcpTargetOptions = Schema.Union([ export type OrchestratorMcpTargetOptions = typeof OrchestratorMcpTargetOptions.Type; export const OrchestratorMcpTarget = Schema.Struct({ - providerInstanceId: Schema.optional( - ProviderInstanceId.annotate({ - description: "Provider instance id from `orchestrator_capabilities`.", - }), - ), - driverKind: Schema.optional( - ProviderDriverKind.annotate({ - description: "Prefer `providerInstanceId`.", - }), - ), + providerInstanceId: Schema.optional(ProviderInstanceId).annotate({ + description: "Provider instance id from `orchestrator_capabilities`.", + }), + driverKind: Schema.optional(ProviderDriverKind).annotate({ + description: "Prefer `providerInstanceId`.", + }), model: Schema.optional(TrimmedNonEmptyString), options: Schema.optional( OrchestratorMcpTargetOptions.annotate({ @@ -186,7 +192,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ timeoutMs: Schema.optional(Schema.Finite).annotate({ description: "Mode 'wait' only. Default 4 min. A timeout does not cancel the child.", }), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, runtimeMode: Schema.optional( OrchestratorMcpRuntimeMode.annotate({ description: @@ -256,7 +262,7 @@ export type OrchestratorMcpTaskStatusInput = typeof OrchestratorMcpTaskStatusInp export const OrchestratorMcpTaskCancelInput = Schema.Struct({ taskId: NodeId, reason: Schema.optional(Schema.String.check(Schema.isMaxLength(2_000))), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpTaskCancelInput = typeof OrchestratorMcpTaskCancelInput.Type; @@ -280,7 +286,7 @@ export const OrchestratorMcpCreateThreadsInput = Schema.Struct({ Schema.isMinLength(1), Schema.isMaxLength(20), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpCreateThreadsInput = typeof OrchestratorMcpCreateThreadsInput.Type; @@ -317,11 +323,9 @@ export const OrchestratorMcpThreadStatus = Schema.Union([ ]); export type OrchestratorMcpThreadStatus = typeof OrchestratorMcpThreadStatus.Type; -const OrchestratorMcpProjectTarget = Schema.optional( - ProjectId.annotate({ - description: "Project to use. Default: your thread's project. Required outside a thread.", - }), -); +const OrchestratorMcpProjectTarget = Schema.optional(ProjectId).annotate({ + description: "Project to use. Default: your thread's project. Required outside a thread.", +}); /** * A linked environment to act in instead of this one. The tools that take it @@ -545,7 +549,7 @@ export const OrchestratorMcpThreadSendInput = Schema.Struct({ "Start a new provider session without the thread's earlier context, like /clear. Never steers.", }), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpThreadSendInput = typeof OrchestratorMcpThreadSendInput.Type; @@ -596,7 +600,7 @@ export const OrchestratorMcpThreadInterruptInput = Schema.Struct({ * watches and stop every task it delegated. `runId` is then not used. */ stop: Schema.optional(Schema.Boolean), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpThreadInterruptInput = typeof OrchestratorMcpThreadInterruptInput.Type; @@ -613,11 +617,9 @@ export type OrchestratorMcpThreadInterruptResult = typeof OrchestratorMcpThreadI export const OrchestratorMcpCapabilitiesInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - search: Schema.optional( - TrimmedNonEmptyString.check(Schema.isMaxLength(200)).annotate({ - description: "Match model ids and labels. Replaces the short list.", - }), - ), + search: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(200))).annotate({ + description: "Match model ids and labels. Replaces the short list.", + }), }); export type OrchestratorMcpCapabilitiesInput = typeof OrchestratorMcpCapabilitiesInput.Type; @@ -710,7 +712,7 @@ export const OrchestratorMcpScheduleTaskInput = Schema.Struct({ "Runs share one thread but each starts a fresh session. Unbound: the first run makes the thread.", }), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpScheduleTaskInput = typeof OrchestratorMcpScheduleTaskInput.Type; @@ -751,11 +753,9 @@ export type OrchestratorMcpScheduleTaskResult = typeof OrchestratorMcpScheduleTa export const OrchestratorMcpListScheduledTasksInput = Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, - projectId: Schema.optional( - ProjectId.annotate({ - description: "Default: your thread's project, or all outside a thread.", - }), - ), + projectId: Schema.optional(ProjectId).annotate({ + description: "Default: your thread's project, or all outside a thread.", + }), }); export type OrchestratorMcpListScheduledTasksInput = typeof OrchestratorMcpListScheduledTasksInput.Type; @@ -785,12 +785,16 @@ const PastedSecretValue = Schema.String.check( ); export const OrchestratorMcpRequestSecretInput = Schema.Struct({ - label: TrimmedNonEmptyString.annotate({ - description: "Card title, such as 'GitHub webhook secret'.", - }), - reason: TrimmedNonEmptyString.annotate({ - description: "What it is for and where to get it. A link goes in `url`.", - }), + label: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "Card title, such as 'GitHub webhook secret'.", + }), + ), + reason: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "What it is for and where to get it. A link goes in `url`.", + }), + ), url: Schema.optional( TrimmedNonEmptyString.check( Schema.makeFilter((value) => secretRequestPageUrl(value) !== null, { @@ -811,7 +815,7 @@ export const OrchestratorMcpRequestSecretInput = Schema.Struct({ timeoutMs: Schema.optional( Schema.Int.check(Schema.isBetween({ minimum: 1_000, maximum: 60 * 60 * 1_000 })), ), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId).annotate({ + clientRequestId: OptionalOrchestratorMcpClientRequestId.annotate({ description: "Retry key.", }), }); @@ -859,7 +863,7 @@ export const OrchestratorMcpEnvironmentLinkInput = Schema.Struct({ requestedAccess: Schema.optional(AuthMcpClientAccess).annotate({ description: "Suggested access. The user picks; default approval-required.", }), - clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), + clientRequestId: OptionalOrchestratorMcpClientRequestId, }); export type OrchestratorMcpEnvironmentLinkInput = typeof OrchestratorMcpEnvironmentLinkInput.Type; diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 4297ad94b73e9..104a73d366cf6 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -20,7 +20,7 @@ import { ProviderInteractionMode, RuntimeMode } from "./providerPolicy.ts"; const TimeOfDay = TrimmedNonEmptyString.check( Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/), -).annotate({ description: "Local time as HH:MM, such as 09:30." }); +).pipe(Schema.annotateEncoded({ description: "Local time as HH:MM, such as 09:30." })); export const MIN_SCHEDULED_TASK_INTERVAL_MS = 60_000; @@ -74,9 +74,11 @@ const ScheduledTaskOnceSchedule = Schema.Struct({ }); const ScheduledTaskWebhookSignatureFields = { - header: TrimmedNonEmptyString.annotate({ - description: "Header with the signature, such as x-hub-signature-256.", - }), + header: TrimmedNonEmptyString.pipe( + Schema.annotateEncoded({ + description: "Header with the signature, such as x-hub-signature-256.", + }), + ), encoding: Schema.Literals(["hex", "base64"]), prefix: Schema.String.annotate({ description: "Text before the digest, such as 'sha256='. Empty for none.", @@ -185,9 +187,6 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ maxDeliveryAgeMinutes: Schema.optional(Schema.NullOr(WebhookMaxDeliveryAgeMinutes)), dedupe: Schema.optional(ScheduledTaskWebhookDedupe), filters: Schema.optional(ScheduledTaskWebhookFilters), -}).annotate({ - description: - "Run per webhook request. Prompt can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}.", }); /** Accept legacy sub-minute schedules so users can still manage them. */ diff --git a/packages/contracts/src/threadMetadataMcp.ts b/packages/contracts/src/threadMetadataMcp.ts index e5332ac56ab47..f7903c796b4c1 100644 --- a/packages/contracts/src/threadMetadataMcp.ts +++ b/packages/contracts/src/threadMetadataMcp.ts @@ -26,19 +26,23 @@ export function hasOnlyPairedSurrogates(value: string): boolean { return true; } -const ThreadMetadataTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({ +const ThreadMetadataTitle = Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(512)), +).annotate({ description: "New concise display title. Required only when action is rename.", }); -const ThreadMetadataClientRequestId = TrimmedNonEmptyString.check(Schema.isMaxLength(256)) - .check( +const ThreadMetadataClientRequestId = Schema.optional( + TrimmedNonEmptyString.check(Schema.isMaxLength(256)).check( Schema.makeFilter((value) => hasOnlyPairedSurrogates(value) ? true : "clientRequestId must contain valid paired Unicode surrogates.", ), - ) - .annotate({ description: "Stable idempotency key to reuse when retrying this mutation." }); + ), +).annotate({ + description: "Stable idempotency key to reuse when retrying this mutation.", +}); export const ThreadMetadataMcpAction = Schema.Literals(["rename", "regenerate_title"]); export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; @@ -46,8 +50,8 @@ export type ThreadMetadataMcpAction = typeof ThreadMetadataMcpAction.Type; export const ThreadMetadataMcpUpdateInput = Schema.Struct({ threadId: Schema.optional(ThreadId), action: ThreadMetadataMcpAction, - title: Schema.optional(ThreadMetadataTitle), - clientRequestId: Schema.optional(ThreadMetadataClientRequestId), + title: ThreadMetadataTitle, + clientRequestId: ThreadMetadataClientRequestId, }).check( Schema.makeFilter((input) => (input.action === "rename") === (input.title !== undefined) diff --git a/packages/contracts/src/worktreeMcp.ts b/packages/contracts/src/worktreeMcp.ts index 87f45dea27598..8cee45b43a8c6 100644 --- a/packages/contracts/src/worktreeMcp.ts +++ b/packages/contracts/src/worktreeMcp.ts @@ -5,11 +5,9 @@ import { TrimmedNonEmptyString } from "./baseSchemas.ts"; /** Uses the managed worktree folder; handoff detaches the provider, ending this turn and resuming there next run. */ export const WorktreeMcpHandoffInput = Schema.Struct({ branch: TrimmedNonEmptyString, - baseRef: Schema.optional( - TrimmedNonEmptyString.annotate({ - description: "Default: checked-out branch.", - }), - ), + baseRef: Schema.optional(TrimmedNonEmptyString).annotate({ + description: "Default: checked-out branch.", + }), startFromOrigin: Schema.optional( Schema.Boolean.annotate({ description: @@ -22,10 +20,10 @@ export const WorktreeMcpHandoffInput = Schema.Struct({ }), ), continuationPrompt: Schema.optional( - TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)).annotate({ - description: "Work to resume in the worktree next turn. Omitted: thread waits.", - }), - ), + TrimmedNonEmptyString.check(Schema.isMaxLength(120_000)), + ).annotate({ + description: "Work to resume in the worktree next turn. Omitted: thread waits.", + }), }); export type WorktreeMcpHandoffInput = typeof WorktreeMcpHandoffInput.Type;