Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/model-roles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Add model roles: lock a model alias to the small, implementer, or advisor slot with `/model <role>`, list assignments with `/model roles`, and reference roles as `@small`, `@implementer`, or `@advisor` wherever a subagent model can be set; an assigned implementer role becomes the default model for subagents.
60 changes: 58 additions & 2 deletions apps/pythinker-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ import {
openFileInExternalEditor,
resolveEditorCommand,
} from '#/utils/process/external-editor';
import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui';
import {
BUILT_IN_MODEL_ROLES,
LLM_NOT_SET_MESSAGE,
NO_ACTIVE_SESSION_MESSAGE,
} from '#/tui/constant/pythinker-tui';
import { formatErrorMessage } from '../utils/event-payload';
import { showUsage } from './info';
import { setExperimentalFeatures } from './experimental-flags';
Expand Down Expand Up @@ -480,6 +484,41 @@ function resolveWorkspaceConfigPath(input: string, workDir: string): string {

export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
const requestedAlias = args.trim();
const tokens = requestedAlias.split(/\s+/u).filter(Boolean);
const config = await host.harness.getConfig({ reload: true });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const roles = [...new Set([...BUILT_IN_MODEL_ROLES, ...Object.keys(config.modelRoles ?? {})])]
.filter((role) => role.length > 0 && role !== 'default');

if (tokens.length === 1 && tokens[0] === 'roles') {
host.showNotice(
'Model roles',
roles
.map((role) => `${role}: ${config.modelRoles?.[role]?.trim() || '(not set)'}`)
.join('\n'),
);
return;
}

const role = tokens[0];
if (role !== undefined && roles.includes(role)) {
if (tokens.length === 2 && (tokens[1] === 'clear' || tokens[1] === 'none')) {
await host.harness.setConfig({ modelRoles: { [role]: '' } });
host.showStatus(`Cleared the ${role} model role.`, 'success');
return;
}
if (tokens.length === 1) {
const picker = showModelPicker(host, config.modelRoles?.[role], undefined, {
assignToRole: role,
});
if (picker !== undefined) {
void refreshModelsForOpenPicker(host, picker, config.modelRoles?.[role], {
assignToRole: role,
});
}
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

const normalized = normalizeModelChoices(host.state.appState.availableModels);
const selectedValue =
requestedAlias.length === 0
Expand Down Expand Up @@ -524,6 +563,7 @@ async function refreshModelsForOpenPicker(
host: SlashCommandHost,
picker: TabbedModelSelectorComponent,
selectedValue: string | undefined,
options?: { assignToRole?: string },
): Promise<void> {
const availableModels = host.state.appState.availableModels;
const normalized = normalizeModelChoices(availableModels);
Expand Down Expand Up @@ -574,7 +614,7 @@ async function refreshModelsForOpenPicker(
}
}

showModelPicker(host, refreshedSelected, activeTabId);
showModelPicker(host, refreshedSelected, activeTabId, options);
}

async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> {
Expand Down Expand Up @@ -615,6 +655,7 @@ export function showModelPicker(
host: SlashCommandHost,
selectedValue?: string,
initialTabId?: string,
options?: { assignToRole?: string },
): TabbedModelSelectorComponent | undefined {
const normalized = normalizeModelChoices(host.state.appState.availableModels);
const entries = Object.entries(normalized.models);
Expand Down Expand Up @@ -646,6 +687,10 @@ export function showModelPicker(
initialTabId,
onSelect: ({ alias, effort }) => {
host.restoreEditor();
if (options?.assignToRole !== undefined) {
void assignModelRole(host, options.assignToRole, alias);
return;
}
void performModelSwitch(host, alias, effort);
},
onCancel: () => {
Expand All @@ -656,6 +701,17 @@ export function showModelPicker(
return picker;
}

async function assignModelRole(host: SlashCommandHost, role: string, alias: string): Promise<void> {
// Model roles store aliases only; thinking effort stays with the active model.
try {
await host.harness.setConfig({ modelRoles: { [role]: alias } });
} catch (error) {
host.showError(`Failed to lock the ${role} model: ${formatErrorMessage(error)}`);
return;
}
host.showStatus(`Locked ${alias} as the ${role} model.`, 'success');
}

async function performModelSwitch(host: SlashCommandHost, alias: string, effort: string): Promise<void> {
if (host.state.appState.streamingPhase !== 'idle') {
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');
Expand Down
2 changes: 1 addition & 1 deletion apps/pythinker-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export const BUILTIN_SLASH_COMMANDS = [
{
name: 'model',
aliases: [],
description: 'Switch LLM model',
description: 'Switch model; assign with /model <role>, clear it, or list /model roles',
priority: 100,
availability: 'always',
},
Expand Down
4 changes: 3 additions & 1 deletion apps/pythinker-code/src/tui/constant/pythinker-tui.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export { OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app';

/** Canonical model roles offered by `/model <role>`, mirroring agent-core's list across the SDK package boundary. */
export const BUILT_IN_MODEL_ROLES = ['small', 'implementer', 'advisor'] as const;

export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.';
export const CTRL_D_HINT = 'Press Ctrl+D again to exit';
Expand All @@ -8,4 +11,3 @@ export const MAIN_AGENT_ID = 'main';
export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.';
export const EXIT_CONFIRM_WINDOW_MS = 1500;
export const MCP_STATUS_TRANSIENT_DURATION_MS = 750;

161 changes: 161 additions & 0 deletions apps/pythinker-code/test/tui/commands/model-roles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from 'vitest';

import { handleModelCommand } from '#/tui/commands/index';
import type { SlashCommandHost } from '#/tui/commands/dispatch';

const ENTER = '\r';

interface TestPicker {
handleInput(data: string): void;
}

function model(name: string) {
return {
provider: 'test',
model: name,
maxContextSize: 200_000,
displayName: name,
capabilities: [],
};
}

function makeHost(options: {
currentModel?: string;
availableModels?: Record<string, ReturnType<typeof model>>;
modelRoles?: Record<string, string>;
setConfig?: ReturnType<typeof vi.fn>;
} = {}) {
const session = {
setModel: vi.fn(async () => {}),
setThinking: vi.fn(async () => {}),
};
const getConfig = vi.fn(async () => ({
providers: {},
modelRoles: options.modelRoles,
}));
const setConfig = options.setConfig ?? vi.fn(async () => {});
const host = {
state: {
appState: {
model: options.currentModel ?? 'worker',
thinkingLevel: 'off',
streamingPhase: 'idle',
availableModels: options.availableModels ?? { worker: model('worker') },
},
editorContainer: { children: [] },
},
session,
harness: { getConfig, setConfig },
authFlow: {
refreshProviderModels: vi.fn(async () => ({ failed: [] })),
},
setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)),
showError: vi.fn(),
showStatus: vi.fn(),
showNotice: vi.fn(),
mountEditorReplacement: vi.fn(),
restoreEditor: vi.fn(),
track: vi.fn(),
} as unknown as SlashCommandHost;
return { host, session, setConfig };
}

function mountedPicker(host: SlashCommandHost, index = 0): TestPicker {
const mount = host.mountEditorReplacement as ReturnType<typeof vi.fn>;
return mount.mock.calls[index]?.[0] as TestPicker;
}

describe('/model roles', () => {
it('lists every built-in role as not set when no assignments exist', async () => {
const { host } = makeHost();

await handleModelCommand(host, 'roles');

expect(host.showNotice).toHaveBeenCalledWith(
'Model roles',
'small: (not set)\nimplementer: (not set)\nadvisor: (not set)',
);
});

it('locks a selected alias to a role without switching the session model', async () => {
const { host, session, setConfig } = makeHost();

await handleModelCommand(host, 'small');
expect(host.authFlow.refreshProviderModels).toHaveBeenCalledOnce();
mountedPicker(host).handleInput(ENTER);

await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } });
});
expect(session.setModel).not.toHaveBeenCalled();
});

it('keeps role assignment active after the picker refreshes', async () => {
const { host, session, setConfig } = makeHost({
currentModel: 'parent',
availableModels: {
parent: model('parent'),
worker: model('worker'),
},
modelRoles: { small: 'worker' },
});
vi.mocked(host.mountEditorReplacement).mockImplementation((picker) => {
host.state.editorContainer.children[0] = picker;
});
vi.mocked(host.authFlow.refreshProviderModels).mockImplementation(async () => {
host.state.appState.availableModels['reviewer'] = model('reviewer');
return { changed: [], unchanged: [], failed: [] };
});

await handleModelCommand(host, 'small');
await vi.waitFor(() => {
expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2);
});
mountedPicker(host, 1).handleInput(ENTER);

await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } });
});
expect(session.setModel).not.toHaveBeenCalled();
});

it('reports a role persistence failure without showing success', async () => {
const setConfig = vi.fn(async () => {
throw new Error('disk full');
});
const { host } = makeHost({ setConfig });

await handleModelCommand(host, 'small');
mountedPicker(host).handleInput(ENTER);

await vi.waitFor(() => {
expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('disk full'));
});
expect(host.showStatus).not.toHaveBeenCalled();
});

it('clears a role with an empty-string tombstone', async () => {
const { host, setConfig } = makeHost({ modelRoles: { small: 'worker' } });

await handleModelCommand(host, 'small clear');

expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: '' } });
});

it('keeps an existing model alias on the default switch path', async () => {
const { host, session } = makeHost({
currentModel: 'parent',
availableModels: {
parent: model('parent'),
worker: model('worker'),
},
});

await handleModelCommand(host, 'worker');
mountedPicker(host).handleInput(ENTER);

await vi.waitFor(() => {
expect(session.setModel).toHaveBeenCalledWith('worker');
});
});
});
21 changes: 20 additions & 1 deletion docs/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `default_model` | `string` | — | Default model alias; must be defined in `models` |
| `model_roles` | `table` | — | Model role assignments → [`model_roles`](#model_roles) |
| `default_thinking` | `boolean` | `false` | Whether new sessions enable Thinking (deep reasoning) mode by default; can be toggled from the model menu inside a session. Even when set to `true`, `[thinking].mode = "off"` will still force Thinking off |
| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking, except a `DynamicWorkflow` call, which still shows its plan for approval) |
| `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default |
Expand All @@ -94,7 +95,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |

The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `experimental`, `services`, and `permission`.
The following sections cover each of the nested tables in turn: `providers`, `models`, `model_roles`, `thinking`, `loop_control`, `background`, `experimental`, `services`, and `permission`.

## `providers`

Expand Down Expand Up @@ -155,6 +156,24 @@ max_context_size = 1047576

You can also switch models temporarily without touching the config file — by setting `PYTHINKER_MODEL_*` environment variables, the CLI synthesizes a temporary provider in memory that does not persist after restart. See [Define a model from environment variables](./env-vars.md#define-a-model-from-environment-variables-pythinker_model).

## `model_roles`

Each entry in the `model_roles` table locks a model alias to a named role. The built-in roles are `small`, `implementer`, and `advisor`; any other key except the reserved `default` defines a custom role. Values must be aliases defined in `models`; an empty string clears the role.

```toml
[model_roles]
small = "haiku"
implementer = "worker-model"
advisor = "reviewer-model"
```

Roles take effect in two places:

- Wherever a subagent model alias is accepted (the `Agent` and `DynamicWorkflow` tool `model` arguments, and agent profile frontmatter), a `@<role>` reference such as `@small` resolves to the locked alias. An unassigned or unresolvable role falls back to the parent agent's model.
- When `implementer` is assigned, it becomes the default model for subagents that do not set an explicit or profile model. Subagents of those subagents inherit the same default.

Inside the TUI, `/model <role>` assigns a role from the model picker, `/model <role> clear` (or `/model <role> none`) removes it, and `/model roles` lists the current assignments. See [Slash commands](../reference/slash-commands.md).

## `thinking`

`thinking` sets the global default behavior for Thinking mode. `mode = "off"` forces Thinking off even when the top-level `default_thinking = true`.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Some commands are only available in the idle state. Executing these commands whi
| `/login` | — | Select an account or platform and log in: Pythinker Code uses OAuth device-code flow; Pythinker Platform uses API key login | No |
| `/logout` | — | Clear credentials for the currently selected account | No |
| `/provider` | — | Open the interactive provider manager to view, add, and remove configured providers. See [Platforms & Models — `/provider` and provider management](../configuration/providers.md#provider-interactive-provider-management) | Yes |
| `/model` | — | Switch the LLM model used in the current session | Yes |
| `/model` | — | Switch the LLM model used in the current session. `/model <role>` locks a model alias to a model role (`small`, `implementer`, or `advisor`), `/model <role> clear` (or `/model <role> none`) removes the lock, and `/model roles` lists the current assignments. See [Model roles](../configuration/config-files.md#model_roles) | Yes |
| `/settings` | `/config` | Open the settings panel inside the TUI | Yes |
| `/experiments` | `/experimental` | Open the experimental feature panel | Yes |
| `/permission` | — | Select a permission mode | Yes |
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './merge';
export * from './model-roles';
export * from './path';
export * from './resolve';
export * from './schema';
Expand Down
26 changes: 26 additions & 0 deletions packages/agent-core/src/config/model-roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Built-in model roles a user can lock a model alias to. */
export const BUILT_IN_MODEL_ROLES = ['small', 'implementer', 'advisor'] as const;
export type BuiltInModelRole = (typeof BUILT_IN_MODEL_ROLES)[number];

interface ModelRoleSource {
modelRoles?: Record<string, string>;
defaultModel?: string;
}

/** Resolve a role name to its locked model alias. Empty string means cleared. */
export function resolveModelRoleAlias(
config: ModelRoleSource | undefined,
role: string,
): string | undefined {
if (role === 'default') return config?.defaultModel;
const alias = config?.modelRoles?.[role]?.trim();
return alias === '' ? undefined : alias;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/** Expand a "@role" model reference; non-@ strings pass through unchanged. */
export function expandModelRef(
config: ModelRoleSource | undefined,
ref: string,
): string | undefined {
return ref.startsWith('@') ? resolveModelRoleAlias(config, ref.slice(1)) : ref;
}
Loading
Loading