From 2c7afaac41841a0df9a9d248a3b4b1e1733e7b81 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 19:50:41 +0200 Subject: [PATCH 1/5] feat(contracts): Prism role kits and provider snapshot schema (#19) Role kits (instructions, runtime mode, skills, thread-tool scope) and ordered model preferences for the six Prism roles, stored as the project-scoped server setting prismRoles, with a per-role patch. Adds the GET /api/prism/snapshot response schema for the router. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/fork.md | 4 + packages/contracts/src/index.ts | 2 + packages/contracts/src/prism.test.ts | 45 ++++++++++ packages/contracts/src/prism.ts | 108 ++++++++++++++++++++++++ packages/contracts/src/prismSnapshot.ts | 37 ++++++++ packages/contracts/src/settings.ts | 6 ++ scripts/fork-upstream-edits.txt | 4 + 7 files changed, 206 insertions(+) create mode 100644 packages/contracts/src/prism.test.ts create mode 100644 packages/contracts/src/prism.ts create mode 100644 packages/contracts/src/prismSnapshot.ts diff --git a/docs/fork.md b/docs/fork.md index 21875ab30a..20ea2e9780 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -79,6 +79,10 @@ any modified upstream file missing from `scripts/fork-upstream-edits.txt`. `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and sibling menu for a child thread. The logic lives in the fork-owned `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`. +- Prism role kits (#19): `packages/contracts/src/settings.ts` adds the + `prismRoles` server setting (project-scoped, with its patch) and + `packages/contracts/src/index.ts` exports the fork-owned `prism.ts` and + `prismSnapshot.ts`. - `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`. - `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e6..58ad03acad 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -22,6 +22,8 @@ export * from "./model.ts"; export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; +export * from "./prism.ts"; +export * from "./prismSnapshot.ts"; export * from "./git.ts"; export * from "./vcs.ts"; export * from "./sourceControl.ts"; diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts new file mode 100644 index 0000000000..9e840479a2 --- /dev/null +++ b/packages/contracts/src/prism.test.ts @@ -0,0 +1,45 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { DEFAULT_PRISM_ROLE_KITS, PrismRoleKitsPatch } from "./prism.ts"; +import { ProjectSettingsOverrides, ServerSettings } from "./settings.ts"; + +describe("Prism role kits", () => { + it("default each role to its thread-tool scope with no preferred models", () => { + expect(DEFAULT_PRISM_ROLE_KITS.planner.threadTools).toBe("planner"); + expect(DEFAULT_PRISM_ROLE_KITS.dispatcher.threadTools).toBe("children"); + expect(DEFAULT_PRISM_ROLE_KITS.reviewer.threadTools).toBe("project-read"); + for (const role of ["worker", "correction", "recovery"] as const) { + expect(DEFAULT_PRISM_ROLE_KITS[role].threadTools).toBe("none"); + } + expect(DEFAULT_PRISM_ROLE_KITS.worker.models).toEqual([]); + }); + + it("fill missing roles and fields when a settings file names one role", () => { + const settings = Schema.decodeSync(ServerSettings)({ + prismRoles: { + worker: { models: [{ instanceId: "opencode", model: "opencode/muse", effort: "high" }] }, + }, + }); + expect(settings.prismRoles.worker.models).toEqual([ + { instanceId: "opencode", model: "opencode/muse", effort: "high" }, + ]); + expect(settings.prismRoles.worker.threadTools).toBe("none"); + expect(settings.prismRoles.reviewer.threadTools).toBe("project-read"); + }); + + it("accept a project override of the whole kit set", () => { + const overrides = Schema.decodeSync(ProjectSettingsOverrides)({ + prismRoles: { reviewer: { models: [{ instanceId: "codex", model: "gpt-5.6-luna" }] } }, + }); + expect(overrides.prismRoles?.reviewer.models[0]?.model).toBe("gpt-5.6-luna"); + expect(overrides.prismRoles?.planner.threadTools).toBe("planner"); + }); + + it("patch one field of one role without defaults for the rest", () => { + const patch = Schema.decodeSync(PrismRoleKitsPatch)({ + dispatcher: { models: [] }, + }); + expect(patch).toEqual({ dispatcher: { models: [] } }); + }); +}); diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts new file mode 100644 index 0000000000..7b4fc738e1 --- /dev/null +++ b/packages/contracts/src/prism.ts @@ -0,0 +1,108 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { RuntimeMode } from "./orchestration.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** + * Prism (Model Router) roles and their kits (toolboxmd/model-router#115). + * + * A role is a kit (instructions, permissions, skills, thread-tool scope) + * plus an ordered list of preferred models. The kits live in server + * settings under `prismRoles`, overridable per project like any key in + * `PROJECT_SCOPED_SERVER_SETTING_KEYS`. The router reads them from the + * provider snapshot endpoint; `spawn_thread(role)` applies them directly. + */ +export const PRISM_ROLES = [ + "planner", + "dispatcher", + "reviewer", + "worker", + "correction", + "recovery", +] as const; +export const PrismRole = Schema.Literals(PRISM_ROLES); +export type PrismRole = typeof PrismRole.Type; + +/** + * Which `threads` MCP tools a role's thread may use. + * - `planner`: every thread tool with `project` scope, plus the Prism tools. + * - `children`: read, list and message its own children only. + * - `project-read`: read and list, with `project` scope; no spawn, no message. + * - `none`: no thread tools. + */ +export const PrismThreadToolScope = Schema.Literals([ + "planner", + "children", + "project-read", + "none", +]); +export type PrismThreadToolScope = typeof PrismThreadToolScope.Type; + +export const PRISM_DEFAULT_THREAD_TOOL_SCOPES: Record = { + planner: "planner", + dispatcher: "children", + reviewer: "project-read", + worker: "none", + correction: "none", + recovery: "none", +}; + +/** One preferred model for a role; the first eligible entry wins. */ +export const PrismModelPreference = Schema.Struct({ + instanceId: ProviderInstanceId, + model: TrimmedNonEmptyString, + /** Claude effort, Codex/Grok reasoningEffort, OpenCode variant. */ + effort: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type PrismModelPreference = typeof PrismModelPreference.Type; + +const prismRoleKit = (role: PrismRole) => + Schema.Struct({ + /** Prepended to the first message of every thread started in this role. */ + instructions: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** Permissions; absent means the spawning thread's runtime mode. */ + runtimeMode: Schema.optionalKey(RuntimeMode), + /** Skill names the role is told to use. */ + skills: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + threadTools: PrismThreadToolScope.pipe( + Schema.withDecodingDefault(Effect.succeed(PRISM_DEFAULT_THREAD_TOOL_SCOPES[role])), + ), + /** Ordered preferences. Eligible = these ∩ models enabled in Providers. */ + models: Schema.Array(PrismModelPreference).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); + +export const PrismRoleKits = Schema.Struct({ + planner: prismRoleKit("planner"), + dispatcher: prismRoleKit("dispatcher"), + reviewer: prismRoleKit("reviewer"), + worker: prismRoleKit("worker"), + correction: prismRoleKit("correction"), + recovery: prismRoleKit("recovery"), +}); +export type PrismRoleKits = typeof PrismRoleKits.Type; +export type PrismRoleKit = PrismRoleKits[PrismRole]; + +export const DEFAULT_PRISM_ROLE_KITS: PrismRoleKits = Schema.decodeSync(PrismRoleKits)({}); + +const PrismRoleKitPatch = Schema.Struct({ + instructions: Schema.optionalKey(TrimmedString), + runtimeMode: Schema.optionalKey(RuntimeMode), + skills: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), + threadTools: Schema.optionalKey(PrismThreadToolScope), + models: Schema.optionalKey(Schema.Array(PrismModelPreference)), +}); + +/** Per-role, per-field update; arrays (skills, models) replace whole. */ +export const PrismRoleKitsPatch = Schema.Struct({ + planner: Schema.optionalKey(PrismRoleKitPatch), + dispatcher: Schema.optionalKey(PrismRoleKitPatch), + reviewer: Schema.optionalKey(PrismRoleKitPatch), + worker: Schema.optionalKey(PrismRoleKitPatch), + correction: Schema.optionalKey(PrismRoleKitPatch), + recovery: Schema.optionalKey(PrismRoleKitPatch), +}); +export type PrismRoleKitsPatch = typeof PrismRoleKitsPatch.Type; diff --git a/packages/contracts/src/prismSnapshot.ts b/packages/contracts/src/prismSnapshot.ts new file mode 100644 index 0000000000..1bfae40d68 --- /dev/null +++ b/packages/contracts/src/prismSnapshot.ts @@ -0,0 +1,37 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PrismRoleKits } from "./prism.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { ServerProviderUsageLimits } from "./providerUsageLimits.ts"; +import { ServerProviderModel, ServerProviderState } from "./server.ts"; + +/** + * What `GET /api/prism/snapshot` returns to the Prism router: every provider + * instance with its models and usage windows, plus the role kits resolved for + * the requested project. Kept apart from `prism.ts` because `settings.ts` + * imports the kits and `server.ts` imports `settings.ts`. + */ + +/** One provider instance as the router needs it: models, options, usage windows. */ +export const PrismProviderSnapshotEntry = Schema.Struct({ + instanceId: ProviderInstanceId, + driver: ProviderDriverKind, + displayName: Schema.optional(TrimmedNonEmptyString), + enabled: Schema.Boolean, + status: ServerProviderState, + /** Models this instance offers; `capabilities` carries the option descriptors. */ + models: Schema.Array(ServerProviderModel), + /** Usage windows with `usedPercent` and `resetsAt`; absent when the driver reports none. */ + usageLimits: Schema.optional(ServerProviderUsageLimits), +}); +export type PrismProviderSnapshotEntry = typeof PrismProviderSnapshotEntry.Type; + +export const PrismProviderSnapshot = Schema.Struct({ + generatedAt: IsoDateTime, + /** The project the role kits were resolved for; null for environment values. */ + projectId: Schema.NullOr(ProjectId), + providers: Schema.Array(PrismProviderSnapshotEntry), + roles: PrismRoleKits, +}); +export type PrismProviderSnapshot = typeof PrismProviderSnapshot.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3e30120191..fbfd44cdc7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -41,6 +41,7 @@ import { type ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; +import { PrismRoleKits, PrismRoleKitsPatch } from "./prism.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -1017,6 +1018,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", "responseStreamingMode", + "prismRoles", ] as const; export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTING_KEYS)[number]; @@ -1044,6 +1046,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), + prismRoles: Schema.optionalKey(PrismRoleKits), } satisfies Record); export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; @@ -1283,6 +1286,8 @@ export const ServerSettings = Schema.Struct({ usagePriceOverrides: Schema.Record(TrimmedNonEmptyString, UsageModelPriceOverride).pipe( Schema.withDecodingDefault(Effect.succeed({})), ), + /** Prism (Model Router) role kits and model preferences; see prism.ts. */ + prismRoles: PrismRoleKits.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -1556,6 +1561,7 @@ export const ServerSettingsPatch = Schema.Struct({ usagePriceOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, Schema.NullOr(UsageModelPriceOverride)), ), + prismRoles: Schema.optionalKey(PrismRoleKitsPatch), }); export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 389dec4772..3e8eef42c5 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -42,6 +42,10 @@ apps/web/src/components/Sidebar.tsx # Agents panel sections, child tree and parent breadcrumb (#17) apps/web/src/components/chat/ChatHeader.tsx +# Prism role kits in settings (#19) +packages/contracts/src/index.ts +packages/contracts/src/settings.ts + # CI on GitHub-hosted runners (#6) .github/workflows/ci.yml .github/workflows/mobile-fingerprint-check.yml From 378b70385f64cd3b0689b043dc61affe509a7727 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:02:54 +0200 Subject: [PATCH 2/5] feat(server): Prism MCP toolkit, spawn_thread(role) and provider snapshot endpoint (#19) Adds prism_submit, prism_status, prism_questions and prism_answer, which run the installed Model Router CLI with the calling thread as the T3 planner and a server URL plus an orchestration-scoped token supplied by the server. spawn_thread takes a Prism role, applies its kit and first eligible preferred model, and each role's thread-tool scope is enforced. GET /api/prism/snapshot returns providers, models, usage windows with resetsAt and the role kits resolved for a project. OpenCode usage now refreshes on the provider health interval. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.ts | 11 + .../src/mcp/toolkits/prism/handlers.test.ts | 76 ++++++ .../server/src/mcp/toolkits/prism/handlers.ts | 255 ++++++++++++++++++ apps/server/src/mcp/toolkits/prism/tools.ts | 121 +++++++++ .../src/mcp/toolkits/threads/handlers.ts | 98 +++++-- .../src/mcp/toolkits/threads/roles.test.ts | 176 ++++++++++++ apps/server/src/mcp/toolkits/threads/roles.ts | 130 +++++++++ apps/server/src/mcp/toolkits/threads/tools.ts | 16 +- apps/server/src/prism/snapshotRoute.test.ts | 82 ++++++ apps/server/src/prism/snapshotRoute.ts | 73 +++++ .../src/provider/Drivers/OpenCodeDriver.ts | 2 +- docs/fork.md | 12 +- packages/contracts/src/prism.test.ts | 10 +- scripts/fork-upstream-edits.txt | 1 + 14 files changed, 1030 insertions(+), 33 deletions(-) create mode 100644 apps/server/src/mcp/toolkits/prism/handlers.test.ts create mode 100644 apps/server/src/mcp/toolkits/prism/handlers.ts create mode 100644 apps/server/src/mcp/toolkits/prism/tools.ts create mode 100644 apps/server/src/mcp/toolkits/threads/roles.test.ts create mode 100644 apps/server/src/mcp/toolkits/threads/roles.ts create mode 100644 apps/server/src/prism/snapshotRoute.test.ts create mode 100644 apps/server/src/prism/snapshotRoute.ts diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ad2dd73713..e0ec1d1296 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -31,6 +31,10 @@ import { } from "./toolkits/preview/tools.ts"; import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { prismSnapshotRouteLayer } from "../prism/snapshotRoute.ts"; +import { PrismToolkitHandlersLive } from "./toolkits/prism/handlers.ts"; +import { PrismToolkit } from "./toolkits/prism/tools.ts"; import { ThreadsToolkitHandlersLive } from "./toolkits/threads/handlers.ts"; import { ThreadsToolkit } from "./toolkits/threads/tools.ts"; import { @@ -614,6 +618,11 @@ const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( Layer.provide(ThreadsToolkitHandlersLive), ); +const PrismToolkitRegistrationLive = McpServer.toolkit(PrismToolkit).pipe( + Layer.provide(PrismToolkitHandlersLive), + Layer.provide(ProcessRunner.layer), +); + const DeviceStandardToolkitRegistrationLive = McpServer.toolkit(DeviceStandardToolkit).pipe( Layer.provide(DeviceStandardToolkitHandlersLive), ); @@ -638,5 +647,7 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, PullRequestsToolkitRegistrationLive, ThreadsToolkitRegistrationLive, + PrismToolkitRegistrationLive, DeviceToolkitRegistrationLive, + prismSnapshotRouteLayer, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/toolkits/prism/handlers.test.ts b/apps/server/src/mcp/toolkits/prism/handlers.test.ts new file mode 100644 index 0000000000..46cad15866 --- /dev/null +++ b/apps/server/src/mcp/toolkits/prism/handlers.test.ts @@ -0,0 +1,76 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { findRouterBin, parseRouterOutput, routerStateDir, submitArgs } from "./handlers.ts"; + +it("submits with the calling thread as the T3 planner and starts the job", () => { + const args = submitArgs({ + requestId: "prism-1", + task: "Fix #80", + workspace: "/repo", + plannerThreadId: "thread-1", + serverUrl: "http://127.0.0.1:3999", + lane: "small", + }); + assert.deepStrictEqual(args, [ + "submit", + "--request-id", + "prism-1", + "--task", + "Fix #80", + "--workspace", + "/repo", + "--planner-session", + "thread-1", + "--planner-harness", + "t3", + "--planner-t3-thread", + "thread-1", + "--t3-server-url", + "http://127.0.0.1:3999", + "--lane", + "small", + "--start", + ]); +}); + +it("passes router JSON through and turns its errors into refusals", () => { + assert.deepStrictEqual(parseRouterOutput('{"status":"running"}', "", 0), { + ok: { status: "running" }, + }); + assert.deepStrictEqual(parseRouterOutput('{"error":"not found: x"}', "", 1), { + error: "Prism router refused: not found: x", + }); + assert.deepStrictEqual(parseRouterOutput("", "Traceback", 1), { + error: "Prism router exited 1: Traceback", + }); +}); + +it("defaults the router state directory like the router does", () => { + assert.strictEqual(routerStateDir({}, "/home/u"), "/home/u/.local/share/durable-runner"); + assert.strictEqual(routerStateDir({ DURABLE_RUNNER_STATE_DIR: "/tmp/s" }, "/home/u"), "/tmp/s"); +}); + +it.layer(NodeServices.layer)("router discovery", (it) => { + it.effect("picks the newest installed version that ships the CLI", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const root = `${home}/.codex/plugins/cache/toolboxmd/model-router`; + for (const version of ["0.9.0", "0.34.0", "0.35.0"]) { + yield* fs.makeDirectory(`${root}/${version}/bin`, { recursive: true }); + } + yield* fs.writeFileString(`${root}/0.9.0/bin/model-router`, ""); + yield* fs.writeFileString(`${root}/0.34.0/bin/model-router`, ""); + // 0.35.0 is a partial install without the CLI. + assert.strictEqual(yield* findRouterBin({}, home), `${root}/0.34.0/bin/model-router`); + assert.strictEqual( + yield* findRouterBin({ PRISM_ROUTER_BIN: "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/x/router" }, home), + "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/x/router", + ); + assert.strictEqual(yield* findRouterBin({}, `${home}/nobody`), null); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/mcp/toolkits/prism/handlers.ts b/apps/server/src/mcp/toolkits/prism/handlers.ts new file mode 100644 index 0000000000..9f093131c4 --- /dev/null +++ b/apps/server/src/mcp/toolkits/prism/handlers.ts @@ -0,0 +1,255 @@ +import * as NodeOS from "node:os"; + +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + ProjectId, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { HttpServer } from "effect/unstable/http"; +import * as NetAddress from "effect/unstable/net/NetAddress"; + +import * as EnvironmentAuth from "../../../auth/EnvironmentAuth.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProcessRunner from "../../../processRunner.ts"; +import { ServerSettingsService } from "../../../serverSettings.ts"; +import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { threadToolScopeOf } from "../threads/roles.ts"; +import { PrismToolError, PrismToolkit } from "./tools.ts"; + +/** Where the Model Router plugin installs its versions, newest wins. */ +const ROUTER_PLUGIN_DIR = [".codex", "plugins", "cache", "toolboxmd", "model-router"]; +const ROUTER_TIMEOUT = "120 seconds"; +/** Jobs run for hours; the token must outlive the job, not the tool call. */ +const ROUTER_TOKEN_TTL = Duration.days(30); + +const fail = (reason: string) => Effect.fail(new PrismToolError({ reason })); + +function compareVersions(left: string, right: string): number { + const a = left.split(".").map((part) => Number.parseInt(part, 10) || 0); + const b = right.split(".").map((part) => Number.parseInt(part, 10) || 0); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const diff = (a[index] ?? 0) - (b[index] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +/** + * The installed router CLI: `PRISM_ROUTER_BIN` when set, else the newest + * version under the Model Router plugin cache that ships `bin/model-router`. + */ +export const findRouterBin = Effect.fn("Prism.findRouterBin")(function* ( + env: NodeJS.ProcessEnv, + home: string, +) { + const explicit = env.PRISM_ROUTER_BIN?.trim(); + if (explicit) return explicit; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.join(home, ...ROUTER_PLUGIN_DIR); + const versions = yield* fs.readDirectory(root).pipe(Effect.orElseSucceed(() => [])); + for (const version of versions.toSorted(compareVersions).toReversed()) { + const bin = path.join(root, version, "bin", "model-router"); + if (yield* fs.exists(bin).pipe(Effect.orElseSucceed(() => false))) return bin; + } + return null; +}); + +/** The router's private state directory, as the router itself defaults it. */ +export function routerStateDir(env: NodeJS.ProcessEnv, home: string): string { + return env.DURABLE_RUNNER_STATE_DIR?.trim() || `${home}/.local/share/durable-runner`; +} + +export interface PrismSubmitArgs { + readonly requestId: string; + readonly task: string; + readonly workspace: string; + readonly plannerThreadId: string; + readonly serverUrl: string; + readonly lane?: string | undefined; + readonly handoffSummary?: string | undefined; +} + +/** `submit` argv: the calling thread is the planner, on the T3 execution path. */ +export function submitArgs(input: PrismSubmitArgs): string[] { + return [ + "submit", + "--request-id", + input.requestId, + "--task", + input.task, + "--workspace", + input.workspace, + "--planner-session", + input.plannerThreadId, + "--planner-harness", + "t3", + "--planner-t3-thread", + input.plannerThreadId, + "--t3-server-url", + input.serverUrl, + ...(input.lane ? ["--lane", input.lane] : []), + ...(input.handoffSummary ? ["--handoff-summary", input.handoffSummary] : []), + "--start", + ]; +} + +/** Router output is JSON on stdout, errors included (`{"error": ...}`). */ +export function parseRouterOutput( + stdout: string, + stderr: string, + code: number | null, +): { readonly ok: unknown } | { readonly error: string } { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + const detail = (stderr || stdout).trim().slice(0, 1_000); + return { error: `Prism router exited ${code ?? "without a code"}: ${detail || "no output"}` }; + } + if (code !== 0) { + const reason = + parsed !== null && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : stdout.trim().slice(0, 1_000); + return { error: `Prism router refused: ${reason}` }; + } + return { ok: parsed }; +} + +/** A wildcard bind is reachable on loopback, where the router runs. */ +function serverBaseUrl(address: HttpServer.HttpServer["Service"]["address"]): string { + if (!NetAddress.isInetAddress(address)) return "http://127.0.0.1:3773"; + const host = NetAddress.isUnspecified(address.address) + ? "127.0.0.1" + : NetAddress.formatUrlHostString(NetAddress.formatIp(address.address)); + return `http://${host}:${address.port}`; +} + +const make = Effect.gen(function* () { + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettingsService; + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const runner = yield* ProcessRunner.ProcessRunner; + const crypto = yield* Crypto.Crypto; + const httpServer = yield* HttpServer.HttpServer; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverUrl = serverBaseUrl(httpServer.address); + + /** The calling thread, once its role carries the planner's tools. */ + const plannerCaller = Effect.gen(function* () { + const invocation = yield* McpInvocationContext.McpInvocationContext; + const caller = yield* snapshots.getThreadShellById(invocation.threadId).pipe( + Effect.map(Option.getOrUndefined), + Effect.catchCause(() => Effect.void), + ); + if (!caller) return yield* fail(`Thread ${invocation.threadId} was not found.`); + const kits = yield* serverSettings.getSettings.pipe( + Effect.map((settings) => resolveProjectSettings(settings, caller.projectId).settings), + Effect.map((settings) => settings.prismRoles), + Effect.catchCause(() => fail("Could not read Prism role settings.")), + ); + if (threadToolScopeOf(caller.id, kits) !== "planner") { + return yield* fail("Only a planner thread may use the Prism tools."); + } + return caller; + }); + + const runRouter = (args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + Effect.gen(function* () { + const home = NodeOS.homedir(); + const bin = yield* findRouterBin(process.env, home).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + if (!bin) { + return yield* fail( + `Prism router is not installed: no bin/model-router under ~/${ROUTER_PLUGIN_DIR.join("/")}.`, + ); + } + const output = yield* runner + .run({ + command: bin, + args: ["--state-dir", routerStateDir(process.env, home), ...args], + timeout: ROUTER_TIMEOUT, + env: { ...process.env, ...env }, + }) + .pipe(Effect.catchCause(() => fail(`Could not run the Prism router at ${bin}.`))); + const parsed = parseRouterOutput(output.stdout, output.stderr, output.code); + if ("error" in parsed) return yield* fail(parsed.error); + return parsed.ok; + }); + + return PrismToolkit.of({ + prism_submit: (input) => + Effect.gen(function* () { + const caller = yield* plannerCaller; + const project = yield* snapshots.getProjectShellById(ProjectId.make(caller.projectId)).pipe( + Effect.map(Option.getOrUndefined), + Effect.catchCause(() => Effect.void), + ); + const workspace = input.workspace ?? caller.worktreePath ?? project?.workspaceRoot; + if (!workspace) return yield* fail("Pass workspace: this thread has no checkout."); + const random = (yield* crypto.randomUUIDv4.pipe(Effect.orDie)).slice(0, 8); + const nowMs = yield* Clock.currentTimeMillis; + const requestId = input.requestId ?? `prism-${nowMs.toString(36)}-${random}`; + // The router talks back to this server (child threads, planner + // reports) with a token scoped to orchestration, never admin. + const issued = yield* auth + .issueSession({ + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + label: `Prism router ${requestId}`, + ttl: ROUTER_TOKEN_TTL, + }) + .pipe(Effect.catchCause(() => fail("Could not issue a server token for Prism."))); + const router = yield* runRouter( + submitArgs({ + requestId, + task: input.task, + workspace, + plannerThreadId: caller.id, + serverUrl, + lane: input.lane, + handoffSummary: input.handoffSummary, + }), + { T3_SERVER_URL: serverUrl, T3_SERVER_TOKEN: issued.token }, + ); + return { requestId, router }; + }), + prism_status: ({ requestId }) => + plannerCaller.pipe( + Effect.andThen(runRouter(["status", "--request-id", requestId])), + Effect.map((router) => ({ requestId, router })), + ), + prism_questions: ({ requestId, includeAnswered }) => + plannerCaller.pipe( + Effect.andThen( + runRouter([ + "questions", + "--request-id", + requestId, + ...(includeAnswered ? ["--all"] : []), + ]), + ), + Effect.map((router) => ({ requestId, router })), + ), + prism_answer: ({ requestId, qid, answer }) => + plannerCaller.pipe( + Effect.andThen( + runRouter(["answer", "--request-id", requestId, "--qid", qid, "--answer", answer]), + ), + Effect.map((router) => ({ requestId, router })), + ), + }); +}); + +export const PrismToolkitHandlersLive = PrismToolkit.toLayer(make); diff --git a/apps/server/src/mcp/toolkits/prism/tools.ts b/apps/server/src/mcp/toolkits/prism/tools.ts new file mode 100644 index 0000000000..b9e01ce78a --- /dev/null +++ b/apps/server/src/mcp/toolkits/prism/tools.ts @@ -0,0 +1,121 @@ +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import * as Tool from "effect/unstable/ai/Tool"; +import * as Toolkit from "effect/unstable/ai/Toolkit"; + +import * as McpInvocationContext from "../../McpInvocationContext.ts"; + +const dependencies = [McpInvocationContext.McpInvocationContext]; + +export class PrismToolError extends Schema.TaggedError()("PrismToolError", { + reason: Schema.String, +}) { + override get message(): string { + return this.reason; + } +} + +const RequestId = TrimmedNonEmptyString.annotate({ + description: "The Prism job's request id, as prism_submit returned it.", +}); + +export const PrismSubmitInput = Schema.Struct({ + task: TrimmedNonEmptyString.annotate({ + description: + "The whole task packet: outcome, Issue, acceptance criteria, non-goals, proof, and delivery (branch, one PR, no merge).", + }), + workspace: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: + "Absolute path of the checkout the job works in. Defaults to this thread's worktree, else its project root.", + }), + ), + lane: Schema.optional( + Schema.Literals(["default", "small", "hard"]).annotate({ + description: + "Routing lane: small for mechanical work, hard for difficult work, default otherwise.", + }), + ), + requestId: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "A unique job id. Generated when omitted.", + }), + ), + handoffSummary: Schema.optional(TrimmedNonEmptyString), +}); + +/** The router's own JSON reply, passed through. */ +export const PrismResult = Schema.Struct({ + requestId: Schema.String, + router: Schema.Unknown, +}); + +const PrismSubmitTool = Tool.make("prism_submit", { + description: + "Hand a full job to Prism (Model Router). Prism starts a dispatcher as a child of this thread, which runs workers, falls back to another model when capacity fails, recovers stalled work, and ends with one pushed branch and one open PR carrying proof and review. This thread becomes the job's planner automatically: it is woken only when the dispatcher needs judgment, and receives the final state (ready with the PR URL, or blocked, failed or cancelled with the reason). Use it for authorized work that should end in a PR; for small direct work (a quick review, a bounded fix) use spawn_thread with a role instead.", + parameters: PrismSubmitInput, + success: PrismResult, + failure: PrismToolError, + dependencies, +}) + .annotate(Tool.Title, "Submit Prism job") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +const PrismStatusTool = Tool.make("prism_status", { + description: + "Read a Prism job's state: status, route, launches, open questions and recent events. Use it when the user asks how a job is going; the final state arrives in this thread by itself.", + parameters: Schema.Struct({ requestId: RequestId }), + success: PrismResult, + failure: PrismToolError, + dependencies, +}) + .annotate(Tool.Title, "Prism job status") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const PrismQuestionsTool = Tool.make("prism_questions", { + description: + "List a Prism job's questions for its planner: pending ones by default, all with includeAnswered. Answer each with prism_answer.", + parameters: Schema.Struct({ + requestId: RequestId, + includeAnswered: Schema.optional(Schema.Boolean), + }), + success: PrismResult, + failure: PrismToolError, + dependencies, +}) + .annotate(Tool.Title, "Prism job questions") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, true); + +const PrismAnswerTool = Tool.make("prism_answer", { + description: + "Answer one of a Prism job's pending questions; the dispatcher continues with the answer. Decide within your authority; take Human Gates to the user first.", + parameters: Schema.Struct({ + requestId: RequestId, + qid: TrimmedNonEmptyString.annotate({ description: "The question id from prism_questions." }), + answer: TrimmedNonEmptyString, + }), + success: PrismResult, + failure: PrismToolError, + dependencies, +}) + .annotate(Tool.Title, "Answer Prism question") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, true); + +export const PrismToolkit = Toolkit.make( + PrismSubmitTool, + PrismStatusTool, + PrismQuestionsTool, + PrismAnswerTool, +); diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index d96b70808b..743baff316 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -7,9 +7,12 @@ import { type OrchestrationEvent, type OrchestrationSession, type OrchestrationThreadShell, + type PrismRoleKits, type ProviderOptionSelection, } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -18,8 +21,18 @@ import * as Stream from "effect/Stream"; import * as OrchestrationEngine from "../../../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderRegistry from "../../../provider/Services/ProviderRegistry.ts"; import * as ProviderService from "../../../provider/Services/ProviderService.ts"; +import { ServerSettingsService } from "../../../serverSettings.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; +import { + pickRoleModel, + prismRoleSuffix, + roleTaskMessage, + type ThreadToolName, + threadToolRefusal, + threadToolScopeOf, +} from "./roles.ts"; import { isSubagentThreadId, makeSubagentThreadId, parentThreadIdOf } from "./subagentThreadId.ts"; import { type SubagentStatus, @@ -135,6 +148,8 @@ const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const providers = yield* ProviderService.ProviderService; + const registry = yield* ProviderRegistry.ProviderRegistry; + const serverSettings = yield* ServerSettingsService; const crypto = yield* Crypto.Crypto; /** Child thread id -> whether its turn results go back to the parent. @@ -205,12 +220,29 @@ const make = Effect.gen(function* () { }; }); - /** The calling thread, plus a guard that the target is in the requested scope. */ - const callerScopedThread = (threadId: string, scope: ThreadScope) => + /** Prism role kits as resolved for a project. */ + const roleKits = (projectId: OrchestrationThreadShell["projectId"]) => + serverSettings.getSettings.pipe( + Effect.map((settings) => resolveProjectSettings(settings, projectId).settings.prismRoles), + Effect.catchCause(() => fail("Could not read Prism role settings.")), + ); + + /** The calling thread, once its role may use `tool` with `scope`. */ + const authorizedCaller = (tool: ThreadToolName, scope: ThreadScope) => Effect.gen(function* () { const invocation = yield* McpInvocationContext.McpInvocationContext; const caller = yield* threadShell(invocation.threadId); if (!caller) return yield* fail(`Thread ${invocation.threadId} was not found.`); + const kits: PrismRoleKits = yield* roleKits(caller.projectId); + const refusal = threadToolRefusal(threadToolScopeOf(caller.id, kits), tool, scope); + if (refusal) return yield* fail(refusal); + return { caller, kits }; + }); + + /** The calling thread, plus a guard that the target is in the requested scope. */ + const callerScopedThread = (tool: ThreadToolName, threadId: string, scope: ThreadScope) => + Effect.gen(function* () { + const { caller } = yield* authorizedCaller(tool, scope); const target = yield* threadShell(threadId); if (!target) return yield* fail(`Thread ${threadId} was not found.`); if (!threadIsInScope(target, caller, scope)) @@ -220,9 +252,7 @@ const make = Effect.gen(function* () { const listThreads = (scope: ThreadScope, includeSettled: boolean) => Effect.gen(function* () { - const invocation = yield* McpInvocationContext.McpInvocationContext; - const caller = yield* threadShell(invocation.threadId); - if (!caller) return yield* fail(`Thread ${invocation.threadId} was not found.`); + const { caller } = yield* authorizedCaller("list_threads", scope); const shells = yield* snapshots.getShellSnapshot().pipe( Effect.map((snapshot) => snapshot.threads), Effect.catchCause(() => fail("Could not read threads.")), @@ -235,9 +265,7 @@ const make = Effect.gen(function* () { const listChildThreads = () => Effect.gen(function* () { - const invocation = yield* McpInvocationContext.McpInvocationContext; - const caller = yield* threadShell(invocation.threadId); - if (!caller) return yield* fail(`Thread ${invocation.threadId} was not found.`); + const { caller } = yield* authorizedCaller("list_child_threads", "children"); const shells = yield* snapshots.getShellSnapshot().pipe( Effect.map((snapshot) => snapshot.threads), Effect.catchCause(() => fail("Could not read threads.")), @@ -391,41 +419,59 @@ const make = Effect.gen(function* () { return ThreadsToolkit.of({ spawn_thread: (input) => Effect.gen(function* () { - const scope = yield* McpInvocationContext.McpInvocationContext; - const parent = yield* threadShell(scope.threadId); - if (!parent) return yield* fail(`Thread ${scope.threadId} was not found.`); + const { caller: parent, kits } = yield* authorizedCaller("spawn_thread", "children"); + const kit = input.role ? kits[input.role] : undefined; + // A role's preferred model applies only when the caller names none. + let preferred: { instanceId: string; model: string; effort?: string } | undefined; + if (kit && !input.model && !input.instanceId && kit.models.length > 0) { + const nowMs = yield* Clock.currentTimeMillis; + const picked = pickRoleModel(kit.models, yield* registry.getProviders, nowMs); + if ("refusal" in picked) return yield* fail(picked.refusal); + preferred = picked.pick; + } const instanceId = ProviderInstanceId.make( - input.instanceId ?? parent.modelSelection.instanceId, + input.instanceId ?? preferred?.instanceId ?? parent.modelSelection.instanceId, ); const sameInstance = instanceId === parent.modelSelection.instanceId; - const model = input.model ?? (sameInstance ? parent.modelSelection.model : undefined); + const model = + input.model ?? + preferred?.model ?? + (sameInstance ? parent.modelSelection.model : undefined); if (!model) return yield* fail("Pass model when instanceId differs from this thread's."); + const effort = input.effort ?? preferred?.effort; const info = yield* providers .getInstanceInfo(instanceId) .pipe(Effect.catchCause(() => fail(`Unknown provider instance ${instanceId}.`))); if (!info.enabled) { return yield* fail(`Provider instance ${instanceId} is disabled in T3 Code settings.`); } - const options: ProviderOptionSelection[] = input.effort - ? [{ id: effortOptionId(info.driverKind), value: input.effort }] + const options: ProviderOptionSelection[] = effort + ? [{ id: effortOptionId(info.driverKind), value: effort }] : []; const modelSelection = { instanceId, model, ...(options.length > 0 ? { options } : {}), }; + const random = (yield* uuid).replaceAll("-", "").slice(0, 12); const childId = ThreadId.make( - makeSubagentThreadId(parent.id, (yield* uuid).replaceAll("-", "").slice(0, 12)), + makeSubagentThreadId( + parent.id, + input.role ? prismRoleSuffix(input.role, random) : random, + ), ); reportBack.set(childId, input.reportBack !== false); const createdAt = yield* nowIso; - const runtimeMode = input.runtimeMode ?? parent.runtimeMode; + const runtimeMode = input.runtimeMode ?? kit?.runtimeMode ?? parent.runtimeMode; + const titlePrefix = input.role + ? `${input.role[0]!.toUpperCase()}${input.role.slice(1)}` + : "Subagent"; yield* dispatch({ type: "thread.create", commandId: yield* commandId("create"), threadId: childId, projectId: parent.projectId, - title: input.title ?? `Subagent: ${input.task.slice(0, 60)}`, + title: input.title ?? `${titlePrefix}: ${input.task.slice(0, 60)}`, modelSelection, runtimeMode, interactionMode: "default", @@ -435,12 +481,18 @@ const make = Effect.gen(function* () { }); const child = yield* threadShell(childId); if (!child) return yield* fail(`Child thread ${childId} was not created.`); - yield* startTurn(child, input.task); - return { threadId: childId, parentThreadId: parent.id, instanceId, model }; + yield* startTurn(child, kit ? roleTaskMessage(kit, input.task) : input.task); + return { + threadId: childId, + ...(input.role ? { role: input.role } : {}), + parentThreadId: parent.id, + instanceId, + model, + }; }), message_thread: ({ threadId, text, scope }) => Effect.gen(function* () { - const { caller, target } = yield* callerScopedThread(threadId, scope); + const { caller, target } = yield* callerScopedThread("message_thread", threadId, scope); const statusBefore = subagentStatusOf(target.session); if (statusBefore === "starting") { return yield* fail(`Thread ${threadId} is still starting. Retry in a few seconds.`); @@ -449,7 +501,9 @@ const make = Effect.gen(function* () { return { threadId, statusBefore, delivery: deliveryOf(statusBefore) }; }), read_thread: ({ threadId, scope }) => - callerScopedThread(threadId, scope).pipe(Effect.flatMap(({ target }) => summarize(target))), + callerScopedThread("read_thread", threadId, scope).pipe( + Effect.flatMap(({ target }) => summarize(target)), + ), list_child_threads: () => listChildThreads(), list_threads: ({ scope, includeSettled }) => listThreads(scope, includeSettled), }); diff --git a/apps/server/src/mcp/toolkits/threads/roles.test.ts b/apps/server/src/mcp/toolkits/threads/roles.test.ts new file mode 100644 index 0000000000..7ca8797f3b --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/roles.test.ts @@ -0,0 +1,176 @@ +import { DEFAULT_PRISM_ROLE_KITS, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isProviderBlocked, + pickRoleModel, + prismRoleSuffix, + roleTaskMessage, + threadRoleOf, + threadToolRefusal, + threadToolScopeOf, +} from "./roles.ts"; +import { makeSubagentThreadId } from "./subagentThreadId.ts"; + +const child = (role: string) => makeSubagentThreadId("planner-1", `${role}-abc123`); + +describe("thread roles", () => { + it("treats a thread the user started as the planner", () => { + expect(threadRoleOf("planner-1")).toBe("planner"); + }); + + it("reads a role spawned child's role from its id", () => { + expect(threadRoleOf(makeSubagentThreadId("planner-1", prismRoleSuffix("reviewer", "a1")))).toBe( + "reviewer", + ); + expect(threadRoleOf(child("dispatcher"))).toBe("dispatcher"); + expect(threadRoleOf(makeSubagentThreadId(child("dispatcher"), "worker-9f"))).toBe("worker"); + }); + + it("leaves children without a role prefix unassigned", () => { + expect(threadRoleOf(makeSubagentThreadId("planner-1", "0123456789ab"))).toBe("unassigned"); + expect(threadRoleOf(makeSubagentThreadId("planner-1", "tester-1"))).toBe("unassigned"); + }); +}); + +describe("thread tool scope per role", () => { + const scopeOf = (threadId: string) => threadToolScopeOf(threadId, DEFAULT_PRISM_ROLE_KITS); + const tools = [ + "spawn_thread", + "message_thread", + "read_thread", + "list_child_threads", + "list_threads", + ] as const; + const allowed = (threadId: string, scope: "children" | "project") => + tools.filter((tool) => threadToolRefusal(scopeOf(threadId), tool, scope) === null); + + it("gives the planner every tool with project scope", () => { + expect(allowed("planner-1", "project")).toEqual(tools); + }); + + it("limits a dispatcher to reading and messaging its own children", () => { + expect(allowed(child("dispatcher"), "children")).toEqual([ + "message_thread", + "read_thread", + "list_child_threads", + "list_threads", + ]); + expect(allowed(child("dispatcher"), "project")).toEqual([]); + }); + + it("lets a reviewer read the project but not write", () => { + expect(allowed(child("reviewer"), "project")).toEqual([ + "read_thread", + "list_child_threads", + "list_threads", + ]); + }); + + it("gives workers, corrections and recoveries no thread tools", () => { + for (const role of ["worker", "correction", "recovery"]) { + expect(allowed(child(role), "children")).toEqual([]); + } + }); + + it("keeps the old behavior for unassigned children", () => { + expect(allowed(makeSubagentThreadId("planner-1", "0123456789ab"), "project")).toEqual(tools); + }); + + it("follows a kit that changes a role's scope", () => { + const kits = { + ...DEFAULT_PRISM_ROLE_KITS, + worker: { ...DEFAULT_PRISM_ROLE_KITS.worker, threadTools: "project-read" as const }, + }; + expect(threadToolScopeOf(child("worker"), kits)).toBe("project-read"); + }); +}); + +const provider = (overrides: Partial = {}): ServerProvider => + ({ + instanceId: "opencode", + driver: "opencode", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-25T00:00:00.000Z", + models: [{ slug: "opencode/muse", name: "Muse", isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + ...overrides, + }) as ServerProvider; + +const window = (usedPercent: number, resetsAt?: string) => ({ + id: "five_hour", + kind: "session" as const, + label: "5h", + usedPercent, + ...(resetsAt ? { resetsAt } : {}), +}); + +describe("role model eligibility", () => { + const now = Date.parse("2026-09-25T12:00:00.000Z"); + const muse = { instanceId: "opencode", model: "opencode/muse", effort: "high" } as never; + const luna = { instanceId: "codex", model: "gpt-5.6-luna" } as never; + + it("picks the first preference enabled in Providers", () => { + expect(pickRoleModel([luna, muse], [provider()], now)).toEqual({ pick: muse }); + }); + + it("skips a disabled instance and a model the instance does not offer", () => { + const result = pickRoleModel( + [muse, { instanceId: "opencode", model: "opencode/other" } as never], + [provider({ enabled: false })], + now, + ); + expect(result).toEqual({ + refusal: + "No eligible model for this role: opencode/opencode/muse (instance disabled), opencode/opencode/other (instance disabled).", + }); + expect( + pickRoleModel([{ instanceId: "opencode", model: "x" } as never], [provider()], now), + ).toEqual({ refusal: "No eligible model for this role: opencode/x (model not offered)." }); + }); + + it("blocks an instance at 100 % until its window resets", () => { + const exhausted = provider({ + usageLimits: { + checkedAt: "2026-09-25T11:59:00.000Z", + windows: [window(100, "2026-09-25T13:00:00.000Z")], + }, + }); + expect(isProviderBlocked(exhausted, now)).toBe(true); + expect(isProviderBlocked(exhausted, Date.parse("2026-09-25T13:00:01.000Z"))).toBe(false); + expect("refusal" in pickRoleModel([muse], [exhausted], now)).toBe(true); + }); + + it("blocks a full window without resetsAt until the next reading, not below 100 %", () => { + const noReset = provider({ + usageLimits: { checkedAt: "2026-09-25T11:59:00.000Z", windows: [window(100)] }, + }); + expect(isProviderBlocked(noReset, now)).toBe(true); + const busy = provider({ + usageLimits: { checkedAt: "2026-09-25T11:59:00.000Z", windows: [window(85)] }, + }); + expect(isProviderBlocked(busy, now)).toBe(false); + }); +}); + +describe("role task message", () => { + it("puts the kit's instructions and skills before the task", () => { + const kit = { + ...DEFAULT_PRISM_ROLE_KITS.reviewer, + instructions: "Review only; do not edit.", + skills: ["code-review"], + }; + expect(roleTaskMessage(kit, "Review PR 12.")).toBe( + "Review only; do not edit.\n\nUse these skills: code-review.\n\nReview PR 12.", + ); + }); + + it("sends the bare task for an empty kit", () => { + expect(roleTaskMessage(DEFAULT_PRISM_ROLE_KITS.worker, "Fix it.")).toBe("Fix it."); + }); +}); diff --git a/apps/server/src/mcp/toolkits/threads/roles.ts b/apps/server/src/mcp/toolkits/threads/roles.ts new file mode 100644 index 0000000000..98a0925c4f --- /dev/null +++ b/apps/server/src/mcp/toolkits/threads/roles.ts @@ -0,0 +1,130 @@ +import { + PRISM_ROLES, + type PrismModelPreference, + type PrismRole, + type PrismRoleKit, + type PrismThreadToolScope, + type ServerProvider, +} from "@t3tools/contracts"; + +import { isSubagentThreadId } from "./subagentThreadId.ts"; +import type { ThreadScope } from "./tools.ts"; + +/** + * Prism roles on T3 threads (toolboxmd/model-router#115). + * + * A thread the user starts (no parent) is the planner. A child started in a + * role carries it at the front of its id suffix, `sub..-`, + * so the role survives restarts like the parent link does. A child without a + * role prefix (direct spawns, router threads before it names roles) keeps the + * thread tools it always had and gets no Prism tools. + */ +export function prismRoleSuffix(role: PrismRole, random: string): string { + return `${role}-${random}`; +} + +export function threadRoleOf(threadId: string): PrismRole | "unassigned" { + if (!isSubagentThreadId(threadId)) return "planner"; + const suffix = threadId.slice(threadId.lastIndexOf(".") + 1); + const prefix = suffix.slice(0, suffix.indexOf("-")); + return (PRISM_ROLES as readonly string[]).includes(prefix) ? (prefix as PrismRole) : "unassigned"; +} + +export type ThreadToolName = + | "spawn_thread" + | "message_thread" + | "read_thread" + | "list_child_threads" + | "list_threads"; + +/** + * Null when the caller may use `tool` with `scope`, else the refusal. An + * unassigned child is treated like the planner for thread tools, which is + * what every child could do before roles existed. + */ +export function threadToolRefusal( + toolScope: PrismThreadToolScope | "unassigned", + tool: ThreadToolName, + scope: ThreadScope, +): string | null { + switch (toolScope) { + case "planner": + case "unassigned": + return null; + case "children": + if (tool === "spawn_thread") return "This role may not spawn threads."; + return scope === "children" + ? null + : "This role may only reach its own child threads (scope: children)."; + case "project-read": + return tool === "read_thread" || tool === "list_threads" || tool === "list_child_threads" + ? null + : "This role may only read threads."; + case "none": + return "This role has no thread tools."; + } +} + +/** The caller's thread-tool scope: its role's kit, or `unassigned`. */ +export function threadToolScopeOf( + threadId: string, + kits: Readonly>, +): PrismThreadToolScope | "unassigned" { + const role = threadRoleOf(threadId); + return role === "unassigned" ? role : kits[role].threadTools; +} + +/** + * A provider instance is blocked while any usage window is exhausted and + * has not reset yet. A window at 100 % without `resetsAt` blocks until the + * next refresh replaces the reading. + */ +export function isProviderBlocked(provider: ServerProvider, nowMs: number): boolean { + return (provider.usageLimits?.windows ?? []).some( + (window) => + window.usedPercent >= 100 && + (window.resetsAt === undefined || Date.parse(window.resetsAt) > nowMs), + ); +} + +function offersModel(provider: ServerProvider, model: string): boolean { + return provider.models.some( + (candidate) => candidate.slug === model || (candidate.aliases ?? []).includes(model), + ); +} + +/** + * The first of a role's preferred models that is enabled in Providers and + * not blocked by a usage limit, or the reason none is. + */ +export function pickRoleModel( + preferences: ReadonlyArray, + providers: ReadonlyArray, + nowMs: number, +): { readonly pick: PrismModelPreference } | { readonly refusal: string } { + const skipped: string[] = []; + for (const preference of preferences) { + const provider = providers.find((candidate) => candidate.instanceId === preference.instanceId); + const label = `${preference.instanceId}/${preference.model}`; + if (!provider || !provider.enabled || provider.availability === "unavailable") { + skipped.push(`${label} (instance disabled)`); + } else if (!offersModel(provider, preference.model)) { + skipped.push(`${label} (model not offered)`); + } else if (isProviderBlocked(provider, nowMs)) { + skipped.push(`${label} (usage limit reached)`); + } else { + return { pick: preference }; + } + } + return { refusal: `No eligible model for this role: ${skipped.join(", ")}.` }; +} + +/** The child's first message: the role's instructions and skills, then the task. */ +export function roleTaskMessage(kit: PrismRoleKit, task: string): string { + const parts = [ + kit.instructions, + kit.skills.length > 0 ? `Use these skills: ${kit.skills.join(", ")}.` : "", + task, + ]; + return parts.filter((part) => part.length > 0).join("\n\n"); +} diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index b16deab3ae..0878eae8f0 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -1,4 +1,4 @@ -import { RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { PrismRole, RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Tool from "effect/unstable/ai/Tool"; @@ -31,15 +31,22 @@ export const SpawnThreadInput = Schema.Struct({ task: TrimmedNonEmptyString.annotate({ description: "The first message the child thread receives: its whole task.", }), + role: Schema.optional( + PrismRole.annotate({ + description: + "Prism role for the child: dispatcher, reviewer, worker, correction, recovery (or planner). Applies that role's kit from Prism settings: its instructions, skills, permissions, thread-tool scope, and its first eligible preferred model unless model is named.", + }), + ), instanceId: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "Provider instance to run the child on, for example claudeAgent, codex, opencode or grok. Defaults to this thread's provider instance.", + "Provider instance to run the child on, for example claudeAgent, codex, opencode or grok. Defaults to the role's preferred model, else this thread's provider instance.", }), ), model: Schema.optional( TrimmedNonEmptyString.annotate({ - description: "Model id on that instance. Defaults to this thread's model.", + description: + "Model id on that instance. Defaults to the role's first eligible preferred model, else this thread's model.", }), ), effort: Schema.optional( @@ -60,6 +67,7 @@ export const SpawnThreadInput = Schema.Struct({ export const SpawnThreadResult = Schema.Struct({ threadId: Schema.String, + role: Schema.optional(PrismRole), parentThreadId: Schema.String, instanceId: Schema.String, model: Schema.String, @@ -96,7 +104,7 @@ export const ThreadSummary = Schema.Struct({ const SpawnThreadTool = Tool.make("spawn_thread", { description: - "Start a child thread in this project on a chosen provider instance, model and effort, and send it a task. Returns immediately with the child's thread id; the child shows in this thread's Agents panel. Use read_thread to see its reply, message_thread to talk to it.", + "Start a child thread in this project and send it a task: small direct work such as a quick review or a bounded fix. Pass role (for example reviewer or worker) to apply that Prism role's kit and preferred model, or name a provider instance, model and effort. For a full job that needs a dispatcher, fallback, recovery and one PR, use prism_submit instead. Returns immediately with the child's thread id; the child shows in this thread's Agents panel. Use read_thread to see its reply, message_thread to talk to it.", parameters: SpawnThreadInput, success: SpawnThreadResult, failure: ThreadsToolError, diff --git a/apps/server/src/prism/snapshotRoute.test.ts b/apps/server/src/prism/snapshotRoute.test.ts new file mode 100644 index 0000000000..0c478f6725 --- /dev/null +++ b/apps/server/src/prism/snapshotRoute.test.ts @@ -0,0 +1,82 @@ +import { ProjectId, type ServerProvider, ServerSettings } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { makePrismSnapshot } from "./snapshotRoute.ts"; + +const decodeServerSettings = Schema.decodeSync(ServerSettings); + +const provider = { + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-25T00:00:00.000Z", + models: [{ slug: "gpt-5.6-luna", name: "Luna", isCustom: false, capabilities: null }], + slashCommands: [{ name: "review" }], + skills: [], + usageLimits: { + checkedAt: "2026-09-25T00:00:00.000Z", + windows: [ + { + id: "primary", + kind: "session", + label: "5h", + usedPercent: 40, + resetsAt: "2026-09-25T05:00:00.000Z", + }, + ], + }, +} as unknown as ServerProvider; + +describe("Prism provider snapshot", () => { + const luna = [{ instanceId: "codex", model: "gpt-5.6-luna" }]; + const settings = decodeServerSettings({ + prismRoles: { worker: { models: luna } }, + projectSettingsOverrides: { p1: { prismRoles: { reviewer: { models: luna } } } }, + }); + + it("carries models and usage windows with their reset times", () => { + const snapshot = makePrismSnapshot({ + generatedAt: "2026-09-25T01:00:00.000Z", + projectId: null, + providers: [provider], + settings, + }); + expect(snapshot.providers).toEqual([ + { + instanceId: "codex", + driver: "codex", + enabled: true, + status: "ready", + models: provider.models, + usageLimits: provider.usageLimits, + }, + ]); + expect(snapshot.roles.worker.models).toEqual(luna); + }); + + it("resolves role kits for a project over the environment", () => { + const snapshot = makePrismSnapshot({ + generatedAt: "2026-09-25T01:00:00.000Z", + projectId: ProjectId.make("p1"), + providers: [], + settings, + }); + expect(snapshot.roles.reviewer.models).toEqual(luna); + expect(snapshot.roles.worker.models).toEqual([]); + }); + + it("reports an unavailable instance as disabled", () => { + const snapshot = makePrismSnapshot({ + generatedAt: "2026-09-25T01:00:00.000Z", + projectId: null, + providers: [{ ...provider, availability: "unavailable" }], + settings, + }); + expect(snapshot.providers[0]?.enabled).toBe(false); + }); +}); diff --git a/apps/server/src/prism/snapshotRoute.ts b/apps/server/src/prism/snapshotRoute.ts new file mode 100644 index 0000000000..6cb743ed9c --- /dev/null +++ b/apps/server/src/prism/snapshotRoute.ts @@ -0,0 +1,73 @@ +import { + AuthOrchestrationReadScope, + type PrismProviderSnapshot, + ProjectId, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + +export const PRISM_SNAPSHOT_PATH = "/api/prism/snapshot"; + +/** + * The Prism router's view of this environment: every provider instance with + * its models, option descriptors and usage windows, and the role kits + * resolved for `projectId` (environment values when null). + */ +export function makePrismSnapshot(input: { + readonly generatedAt: string; + readonly projectId: ProjectId | null; + readonly providers: ReadonlyArray; + readonly settings: ServerSettings; +}): PrismProviderSnapshot { + return { + generatedAt: input.generatedAt, + projectId: input.projectId, + providers: input.providers.map((provider) => ({ + instanceId: provider.instanceId, + driver: provider.driver, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + enabled: provider.enabled && provider.availability !== "unavailable", + status: provider.status, + models: provider.models, + ...(provider.usageLimits ? { usageLimits: provider.usageLimits } : {}), + })), + roles: resolveProjectSettings(input.settings, input.projectId).settings.prismRoles, + }; +} + +const jsonError = (status: number, error: string) => + HttpServerResponse.jsonUnsafe({ error }, { status }); + +/** `GET /api/prism/snapshot[?projectId=…]`, bearer token with `orchestration:read`. */ +export const prismSnapshotRouteLayer = HttpRouter.add( + "GET", + PRISM_SNAPSHOT_PATH, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const session = yield* auth.authenticateHttpRequest(request).pipe(Effect.option); + if (Option.isNone(session)) return jsonError(401, "unauthorized"); + if (!session.value.scopes.includes(AuthOrchestrationReadScope)) { + return jsonError(403, `scope ${AuthOrchestrationReadScope} required`); + } + const url = HttpServerRequest.toURL(request); + const rawProjectId = Option.isSome(url) ? url.value.searchParams.get("projectId") : null; + const projectId = rawProjectId?.trim() ? ProjectId.make(rawProjectId.trim()) : null; + const settings = yield* (yield* ServerSettingsService).getSettings.pipe(Effect.option); + if (Option.isNone(settings)) return jsonError(500, "settings unavailable"); + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + const generatedAt = DateTime.formatIso(yield* DateTime.now); + return HttpServerResponse.jsonUnsafe( + makePrismSnapshot({ generatedAt, projectId, providers, settings: settings.value }), + ); + }), +); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 0d874b9ceb..2e6f3dbc31 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -230,7 +230,7 @@ export const OpenCodeDriver: ProviderDriver streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, checkProviderOnSettingsChange: () => false, - refreshOnInterval: false, + refreshOnInterval: true, initialSnapshot: (settings) => makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)), checkProvider, diff --git a/docs/fork.md b/docs/fork.md index 20ea2e9780..d0d794404d 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -79,10 +79,16 @@ any modified upstream file missing from `scripts/fork-upstream-edits.txt`. `apps/web/src/components/chat/ChatHeader.tsx` renders the parent crumb and sibling menu for a child thread. The logic lives in the fork-owned `AgentThreadTree.logic.ts`, `AgentThreadTree.tsx` and `chat/ThreadParentCrumbs.tsx`. -- Prism role kits (#19): `packages/contracts/src/settings.ts` adds the - `prismRoles` server setting (project-scoped, with its patch) and +- Prism toolkit and role kits (#19): `packages/contracts/src/settings.ts` + adds the `prismRoles` server setting (project-scoped, with its patch) and `packages/contracts/src/index.ts` exports the fork-owned `prism.ts` and - `prismSnapshot.ts`. + `prismSnapshot.ts`; `apps/server/src/mcp/McpHttpServer.ts` registers the + fork-owned Prism toolkit (`toolkits/prism/`) and `GET /api/prism/snapshot` + (`apps/server/src/prism/`); the threads toolkit's `tools.ts` and + `handlers.ts` take `spawn_thread(role)` and enforce each role's thread-tool + scope (`toolkits/threads/roles.ts`); + `apps/server/src/provider/Drivers/OpenCodeDriver.ts` turns on interval + refresh so OpenCode usage windows stay current. - `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`. - `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts index 9e840479a2..56b7e2b8d1 100644 --- a/packages/contracts/src/prism.test.ts +++ b/packages/contracts/src/prism.test.ts @@ -4,6 +4,10 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_PRISM_ROLE_KITS, PrismRoleKitsPatch } from "./prism.ts"; import { ProjectSettingsOverrides, ServerSettings } from "./settings.ts"; +const decodeServerSettings = Schema.decodeSync(ServerSettings); +const decodeProjectOverrides = Schema.decodeSync(ProjectSettingsOverrides); +const decodeKitsPatch = Schema.decodeSync(PrismRoleKitsPatch); + describe("Prism role kits", () => { it("default each role to its thread-tool scope with no preferred models", () => { expect(DEFAULT_PRISM_ROLE_KITS.planner.threadTools).toBe("planner"); @@ -16,7 +20,7 @@ describe("Prism role kits", () => { }); it("fill missing roles and fields when a settings file names one role", () => { - const settings = Schema.decodeSync(ServerSettings)({ + const settings = decodeServerSettings({ prismRoles: { worker: { models: [{ instanceId: "opencode", model: "opencode/muse", effort: "high" }] }, }, @@ -29,7 +33,7 @@ describe("Prism role kits", () => { }); it("accept a project override of the whole kit set", () => { - const overrides = Schema.decodeSync(ProjectSettingsOverrides)({ + const overrides = decodeProjectOverrides({ prismRoles: { reviewer: { models: [{ instanceId: "codex", model: "gpt-5.6-luna" }] } }, }); expect(overrides.prismRoles?.reviewer.models[0]?.model).toBe("gpt-5.6-luna"); @@ -37,7 +41,7 @@ describe("Prism role kits", () => { }); it("patch one field of one role without defaults for the rest", () => { - const patch = Schema.decodeSync(PrismRoleKitsPatch)({ + const patch = decodeKitsPatch({ dispatcher: { models: [] }, }); expect(patch).toEqual({ dispatcher: { models: [] } }); diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt index 3e8eef42c5..992c071dda 100644 --- a/scripts/fork-upstream-edits.txt +++ b/scripts/fork-upstream-edits.txt @@ -43,6 +43,7 @@ apps/web/src/components/Sidebar.tsx apps/web/src/components/chat/ChatHeader.tsx # Prism role kits in settings (#19) +apps/server/src/provider/Drivers/OpenCodeDriver.ts packages/contracts/src/index.ts packages/contracts/src/settings.ts From 2913b44fdb4a710c3762bfc2811df914d1ced49e Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:11:40 +0200 Subject: [PATCH 3/5] chore: drop unused Prism exports flagged by knip (#19) Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/prism/snapshotRoute.ts | 2 +- packages/contracts/src/prism.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/prism/snapshotRoute.ts b/apps/server/src/prism/snapshotRoute.ts index 6cb743ed9c..0492c49f04 100644 --- a/apps/server/src/prism/snapshotRoute.ts +++ b/apps/server/src/prism/snapshotRoute.ts @@ -15,7 +15,7 @@ import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import { ServerSettingsService } from "../serverSettings.ts"; -export const PRISM_SNAPSHOT_PATH = "/api/prism/snapshot"; +const PRISM_SNAPSHOT_PATH = "/api/prism/snapshot"; /** * The Prism router's view of this environment: every provider instance with diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index 7b4fc738e1..27261fd425 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -40,7 +40,7 @@ export const PrismThreadToolScope = Schema.Literals([ ]); export type PrismThreadToolScope = typeof PrismThreadToolScope.Type; -export const PRISM_DEFAULT_THREAD_TOOL_SCOPES: Record = { +const PRISM_DEFAULT_THREAD_TOOL_SCOPES: Record = { planner: "planner", dispatcher: "children", reviewer: "project-read", From 9d22ee3d042ac57cbb913ef0c309cd43be8d0803 Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:17:51 +0200 Subject: [PATCH 4/5] feat(contracts): Prism model lists per role and lane (#19) Each role keeps an ordered list of {instanceId, model, effort} per lane (easy, medium, hard): the first entry is the primary choice, the rest are fallbacks, and one model at another effort is its own entry. Adds PrismLane (default medium) and per-lane patching. The snapshot carries the same structure through PrismRoleKits. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/contracts/src/prism.test.ts | 32 ++++++++++++++++------ packages/contracts/src/prism.ts | 41 ++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/packages/contracts/src/prism.test.ts b/packages/contracts/src/prism.test.ts index 56b7e2b8d1..9e9a1337b3 100644 --- a/packages/contracts/src/prism.test.ts +++ b/packages/contracts/src/prism.test.ts @@ -16,34 +16,48 @@ describe("Prism role kits", () => { for (const role of ["worker", "correction", "recovery"] as const) { expect(DEFAULT_PRISM_ROLE_KITS[role].threadTools).toBe("none"); } - expect(DEFAULT_PRISM_ROLE_KITS.worker.models).toEqual([]); + expect(DEFAULT_PRISM_ROLE_KITS.worker.lanes).toEqual({ easy: [], medium: [], hard: [] }); }); it("fill missing roles and fields when a settings file names one role", () => { const settings = decodeServerSettings({ prismRoles: { - worker: { models: [{ instanceId: "opencode", model: "opencode/muse", effort: "high" }] }, + worker: { + lanes: { + medium: [ + { instanceId: "claudeAgent", model: "claude-opus-5-5", effort: "medium" }, + { instanceId: "opencode", model: "opencode/muse", effort: "medium" }, + ], + hard: [{ instanceId: "claudeAgent", model: "claude-opus-5-5", effort: "xhigh" }], + }, + }, }, }); - expect(settings.prismRoles.worker.models).toEqual([ - { instanceId: "opencode", model: "opencode/muse", effort: "high" }, + expect(settings.prismRoles.worker.lanes.medium.map((entry) => entry.model)).toEqual([ + "claude-opus-5-5", + "opencode/muse", ]); + expect(settings.prismRoles.worker.lanes.hard[0]?.effort).toBe("xhigh"); + expect(settings.prismRoles.worker.lanes.easy).toEqual([]); + expect(settings.prismRoles.recovery.lanes).toEqual({ easy: [], medium: [], hard: [] }); expect(settings.prismRoles.worker.threadTools).toBe("none"); expect(settings.prismRoles.reviewer.threadTools).toBe("project-read"); }); it("accept a project override of the whole kit set", () => { const overrides = decodeProjectOverrides({ - prismRoles: { reviewer: { models: [{ instanceId: "codex", model: "gpt-5.6-luna" }] } }, + prismRoles: { + reviewer: { lanes: { medium: [{ instanceId: "codex", model: "gpt-5.6-luna" }] } }, + }, }); - expect(overrides.prismRoles?.reviewer.models[0]?.model).toBe("gpt-5.6-luna"); + expect(overrides.prismRoles?.reviewer.lanes.medium[0]?.model).toBe("gpt-5.6-luna"); expect(overrides.prismRoles?.planner.threadTools).toBe("planner"); }); - it("patch one field of one role without defaults for the rest", () => { + it("patch one lane of one role without defaults for the rest", () => { const patch = decodeKitsPatch({ - dispatcher: { models: [] }, + dispatcher: { lanes: { hard: [] } }, }); - expect(patch).toEqual({ dispatcher: { models: [] } }); + expect(patch).toEqual({ dispatcher: { lanes: { hard: [] } } }); }); }); diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index 27261fd425..8088367366 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -9,7 +9,7 @@ import { ProviderInstanceId } from "./providerInstance.ts"; * Prism (Model Router) roles and their kits (toolboxmd/model-router#115). * * A role is a kit (instructions, permissions, skills, thread-tool scope) - * plus an ordered list of preferred models. The kits live in server + * plus, per lane, an ordered list of models. The kits live in server * settings under `prismRoles`, overridable per project like any key in * `PROJECT_SCOPED_SERVER_SETTING_KEYS`. The router reads them from the * provider snapshot endpoint; `spawn_thread(role)` applies them directly. @@ -49,7 +49,16 @@ const PRISM_DEFAULT_THREAD_TOOL_SCOPES: Record recovery: "none", }; -/** One preferred model for a role; the first eligible entry wins. */ +/** Work difficulty a job or spawn runs at; each role keeps one model list per lane. */ +export const PRISM_LANES = ["easy", "medium", "hard"] as const; +export const PrismLane = Schema.Literals(PRISM_LANES); +export type PrismLane = typeof PrismLane.Type; +export const DEFAULT_PRISM_LANE: PrismLane = "medium"; + +/** + * One entry of a (role, lane) list. The same model at another effort is a + * separate entry. + */ export const PrismModelPreference = Schema.Struct({ instanceId: ProviderInstanceId, model: TrimmedNonEmptyString, @@ -58,6 +67,17 @@ export const PrismModelPreference = Schema.Struct({ }); export type PrismModelPreference = typeof PrismModelPreference.Type; +const laneModels = Schema.Array(PrismModelPreference).pipe( + Schema.withDecodingDefault(Effect.succeed([])), +); + +export const PrismLaneModels = Schema.Struct({ + easy: laneModels, + medium: laneModels, + hard: laneModels, +}).pipe(Schema.withDecodingDefault(Effect.succeed({}))); +export type PrismLaneModels = typeof PrismLaneModels.Type; + const prismRoleKit = (role: PrismRole) => Schema.Struct({ /** Prepended to the first message of every thread started in this role. */ @@ -71,8 +91,11 @@ const prismRoleKit = (role: PrismRole) => threadTools: PrismThreadToolScope.pipe( Schema.withDecodingDefault(Effect.succeed(PRISM_DEFAULT_THREAD_TOOL_SCOPES[role])), ), - /** Ordered preferences. Eligible = these ∩ models enabled in Providers. */ - models: Schema.Array(PrismModelPreference).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + /** + * Per lane, the primary model first and fallbacks after it. Eligible = + * these ∩ models enabled in Providers for the project and environment. + */ + lanes: PrismLaneModels, }).pipe(Schema.withDecodingDefault(Effect.succeed({}))); export const PrismRoleKits = Schema.Struct({ @@ -93,10 +116,16 @@ const PrismRoleKitPatch = Schema.Struct({ runtimeMode: Schema.optionalKey(RuntimeMode), skills: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), threadTools: Schema.optionalKey(PrismThreadToolScope), - models: Schema.optionalKey(Schema.Array(PrismModelPreference)), + lanes: Schema.optionalKey( + Schema.Struct({ + easy: Schema.optionalKey(Schema.Array(PrismModelPreference)), + medium: Schema.optionalKey(Schema.Array(PrismModelPreference)), + hard: Schema.optionalKey(Schema.Array(PrismModelPreference)), + }), + ), }); -/** Per-role, per-field update; arrays (skills, models) replace whole. */ +/** Per-role, per-field, per-lane update; arrays (skills, a lane's list) replace whole. */ export const PrismRoleKitsPatch = Schema.Struct({ planner: Schema.optionalKey(PrismRoleKitPatch), dispatcher: Schema.optionalKey(PrismRoleKitPatch), From c18070dcd4198862703631d76be31eeccff7356f Mon Sep 17 00:00:00 2001 From: lukemaj Date: Fri, 25 Sep 2026 20:20:47 +0200 Subject: [PATCH 5/5] feat(server): pick role models by lane in spawn_thread and prism_submit (#19) spawn_thread takes an optional lane (default medium) and walks that role's lane list, primary first then fallbacks. prism_submit takes easy, medium or hard and maps them onto the installed router's small, default and hard lanes. PRISM_LANES stays module-local for knip; PrismLane.literals lists the lanes. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/prism/handlers.test.ts | 2 +- .../server/src/mcp/toolkits/prism/handlers.ts | 15 +++++++-- apps/server/src/mcp/toolkits/prism/tools.ts | 6 ++-- .../src/mcp/toolkits/threads/handlers.ts | 11 ++++--- .../src/mcp/toolkits/threads/roles.test.ts | 32 +++++++++++++++++-- apps/server/src/mcp/toolkits/threads/roles.ts | 7 ++-- apps/server/src/mcp/toolkits/threads/tools.ts | 13 ++++++-- apps/server/src/prism/snapshotRoute.test.ts | 12 ++++--- packages/contracts/src/prism.ts | 2 +- 9 files changed, 76 insertions(+), 24 deletions(-) diff --git a/apps/server/src/mcp/toolkits/prism/handlers.test.ts b/apps/server/src/mcp/toolkits/prism/handlers.test.ts index 46cad15866..11bcfc197d 100644 --- a/apps/server/src/mcp/toolkits/prism/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/prism/handlers.test.ts @@ -12,7 +12,7 @@ it("submits with the calling thread as the T3 planner and starts the job", () => workspace: "/repo", plannerThreadId: "thread-1", serverUrl: "http://127.0.0.1:3999", - lane: "small", + lane: "easy", }); assert.deepStrictEqual(args, [ "submit", diff --git a/apps/server/src/mcp/toolkits/prism/handlers.ts b/apps/server/src/mcp/toolkits/prism/handlers.ts index 9f093131c4..d4746906bb 100644 --- a/apps/server/src/mcp/toolkits/prism/handlers.ts +++ b/apps/server/src/mcp/toolkits/prism/handlers.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, + type PrismLane, ProjectId, } from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; @@ -68,13 +69,23 @@ export function routerStateDir(env: NodeJS.ProcessEnv, home: string): string { return env.DURABLE_RUNNER_STATE_DIR?.trim() || `${home}/.local/share/durable-runner`; } +/** + * The installed router (0.34) names its lanes default, small and hard; + * Prism lanes map onto them until the router accepts easy/medium/hard. + */ +const ROUTER_LANES: Record = { + easy: "small", + medium: "default", + hard: "hard", +}; + export interface PrismSubmitArgs { readonly requestId: string; readonly task: string; readonly workspace: string; readonly plannerThreadId: string; readonly serverUrl: string; - readonly lane?: string | undefined; + readonly lane?: PrismLane | undefined; readonly handoffSummary?: string | undefined; } @@ -96,7 +107,7 @@ export function submitArgs(input: PrismSubmitArgs): string[] { input.plannerThreadId, "--t3-server-url", input.serverUrl, - ...(input.lane ? ["--lane", input.lane] : []), + ...(input.lane ? ["--lane", ROUTER_LANES[input.lane]] : []), ...(input.handoffSummary ? ["--handoff-summary", input.handoffSummary] : []), "--start", ]; diff --git a/apps/server/src/mcp/toolkits/prism/tools.ts b/apps/server/src/mcp/toolkits/prism/tools.ts index b9e01ce78a..c7b5670ffc 100644 --- a/apps/server/src/mcp/toolkits/prism/tools.ts +++ b/apps/server/src/mcp/toolkits/prism/tools.ts @@ -1,4 +1,4 @@ -import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { PrismLane, TrimmedNonEmptyString } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import * as Tool from "effect/unstable/ai/Tool"; import * as Toolkit from "effect/unstable/ai/Toolkit"; @@ -31,9 +31,9 @@ export const PrismSubmitInput = Schema.Struct({ }), ), lane: Schema.optional( - Schema.Literals(["default", "small", "hard"]).annotate({ + PrismLane.annotate({ description: - "Routing lane: small for mechanical work, hard for difficult work, default otherwise.", + "How difficult the job is: easy for mechanical work, hard for difficult work, medium (default) otherwise. Selects each role's model list for that lane.", }), ), requestId: Schema.optional( diff --git a/apps/server/src/mcp/toolkits/threads/handlers.ts b/apps/server/src/mcp/toolkits/threads/handlers.ts index 743baff316..4bfb9e0b41 100644 --- a/apps/server/src/mcp/toolkits/threads/handlers.ts +++ b/apps/server/src/mcp/toolkits/threads/handlers.ts @@ -6,6 +6,7 @@ import { ThreadId, type OrchestrationEvent, type OrchestrationSession, + DEFAULT_PRISM_LANE, type OrchestrationThreadShell, type PrismRoleKits, type ProviderOptionSelection, @@ -421,11 +422,13 @@ const make = Effect.gen(function* () { Effect.gen(function* () { const { caller: parent, kits } = yield* authorizedCaller("spawn_thread", "children"); const kit = input.role ? kits[input.role] : undefined; - // A role's preferred model applies only when the caller names none. + const lane = input.lane ?? DEFAULT_PRISM_LANE; + const laneModels = kit?.lanes[lane] ?? []; + // A role's lane list applies only when the caller names no model. let preferred: { instanceId: string; model: string; effort?: string } | undefined; - if (kit && !input.model && !input.instanceId && kit.models.length > 0) { + if (kit && !input.model && !input.instanceId && laneModels.length > 0) { const nowMs = yield* Clock.currentTimeMillis; - const picked = pickRoleModel(kit.models, yield* registry.getProviders, nowMs); + const picked = pickRoleModel(laneModels, yield* registry.getProviders, nowMs); if ("refusal" in picked) return yield* fail(picked.refusal); preferred = picked.pick; } @@ -484,7 +487,7 @@ const make = Effect.gen(function* () { yield* startTurn(child, kit ? roleTaskMessage(kit, input.task) : input.task); return { threadId: childId, - ...(input.role ? { role: input.role } : {}), + ...(input.role ? { role: input.role, lane } : {}), parentThreadId: parent.id, instanceId, model, diff --git a/apps/server/src/mcp/toolkits/threads/roles.test.ts b/apps/server/src/mcp/toolkits/threads/roles.test.ts index 7ca8797f3b..faedbec83a 100644 --- a/apps/server/src/mcp/toolkits/threads/roles.test.ts +++ b/apps/server/src/mcp/toolkits/threads/roles.test.ts @@ -127,11 +127,13 @@ describe("role model eligibility", () => { ); expect(result).toEqual({ refusal: - "No eligible model for this role: opencode/opencode/muse (instance disabled), opencode/opencode/other (instance disabled).", + "No eligible model for this role and lane: opencode/opencode/muse (instance disabled), opencode/opencode/other (instance disabled).", }); expect( pickRoleModel([{ instanceId: "opencode", model: "x" } as never], [provider()], now), - ).toEqual({ refusal: "No eligible model for this role: opencode/x (model not offered)." }); + ).toEqual({ + refusal: "No eligible model for this role and lane: opencode/x (model not offered).", + }); }); it("blocks an instance at 100 % until its window resets", () => { @@ -146,6 +148,32 @@ describe("role model eligibility", () => { expect("refusal" in pickRoleModel([muse], [exhausted], now)).toBe(true); }); + it("falls back down a lane list, with one model at two efforts as separate entries", () => { + const opusMedium = { + instanceId: "claudeAgent", + model: "claude-opus-5-5", + effort: "medium", + } as never; + const opusXhigh = { ...(opusMedium as object), effort: "xhigh" } as never; + const claude = provider({ + instanceId: "claudeAgent" as never, + driver: "claudeAgent" as never, + models: [{ slug: "claude-opus-5-5", name: "Opus", isCustom: false, capabilities: null }], + usageLimits: { + checkedAt: "2026-09-25T11:59:00.000Z", + windows: [window(100, "2026-09-25T13:00:00.000Z")], + }, + }); + expect(pickRoleModel([opusXhigh, opusMedium], [claude], now)).toEqual({ + refusal: + "No eligible model for this role and lane: claudeAgent/claude-opus-5-5 (usage limit reached), claudeAgent/claude-opus-5-5 (usage limit reached).", + }); + expect(pickRoleModel([opusXhigh, muse], [claude, provider()], now)).toEqual({ pick: muse }); + expect( + pickRoleModel([opusXhigh, opusMedium], [{ ...claude, usageLimits: undefined }], now), + ).toEqual({ pick: opusXhigh }); + }); + it("blocks a full window without resetsAt until the next reading, not below 100 %", () => { const noReset = provider({ usageLimits: { checkedAt: "2026-09-25T11:59:00.000Z", windows: [window(100)] }, diff --git a/apps/server/src/mcp/toolkits/threads/roles.ts b/apps/server/src/mcp/toolkits/threads/roles.ts index 98a0925c4f..bd519d37b5 100644 --- a/apps/server/src/mcp/toolkits/threads/roles.ts +++ b/apps/server/src/mcp/toolkits/threads/roles.ts @@ -94,8 +94,9 @@ function offersModel(provider: ServerProvider, model: string): boolean { } /** - * The first of a role's preferred models that is enabled in Providers and - * not blocked by a usage limit, or the reason none is. + * The first entry of a role's lane list whose instance is enabled in + * Providers, offers the model and is not blocked by a usage limit, or the + * reason none is. Later entries are the fallbacks. */ export function pickRoleModel( preferences: ReadonlyArray, @@ -116,7 +117,7 @@ export function pickRoleModel( return { pick: preference }; } } - return { refusal: `No eligible model for this role: ${skipped.join(", ")}.` }; + return { refusal: `No eligible model for this role and lane: ${skipped.join(", ")}.` }; } /** The child's first message: the role's instructions and skills, then the task. */ diff --git a/apps/server/src/mcp/toolkits/threads/tools.ts b/apps/server/src/mcp/toolkits/threads/tools.ts index 0878eae8f0..c4321872eb 100644 --- a/apps/server/src/mcp/toolkits/threads/tools.ts +++ b/apps/server/src/mcp/toolkits/threads/tools.ts @@ -1,4 +1,4 @@ -import { PrismRole, RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { PrismLane, PrismRole, RuntimeMode, TrimmedNonEmptyString } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Tool from "effect/unstable/ai/Tool"; @@ -34,7 +34,13 @@ export const SpawnThreadInput = Schema.Struct({ role: Schema.optional( PrismRole.annotate({ description: - "Prism role for the child: dispatcher, reviewer, worker, correction, recovery (or planner). Applies that role's kit from Prism settings: its instructions, skills, permissions, thread-tool scope, and its first eligible preferred model unless model is named.", + "Prism role for the child: dispatcher, reviewer, worker, correction, recovery (or planner). Applies that role's kit from Prism settings: its instructions, skills, permissions, thread-tool scope, and the first eligible model of its lane list unless model is named.", + }), + ), + lane: Schema.optional( + PrismLane.annotate({ + description: + "With role: which of the role's model lists to use, easy, medium (default) or hard, by how difficult the task is. Later entries in the list are fallbacks.", }), ), instanceId: Schema.optional( @@ -46,7 +52,7 @@ export const SpawnThreadInput = Schema.Struct({ model: Schema.optional( TrimmedNonEmptyString.annotate({ description: - "Model id on that instance. Defaults to the role's first eligible preferred model, else this thread's model.", + "Model id on that instance. Defaults to the first eligible model of the role's lane, else this thread's model.", }), ), effort: Schema.optional( @@ -68,6 +74,7 @@ export const SpawnThreadInput = Schema.Struct({ export const SpawnThreadResult = Schema.Struct({ threadId: Schema.String, role: Schema.optional(PrismRole), + lane: Schema.optional(PrismLane), parentThreadId: Schema.String, instanceId: Schema.String, model: Schema.String, diff --git a/apps/server/src/prism/snapshotRoute.test.ts b/apps/server/src/prism/snapshotRoute.test.ts index 0c478f6725..9b523615de 100644 --- a/apps/server/src/prism/snapshotRoute.test.ts +++ b/apps/server/src/prism/snapshotRoute.test.ts @@ -35,8 +35,10 @@ const provider = { describe("Prism provider snapshot", () => { const luna = [{ instanceId: "codex", model: "gpt-5.6-luna" }]; const settings = decodeServerSettings({ - prismRoles: { worker: { models: luna } }, - projectSettingsOverrides: { p1: { prismRoles: { reviewer: { models: luna } } } }, + prismRoles: { worker: { lanes: { medium: luna, hard: luna } } }, + projectSettingsOverrides: { + p1: { prismRoles: { reviewer: { lanes: { easy: luna } } } }, + }, }); it("carries models and usage windows with their reset times", () => { @@ -56,7 +58,7 @@ describe("Prism provider snapshot", () => { usageLimits: provider.usageLimits, }, ]); - expect(snapshot.roles.worker.models).toEqual(luna); + expect(snapshot.roles.worker.lanes).toEqual({ easy: [], medium: luna, hard: luna }); }); it("resolves role kits for a project over the environment", () => { @@ -66,8 +68,8 @@ describe("Prism provider snapshot", () => { providers: [], settings, }); - expect(snapshot.roles.reviewer.models).toEqual(luna); - expect(snapshot.roles.worker.models).toEqual([]); + expect(snapshot.roles.reviewer.lanes.easy).toEqual(luna); + expect(snapshot.roles.worker.lanes.medium).toEqual([]); }); it("reports an unavailable instance as disabled", () => { diff --git a/packages/contracts/src/prism.ts b/packages/contracts/src/prism.ts index 8088367366..1f9468b400 100644 --- a/packages/contracts/src/prism.ts +++ b/packages/contracts/src/prism.ts @@ -50,7 +50,7 @@ const PRISM_DEFAULT_THREAD_TOOL_SCOPES: Record }; /** Work difficulty a job or spawn runs at; each role keeps one model list per lane. */ -export const PRISM_LANES = ["easy", "medium", "hard"] as const; +const PRISM_LANES = ["easy", "medium", "hard"] as const; export const PrismLane = Schema.Literals(PRISM_LANES); export type PrismLane = typeof PrismLane.Type; export const DEFAULT_PRISM_LANE: PrismLane = "medium";