Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/mobile/src/features/threads/thread-settings-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
broadestRuntimeModeWithin,
managedRuntimeModeDenial,
type ManagedPolicySummary,
type RuntimeMode,
Expand Down Expand Up @@ -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";
4 changes: 2 additions & 2 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CommandId,
type RunId,
isProviderAvailable,
isRuntimeModeAbove,
MessageId,
type ModelSelection,
NodeId,
Expand Down Expand Up @@ -122,7 +123,6 @@ import {
interactionModeRank,
resolveInteractionMode,
resolveRuntimeMode,
runtimeModeRank,
taskPrompt,
} from "./delegatedTaskModes.ts";
import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts";
Expand Down Expand Up @@ -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);

/**
Expand Down
41 changes: 12 additions & 29 deletions apps/server/src/mcp/delegatedTaskModes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
broadestRuntimeModeWithin,
isRuntimeModeAbove,
type ModelSelection,
OrchestratorMcpFailure,
type OrchestratorMcpDelegateTaskInput,
Expand All @@ -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;
Expand All @@ -43,7 +31,7 @@ export function resolveRuntimeMode(
): Effect.Effect<RuntimeMode, OrchestratorMcpFailure> {
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",
Expand All @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/project/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ThreadLaunch.ThreadLaunchInput> = [];
Expand Down
44 changes: 30 additions & 14 deletions apps/server/src/orchestration-v2/RuntimePolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderInstanceId, ReadonlyArray<RuntimeMode>>([
[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) =>
({
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 3 additions & 4 deletions apps/server/src/orchestration-v2/RuntimePolicy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
broadestRuntimeModeWithin,
isRuntimeModeAbove,
managedRuntimeModeCeilingMessage,
ModelSelection,
Expand Down Expand Up @@ -92,11 +93,9 @@ function providerRuntimeMode(
runtimeMode: RuntimeMode,
supportedRuntimeModes: ReadonlyArray<RuntimeMode> | 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 = (
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/AntigravityProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/provider/AntigravityProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/runtimeModeConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
broadestRuntimeModeWithin,
managedRuntimeModeDenial,
type ManagedPolicySummary,
type RuntimeMode,
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/internals/adding-a-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 11 additions & 9 deletions docs/user/permission-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion docs/user/providers-fx.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/user/providers-pi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/contracts/src/providerPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ const RUNTIME_MODE_RANK: Record<RuntimeMode, number> = {
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>,
): RuntimeMode | undefined =>
supported.reduce<RuntimeMode | undefined>(
(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";
Expand Down
Loading