fix(harness): keep tool input schemas free of top-level oneOf (#1839)

This commit is contained in:
jason.woltje committed 2026-11-03 21:55:20 -06:00
1 parent 1de685ccad
commit 6f41eddb20
2 files changed
+23 -4

No files matched your search

+2 -2
View File
@@ -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).
+21 -2
View File
@@ -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 });