From 6f41eddb202a55563dadcf3efdf2c2bb19735d2d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 3 Nov 2026 21:55:20 -0600 Subject: [PATCH] fix(harness): keep tool input schemas free of top-level oneOf (#1839) --- packages/harness/src/tools.mjs | 4 ++-- packages/harness/tests/tools.test.mjs | 23 +++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs index ab22e413f..1e4262df8 100644 --- a/packages/harness/src/tools.mjs +++ b/packages/harness/src/tools.mjs @@ -105,7 +105,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project // Row 140 (#1634, REQ-COS-1): the pm asks the chief of staff, the only launcher, for sessions. The broker // addresses the request to the launcher and refuses any sender that isn't an instance of the pm role. if (resolved.definition === "pm") - add("request_staff", "staff.request", "message.send", "Ask the chief of staff to launch role sessions for a task or a pre-task request: pass exactly one of task_ref or request, and name each role and how many. The chief of staff launches what the organization's limits allow and answers with the instances it launched, or with a refusal and its reason. You don't launch sessions yourself.", { ...obj({ + add("request_staff", "staff.request", "message.send", "Ask the chief of staff to launch role sessions for a task or a pre-task request: pass exactly one of task_ref or request, and name each role and how many. The chief of staff launches what the organization's limits allow and answers with the instances it launched, or with a refusal and its reason. You don't launch sessions yourself.", obj({ task_ref: TASK_REF, request: REQUEST, roles: { type: "array", minItems: 1, maxItems: 10, description: "The roles wanted and how many of each", items: obj({ @@ -114,7 +114,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project }, ["role", "count"]) }, note: str("What the sessions are for, for the chief of staff"), decision: COMMON.decision, - }, ["roles"]), oneOf: [{ required: ["task_ref"] }, { required: ["request"] }] }); + }, ["roles"])); } // Intake precedes tasks, tracker availability and project checkouts. Tool presence does not // widen the broker's request ownership checks (currently PM/owner, including for reads). diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs index dd2964804..48a48df6b 100644 --- a/packages/harness/tests/tools.test.mjs +++ b/packages/harness/tests/tools.test.mjs @@ -1,10 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs"; import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs"; import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs"; import { ID_PATTERN } from "../../business/src/vocabulary.mjs"; -import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs"; +import { fakeToolSocket, REPO, resolvedFor, scratch } from "./helpers.mjs"; const names = (tools) => tools.map((t) => t.name); @@ -88,7 +90,6 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an assert.deepEqual([ask.verb, ask.action], ["staff.request", "message.send"]); assert.deepEqual(Object.keys(ask.parameters.properties), ["task_ref", "request", "roles", "note", "decision"]); assert.deepEqual(ask.parameters.required, ["roles"]); - assert.deepEqual(ask.parameters.oneOf, [{ required: ["task_ref"] }, { required: ["request"] }]); assert.equal(ask.parameters.additionalProperties, false); const roles = ask.parameters.properties.roles; assert.deepEqual([roles.minItems, roles.maxItems], [1, 10]); @@ -110,6 +111,24 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`)); }); +test("every managed role's typed tools avoid top-level schema composition rejected by model APIs", (t) => { + const chief = resolvedFor(t, "chief"); + for (const file of readdirSync(join(REPO, "roles")).filter((name) => name.endsWith(".json"))) { + const role = JSON.parse(readFileSync(join(REPO, "roles", file), "utf8")); + // Version 1 worker roles and the conductor policy do not use managed harness tools. + if (role.roleVersion !== 2) continue; + const resolved = { + definition: role.name, + limits: { tools: role.tools, authority: role.authority }, + launch: role.name === "chief-of-staff" ? chief.launch : null, + }; + const tools = typedTools({ resolved, arbiter: true, tracker: true, projects: ["stack"], land: true, library: true }); + for (const tool of tools) + for (const keyword of ["oneOf", "anyOf", "allOf"]) + assert.equal(Object.hasOwn(tool.parameters, keyword), false, `${role.name}.${tool.name}: top-level ${keyword}`); + } +}); + // Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added. test("end_run sends role and reason, no decision; change_organization sends revision and the organization keys", (t) => { const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });