diff --git a/apps/mobile/src/features/threads/thread-settings-options.ts b/apps/mobile/src/features/threads/thread-settings-options.ts index 9f9d4f32b7d52..b660ecc78b0f7 100644 --- a/apps/mobile/src/features/threads/thread-settings-options.ts +++ b/apps/mobile/src/features/threads/thread-settings-options.ts @@ -1,4 +1,5 @@ import { + broadestRuntimeModeWithin, managedRuntimeModeDenial, type ManagedPolicySummary, type RuntimeMode, @@ -78,9 +79,14 @@ export function compatibleRuntimeModeForChoices( runtimeMode: RuntimeMode, choices: ReadonlyArray<{ readonly mode: RuntimeMode }>, ): RuntimeMode { - return choices.some((choice) => choice.mode === runtimeMode) - ? runtimeMode - : (choices[0]?.mode ?? runtimeMode); + return ( + broadestRuntimeModeWithin( + runtimeMode, + choices.map((choice) => choice.mode), + ) ?? + choices[0]?.mode ?? + runtimeMode + ); } export { selectableProviderOptionChoices as selectableChoices } from "@t3tools/client-runtime/model-traits"; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 066722ea6625f..4a1992fbfabea 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2,6 +2,7 @@ import { CommandId, type RunId, isProviderAvailable, + isRuntimeModeAbove, MessageId, type ModelSelection, NodeId, @@ -122,7 +123,6 @@ import { interactionModeRank, resolveInteractionMode, resolveRuntimeMode, - runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; @@ -1892,7 +1892,7 @@ const make = Effect.gen(function* () { }, modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, ) => - runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) && + !isRuntimeModeAbove(modes.runtimeMode, limits.runtimeMode) && interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode); /** diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 54d25bbb36e02..da0410f92c1d0 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -1,4 +1,6 @@ import { + broadestRuntimeModeWithin, + isRuntimeModeAbove, type ModelSelection, OrchestratorMcpFailure, type OrchestratorMcpDelegateTaskInput, @@ -9,20 +11,6 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -/** How much a runtime mode allows, narrowest first. */ -export function runtimeModeRank(mode: RuntimeMode): number { - switch (mode) { - case "approval-required": - return 0; - case "auto-accept-edits": - return 1; - case "auto": - return 2; - case "full-access": - return 3; - } -} - /** How much an interaction mode allows: plan, then default. */ export function interactionModeRank(mode: ProviderInteractionMode): number { return mode === "plan" ? 0 : 1; @@ -43,7 +31,7 @@ export function resolveRuntimeMode( ): Effect.Effect { const inherited = requested === undefined || requested === "inherit"; const resolved = inherited ? parentMode : requested; - if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) { + if (isRuntimeModeAbove(resolved, parentMode)) { return Effect.fail( new OrchestratorMcpFailure({ code: "runtime_mode_escalation_denied", @@ -55,20 +43,15 @@ export function resolveRuntimeMode( if (supported === undefined || supported.length === 0 || supported.includes(resolved)) { return Effect.succeed(resolved); } - if (!inherited) { - return Effect.fail( - new OrchestratorMcpFailure({ - code: "invalid_request", - message: `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as approval-required. Use one of: ${supported.join(", ")}.`, - }), - ); - } - return Effect.succeed( - supported - .filter((mode) => runtimeModeRank(mode) <= runtimeModeRank(resolved)) - .toSorted((left, right) => runtimeModeRank(right) - runtimeModeRank(left))[0] ?? - "approval-required", - ); + const steppedDown = broadestRuntimeModeWithin(resolved, supported) ?? "approval-required"; + return inherited + ? Effect.succeed(steppedDown) + : Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as ${steppedDown}. Use one of: ${supported.join(", ")}.`, + }), + ); } /** The same, for interaction modes. */ diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b77df3ac2075e..425ea5126acfc 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -278,7 +278,7 @@ it.effect( it.effect("a launch steps an inherited mode down to the provider's, and refuses one it lacks", () => Effect.gen(function* () { - // Pi offers no Auto; a mode a provider lacks would run as Supervised. + // Pi offers no Auto, so an inherited Auto steps down to Auto-accept edits. const pi = ProviderInstanceId.make("pi"); const launchPi = Effect.fn("test.launchPi")(function* (runtimeMode?: RuntimeMode) { const launched: Array = []; diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index ec610b093f7d3..d8b853cae51f1 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -65,9 +65,11 @@ function makeThread(input: { const grokInstanceId = ProviderInstanceId.make("grok"); const antigravityInstanceId = ProviderInstanceId.make("antigravity"); +const piInstanceId = ProviderInstanceId.make("pi"); const supportedRuntimeModesByInstance = new Map>([ [grokInstanceId, ["approval-required", "auto", "full-access"]], [antigravityInstanceId, ["approval-required", "auto-accept-edits", "full-access"]], + [piInstanceId, ["approval-required", "auto-accept-edits", "full-access"]], ]); const providerInstanceFor = (instanceId: ProviderInstanceId) => ({ @@ -131,31 +133,45 @@ it.layer(layerTest)("RuntimePolicyV2", (it) => { options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], }; const auto = yield* resolve("auto"); - assert.equal(auto.runtimeMode, "approval-required"); + assert.equal(auto.runtimeMode, "auto-accept-edits"); assert.equal(acpPermissionDisposition(auto, request), "ask"); assert.equal(acpPermissionDisposition(yield* resolve("auto-accept-edits"), request), "ask"); assert.equal(acpPermissionDisposition(yield* resolve("full-access"), request), "allow"); }), ); - it.effect("runs a mode the provider does not offer in Supervised", () => + it.effect("runs a stored Auto Pi thread in Auto-accept edits, not Supervised", () => Effect.gen(function* () { const policy = yield* RuntimePolicy.RuntimePolicyV2; const now = yield* DateTime.now; - const modeFor = (instanceId: ProviderInstanceId, runtimeMode: RuntimeMode) => - policy - .resolve({ - thread: makeThread({ now, worktreePath: null, runtimeMode }), - modelSelection: { instanceId, model: "test-model" }, - }) - .pipe(Effect.map((resolved) => resolved.runtimeMode)); - - assert.equal(yield* modeFor(grokInstanceId, "auto-accept-edits"), "approval-required"); - assert.equal(yield* modeFor(grokInstanceId, "auto"), "auto"); - assert.equal(yield* modeFor(grokInstanceId, "full-access"), "full-access"); - assert.equal(yield* modeFor(providerInstanceId, "auto-accept-edits"), "auto-accept-edits"); + const resolved = yield* policy.resolve({ + thread: makeThread({ now, worktreePath: null, runtimeMode: "auto" }), + modelSelection: { instanceId: piInstanceId, model: "default" }, + }); + assert.equal(resolved.runtimeMode, "auto-accept-edits"); }), ); + + it.effect( + "runs Supervised when the provider offers nothing between it and the requested mode", + () => + Effect.gen(function* () { + const policy = yield* RuntimePolicy.RuntimePolicyV2; + const now = yield* DateTime.now; + const modeFor = (instanceId: ProviderInstanceId, runtimeMode: RuntimeMode) => + policy + .resolve({ + thread: makeThread({ now, worktreePath: null, runtimeMode }), + modelSelection: { instanceId, model: "test-model" }, + }) + .pipe(Effect.map((resolved) => resolved.runtimeMode)); + + assert.equal(yield* modeFor(grokInstanceId, "auto-accept-edits"), "approval-required"); + assert.equal(yield* modeFor(grokInstanceId, "auto"), "auto"); + assert.equal(yield* modeFor(grokInstanceId, "full-access"), "full-access"); + assert.equal(yield* modeFor(providerInstanceId, "auto-accept-edits"), "auto-accept-edits"); + }), + ); }); it.layer( diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index f0d0ae38577d1..34ed3aa45cecc 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -1,4 +1,5 @@ import { + broadestRuntimeModeWithin, isRuntimeModeAbove, managedRuntimeModeCeilingMessage, ModelSelection, @@ -92,11 +93,9 @@ function providerRuntimeMode( runtimeMode: RuntimeMode, supportedRuntimeModes: ReadonlyArray | undefined, ): RuntimeMode { - return supportedRuntimeModes === undefined || - supportedRuntimeModes.length === 0 || - supportedRuntimeModes.includes(runtimeMode) + return supportedRuntimeModes === undefined || supportedRuntimeModes.length === 0 ? runtimeMode - : "approval-required"; + : (broadestRuntimeModeWithin(runtimeMode, supportedRuntimeModes) ?? "approval-required"); } const refuseAboveCeiling = ( diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 791ed748de2ef..76152e09431c8 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -348,7 +348,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("does not offer Auto, so Auto threads fall back to Supervised", () => + it.effect("does not offer Auto, so Auto threads step down to Auto-accept edits", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index ed047331e73ee..37222310ed887 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -185,7 +185,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func presentation: { displayName: "Antigravity", showInteractionModeToggle: false, - // No automatic review, so Auto falls back to Supervised instead of approving everything. + // No automatic review, so Auto steps down to Auto-accept edits instead of approving everything. supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], }, enabled: settings.enabled, diff --git a/apps/web/src/components/chat/runtimeModeConfig.ts b/apps/web/src/components/chat/runtimeModeConfig.ts index 3702641e816f9..a9807f9fbaff1 100644 --- a/apps/web/src/components/chat/runtimeModeConfig.ts +++ b/apps/web/src/components/chat/runtimeModeConfig.ts @@ -1,4 +1,5 @@ import { + broadestRuntimeModeWithin, managedRuntimeModeDenial, type ManagedPolicySummary, type RuntimeMode, @@ -49,7 +50,7 @@ export function resolveCompatibleRuntimeMode( supportedRuntimeModes && supportedRuntimeModes.length > 0 ? runtimeModeOptions.filter((mode) => supportedRuntimeModes.includes(mode)) : runtimeModeOptions; - return { modes, mode: modes.includes(runtimeMode) ? runtimeMode : (modes[0] ?? runtimeMode) }; + return { modes, mode: broadestRuntimeModeWithin(runtimeMode, modes) ?? modes[0] ?? runtimeMode }; } export function resolveRuntimeModeOptions( diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 821269954f8d3..3e43be7685ae0 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -29,7 +29,7 @@ capabilities, never the driver kind. `supportedRuntimeModes` in the provider presentation ([Grok](../../packages/provider-grok/src/server/status.ts) and [Pi](../../packages/provider-pi/src/server/status.ts) are examples). Do not imitate a missing mode by answering approvals in T3: T3's check is weaker than the agent's own enforcement. The - server runs an unoffered stored mode as Supervised + server runs an unoffered stored mode as the broadest offered mode below it ([`RuntimePolicy.ts`](../../apps/server/src/orchestration-v2/RuntimePolicy.ts)). - **Approval and question options.** Pass the provider's own option IDs through unchanged. Every request needs a way to decline that the provider honors. diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 796e3f316122d..989715b92ddaa 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -28,18 +28,20 @@ the parent conversation with the subagent's name, so you can respond without swi Providers enforce permissions differently. Some read-only actions can proceed in **Supervised**. **Auto** uses automatic review on Codex, Claude, Cursor, Grok, and fx; providers without an equivalent, -including OpenCode and Antigravity, fall back to asking. On Grok, commands its review blocks come -to you for approval. On fx, prompts it still sends also come to you. +including OpenCode, fall back to asking. On Grok, commands its review blocks come to you for +approval. On fx, prompts it still sends also come to you. -Muse Code offers only **Supervised** and **Full access**. A Muse thread already set to another mode -runs in **Supervised**. +A thread set to a mode its provider does not offer runs in the broadest mode the provider offers +below it. For example, **Auto** runs as **Auto-accept edits** on Pi and Antigravity, and +**Full access** runs as **Auto** on fx. -T3 Code offers no **Auto-accept edits** or **Full access** for fx. An fx thread already set to either runs in -**Supervised**. +Muse Code offers only **Supervised** and **Full access**. -Grok offers no **Auto-accept edits**. A Grok thread already set to it runs in **Supervised**. Grok -file-change approvals offer **Allow all edits this session**. Its command approvals have no -session-wide choice, because Grok would remember that command for the whole project. +T3 Code offers no **Auto-accept edits** or **Full access** for fx. + +Grok offers no **Auto-accept edits**. Grok file-change approvals offer **Allow all edits this +session**. Its command approvals have no session-wide choice, because Grok would remember that +command for the whole project. ACP Registry agents run their own tools in their own mode; T3 Code answers their approval requests by the permission mode. See [ACP Registry permissions](./providers-acp.md#permissions-and-terminals). diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 312ca7898f6c4..7167ea2c98062 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -36,7 +36,7 @@ fx offers two composer modes. | **Supervised** | fx asks before it changes files or runs commands. Read requests run without asking. | | **Auto** | fx handles routine actions itself. Anything it still asks about reaches you. | -T3 Code does not offer **Full access** or **Auto-accept edits** for fx. A thread already set to one of those runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. +T3 Code does not offer **Full access** or **Auto-accept edits** for fx. A thread already set to **Full access** runs in **Auto**, and one set to **Auto-accept edits** runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. On fx versions after [vercel-labs/fx#1189](https://github.com/vercel-labs/fx/pull/1189), fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. diff --git a/docs/user/providers-pi.md b/docs/user/providers-pi.md index e6a7b7d244023..ffb2c25fe3e8b 100644 --- a/docs/user/providers-pi.md +++ b/docs/user/providers-pi.md @@ -65,7 +65,7 @@ T3 Code applies the composer permission mode through Pi's blocking tool hook: - **Full access** allows tools without T3 Code approval prompts. The **Auto** option is not shown for Pi because Pi does not expose an AI approval reviewer. -Threads that already stored Auto before Pi support was added behave and display as Supervised. +Threads that already stored Auto before Pi support was added behave and display as Auto-accept edits. Changing the mode restarts the Pi provider session and resumes the same native conversation. The policy covers Pi tool calls; it is not an operating-system sandbox, and code that a trusted Pi diff --git a/packages/contracts/src/providerPolicy.ts b/packages/contracts/src/providerPolicy.ts index 1b94f43aabd5c..fe4f393a5b2a6 100644 --- a/packages/contracts/src/providerPolicy.ts +++ b/packages/contracts/src/providerPolicy.ts @@ -42,6 +42,20 @@ const RUNTIME_MODE_RANK: Record = { export const isRuntimeModeAbove = (mode: RuntimeMode, ceiling: RuntimeMode | undefined): boolean => ceiling !== undefined && RUNTIME_MODE_RANK[mode] > RUNTIME_MODE_RANK[ceiling]; +/** The broadest of `supported` that grants no more than `mode`; none when every one grants more. */ +export const broadestRuntimeModeWithin = ( + mode: RuntimeMode, + supported: ReadonlyArray, +): RuntimeMode | undefined => + supported.reduce( + (best, candidate) => + !isRuntimeModeAbove(candidate, mode) && + (best === undefined || isRuntimeModeAbove(candidate, best)) + ? candidate + : best, + undefined, + ); + export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";