Skip to content

Commit b71f094

Browse files
authored
feat: add model roles for small, implementer, and advisor (#56)
## Related Issue No linked issue — this implements a directly requested feature; the problem is explained below. ## Problem There is no way to designate models for specific duties. Subagents always inherit the parent session model unless each call site names an explicit alias, so a user who wants a cheap worker model for subagents, or a designated reviewer model, has to repeat concrete aliases everywhere and update them all when switching providers. ## What changed Adds a `model_roles` config map that locks a model alias to a named role, with three built-in roles: `small`, `implementer`, and `advisor` (custom role keys are also accepted). - `@<role>` references (`@small`, `@implementer`, `@advisor`, `@<custom>`) resolve through the map wherever a subagent model alias is accepted: the `Agent` and `DynamicWorkflow` tool `model` arguments and agent profile frontmatter. Unassigned or unresolvable roles fall back to the existing precedence; `model:` permission deny rules are checked against both the raw `@role` form and the resolved alias. - An assigned `implementer` role becomes the default model for subagents that set no explicit or profile model. - TUI: `/model <role>` assigns from the model picker, `/model <role> clear` unassigns, `/model roles` lists assignments. Role assignment persists through the standard config patch path; an empty string is the cleared state. - The `advisor` role is configuration-only for now; a follow-up PR adds the runtime that consumes it. This approach keeps the existing alias-based config as the single source of model identity — roles are one indirection layer over aliases, with no new selector grammar. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/Pythoughts-labs/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added configurable `small`, `implementer`, and `advisor` model roles, along with custom roles. * Assign aliases with `/model <role>`, view them with `/model roles`, and clear assignments when needed. * Reference roles with aliases such as `@small`, `@implementer`, and `@advisor`. * The assigned implementer model becomes the default for subagents, with fallback when unavailable. * **Documentation** * Documented model-role configuration, aliases, inheritance, and fallback behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4c41c24 commit b71f094

17 files changed

Lines changed: 587 additions & 18 deletions

File tree

.changeset/model-roles.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": minor
3+
---
4+
5+
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.

apps/pythinker-code/src/tui/commands/config.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ import {
4141
openFileInExternalEditor,
4242
resolveEditorCommand,
4343
} from '#/utils/process/external-editor';
44-
import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui';
44+
import {
45+
BUILT_IN_MODEL_ROLES,
46+
LLM_NOT_SET_MESSAGE,
47+
NO_ACTIVE_SESSION_MESSAGE,
48+
} from '#/tui/constant/pythinker-tui';
4549
import { formatErrorMessage } from '../utils/event-payload';
4650
import { showUsage } from './info';
4751
import { setExperimentalFeatures } from './experimental-flags';
@@ -480,6 +484,41 @@ function resolveWorkspaceConfigPath(input: string, workDir: string): string {
480484

481485
export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> {
482486
const requestedAlias = args.trim();
487+
const tokens = requestedAlias.split(/\s+/u).filter(Boolean);
488+
const config = await host.harness.getConfig({ reload: true });
489+
const roles = [...new Set([...BUILT_IN_MODEL_ROLES, ...Object.keys(config.modelRoles ?? {})])]
490+
.filter((role) => role.length > 0 && role !== 'default');
491+
492+
if (tokens.length === 1 && tokens[0] === 'roles') {
493+
host.showNotice(
494+
'Model roles',
495+
roles
496+
.map((role) => `${role}: ${config.modelRoles?.[role]?.trim() || '(not set)'}`)
497+
.join('\n'),
498+
);
499+
return;
500+
}
501+
502+
const role = tokens[0];
503+
if (role !== undefined && roles.includes(role)) {
504+
if (tokens.length === 2 && (tokens[1] === 'clear' || tokens[1] === 'none')) {
505+
await host.harness.setConfig({ modelRoles: { [role]: '' } });
506+
host.showStatus(`Cleared the ${role} model role.`, 'success');
507+
return;
508+
}
509+
if (tokens.length === 1) {
510+
const picker = showModelPicker(host, config.modelRoles?.[role], undefined, {
511+
assignToRole: role,
512+
});
513+
if (picker !== undefined) {
514+
void refreshModelsForOpenPicker(host, picker, config.modelRoles?.[role], {
515+
assignToRole: role,
516+
});
517+
}
518+
return;
519+
}
520+
}
521+
483522
const normalized = normalizeModelChoices(host.state.appState.availableModels);
484523
const selectedValue =
485524
requestedAlias.length === 0
@@ -524,6 +563,7 @@ async function refreshModelsForOpenPicker(
524563
host: SlashCommandHost,
525564
picker: TabbedModelSelectorComponent,
526565
selectedValue: string | undefined,
566+
options?: { assignToRole?: string },
527567
): Promise<void> {
528568
const availableModels = host.state.appState.availableModels;
529569
const normalized = normalizeModelChoices(availableModels);
@@ -574,7 +614,7 @@ async function refreshModelsForOpenPicker(
574614
}
575615
}
576616

577-
showModelPicker(host, refreshedSelected, activeTabId);
617+
showModelPicker(host, refreshedSelected, activeTabId, options);
578618
}
579619

580620
async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> {
@@ -615,6 +655,7 @@ export function showModelPicker(
615655
host: SlashCommandHost,
616656
selectedValue?: string,
617657
initialTabId?: string,
658+
options?: { assignToRole?: string },
618659
): TabbedModelSelectorComponent | undefined {
619660
const normalized = normalizeModelChoices(host.state.appState.availableModels);
620661
const entries = Object.entries(normalized.models);
@@ -646,6 +687,10 @@ export function showModelPicker(
646687
initialTabId,
647688
onSelect: ({ alias, effort }) => {
648689
host.restoreEditor();
690+
if (options?.assignToRole !== undefined) {
691+
void assignModelRole(host, options.assignToRole, alias);
692+
return;
693+
}
649694
void performModelSwitch(host, alias, effort);
650695
},
651696
onCancel: () => {
@@ -656,6 +701,17 @@ export function showModelPicker(
656701
return picker;
657702
}
658703

704+
async function assignModelRole(host: SlashCommandHost, role: string, alias: string): Promise<void> {
705+
// Model roles store aliases only; thinking effort stays with the active model.
706+
try {
707+
await host.harness.setConfig({ modelRoles: { [role]: alias } });
708+
} catch (error) {
709+
host.showError(`Failed to lock the ${role} model: ${formatErrorMessage(error)}`);
710+
return;
711+
}
712+
host.showStatus(`Locked ${alias} as the ${role} model.`, 'success');
713+
}
714+
659715
async function performModelSwitch(host: SlashCommandHost, alias: string, effort: string): Promise<void> {
660716
if (host.state.appState.streamingPhase !== 'idle') {
661717
host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.');

apps/pythinker-code/src/tui/commands/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export const BUILTIN_SLASH_COMMANDS = [
164164
{
165165
name: 'model',
166166
aliases: [],
167-
description: 'Switch LLM model',
167+
description: 'Switch model; assign with /model <role>, clear it, or list /model roles',
168168
priority: 100,
169169
availability: 'always',
170170
},

apps/pythinker-code/src/tui/constant/pythinker-tui.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
export { OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app';
22

3+
/** Canonical model roles offered by `/model <role>`, mirroring agent-core's list across the SDK package boundary. */
4+
export const BUILT_IN_MODEL_ROLES = ['small', 'implementer', 'advisor'] as const;
5+
36
export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login';
47
export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.';
58
export const CTRL_D_HINT = 'Press Ctrl+D again to exit';
@@ -8,4 +11,3 @@ export const MAIN_AGENT_ID = 'main';
811
export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.';
912
export const EXIT_CONFIRM_WINDOW_MS = 1500;
1013
export const MCP_STATUS_TRANSIENT_DURATION_MS = 750;
11-
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
import { handleModelCommand } from '#/tui/commands/index';
4+
import type { SlashCommandHost } from '#/tui/commands/dispatch';
5+
6+
const ENTER = '\r';
7+
8+
interface TestPicker {
9+
handleInput(data: string): void;
10+
}
11+
12+
function model(name: string) {
13+
return {
14+
provider: 'test',
15+
model: name,
16+
maxContextSize: 200_000,
17+
displayName: name,
18+
capabilities: [],
19+
};
20+
}
21+
22+
function makeHost(options: {
23+
currentModel?: string;
24+
availableModels?: Record<string, ReturnType<typeof model>>;
25+
modelRoles?: Record<string, string>;
26+
setConfig?: ReturnType<typeof vi.fn>;
27+
} = {}) {
28+
const session = {
29+
setModel: vi.fn(async () => {}),
30+
setThinking: vi.fn(async () => {}),
31+
};
32+
const getConfig = vi.fn(async () => ({
33+
providers: {},
34+
modelRoles: options.modelRoles,
35+
}));
36+
const setConfig = options.setConfig ?? vi.fn(async () => {});
37+
const host = {
38+
state: {
39+
appState: {
40+
model: options.currentModel ?? 'worker',
41+
thinkingLevel: 'off',
42+
streamingPhase: 'idle',
43+
availableModels: options.availableModels ?? { worker: model('worker') },
44+
},
45+
editorContainer: { children: [] },
46+
},
47+
session,
48+
harness: { getConfig, setConfig },
49+
authFlow: {
50+
refreshProviderModels: vi.fn(async () => ({ failed: [] })),
51+
},
52+
setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)),
53+
showError: vi.fn(),
54+
showStatus: vi.fn(),
55+
showNotice: vi.fn(),
56+
mountEditorReplacement: vi.fn(),
57+
restoreEditor: vi.fn(),
58+
track: vi.fn(),
59+
} as unknown as SlashCommandHost;
60+
return { host, session, setConfig };
61+
}
62+
63+
function mountedPicker(host: SlashCommandHost, index = 0): TestPicker {
64+
const mount = host.mountEditorReplacement as ReturnType<typeof vi.fn>;
65+
return mount.mock.calls[index]?.[0] as TestPicker;
66+
}
67+
68+
describe('/model roles', () => {
69+
it('lists every built-in role as not set when no assignments exist', async () => {
70+
const { host } = makeHost();
71+
72+
await handleModelCommand(host, 'roles');
73+
74+
expect(host.showNotice).toHaveBeenCalledWith(
75+
'Model roles',
76+
'small: (not set)\nimplementer: (not set)\nadvisor: (not set)',
77+
);
78+
});
79+
80+
it('locks a selected alias to a role without switching the session model', async () => {
81+
const { host, session, setConfig } = makeHost();
82+
83+
await handleModelCommand(host, 'small');
84+
expect(host.authFlow.refreshProviderModels).toHaveBeenCalledOnce();
85+
mountedPicker(host).handleInput(ENTER);
86+
87+
await vi.waitFor(() => {
88+
expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } });
89+
});
90+
expect(session.setModel).not.toHaveBeenCalled();
91+
});
92+
93+
it('keeps role assignment active after the picker refreshes', async () => {
94+
const { host, session, setConfig } = makeHost({
95+
currentModel: 'parent',
96+
availableModels: {
97+
parent: model('parent'),
98+
worker: model('worker'),
99+
},
100+
modelRoles: { small: 'worker' },
101+
});
102+
vi.mocked(host.mountEditorReplacement).mockImplementation((picker) => {
103+
host.state.editorContainer.children[0] = picker;
104+
});
105+
vi.mocked(host.authFlow.refreshProviderModels).mockImplementation(async () => {
106+
host.state.appState.availableModels['reviewer'] = model('reviewer');
107+
return { changed: [], unchanged: [], failed: [] };
108+
});
109+
110+
await handleModelCommand(host, 'small');
111+
await vi.waitFor(() => {
112+
expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2);
113+
});
114+
mountedPicker(host, 1).handleInput(ENTER);
115+
116+
await vi.waitFor(() => {
117+
expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } });
118+
});
119+
expect(session.setModel).not.toHaveBeenCalled();
120+
});
121+
122+
it('reports a role persistence failure without showing success', async () => {
123+
const setConfig = vi.fn(async () => {
124+
throw new Error('disk full');
125+
});
126+
const { host } = makeHost({ setConfig });
127+
128+
await handleModelCommand(host, 'small');
129+
mountedPicker(host).handleInput(ENTER);
130+
131+
await vi.waitFor(() => {
132+
expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('disk full'));
133+
});
134+
expect(host.showStatus).not.toHaveBeenCalled();
135+
});
136+
137+
it('clears a role with an empty-string tombstone', async () => {
138+
const { host, setConfig } = makeHost({ modelRoles: { small: 'worker' } });
139+
140+
await handleModelCommand(host, 'small clear');
141+
142+
expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: '' } });
143+
});
144+
145+
it('keeps an existing model alias on the default switch path', async () => {
146+
const { host, session } = makeHost({
147+
currentModel: 'parent',
148+
availableModels: {
149+
parent: model('parent'),
150+
worker: model('worker'),
151+
},
152+
});
153+
154+
await handleModelCommand(host, 'worker');
155+
mountedPicker(host).handleInput(ENTER);
156+
157+
await vi.waitFor(() => {
158+
expect(session.setModel).toHaveBeenCalledWith('worker');
159+
});
160+
});
161+
});

docs/configuration/config-files.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
7676
| Field | Type | Default | Description |
7777
| --- | --- | --- | --- |
7878
| `default_model` | `string` || Default model alias; must be defined in `models` |
79+
| `model_roles` | `table` || Model role assignments → [`model_roles`](#model_roles) |
7980
| `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 |
8081
| `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) |
8182
| `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default |
@@ -94,7 +95,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
9495
| `permission` | `table` || Initial permission rules → [`permission`](#permission) |
9596
| `hooks` | `array<table>` || Lifecycle hooks; see [Hooks](../customization/hooks.md) |
9697

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

99100
## `providers`
100101

@@ -155,6 +156,24 @@ max_context_size = 1047576
155156

156157
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).
157158

159+
## `model_roles`
160+
161+
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.
162+
163+
```toml
164+
[model_roles]
165+
small = "haiku"
166+
implementer = "worker-model"
167+
advisor = "reviewer-model"
168+
```
169+
170+
Roles take effect in two places:
171+
172+
- 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.
173+
- 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.
174+
175+
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).
176+
158177
## `thinking`
159178

160179
`thinking` sets the global default behavior for Thinking mode. `mode = "off"` forces Thinking off even when the top-level `default_thinking = true`.

docs/reference/slash-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Some commands are only available in the idle state. Executing these commands whi
1515
| `/login` || Select an account or platform and log in: Pythinker Code uses OAuth device-code flow; Pythinker Platform uses API key login | No |
1616
| `/logout` || Clear credentials for the currently selected account | No |
1717
| `/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 |
18-
| `/model` || Switch the LLM model used in the current session | Yes |
18+
| `/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 |
1919
| `/settings` | `/config` | Open the settings panel inside the TUI | Yes |
2020
| `/experiments` | `/experimental` | Open the experimental feature panel | Yes |
2121
| `/permission` || Select a permission mode | Yes |

packages/agent-core/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './merge';
2+
export * from './model-roles';
23
export * from './path';
34
export * from './resolve';
45
export * from './schema';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/** Built-in model roles a user can lock a model alias to. */
2+
export const BUILT_IN_MODEL_ROLES = ['small', 'implementer', 'advisor'] as const;
3+
export type BuiltInModelRole = (typeof BUILT_IN_MODEL_ROLES)[number];
4+
5+
interface ModelRoleSource {
6+
modelRoles?: Record<string, string>;
7+
defaultModel?: string;
8+
}
9+
10+
/** Resolve a role name to its locked model alias. Empty string means cleared. */
11+
export function resolveModelRoleAlias(
12+
config: ModelRoleSource | undefined,
13+
role: string,
14+
): string | undefined {
15+
if (role === 'default') return config?.defaultModel;
16+
const alias = config?.modelRoles?.[role]?.trim();
17+
return alias === '' ? undefined : alias;
18+
}
19+
20+
/** Expand a "@role" model reference; non-@ strings pass through unchanged. */
21+
export function expandModelRef(
22+
config: ModelRoleSource | undefined,
23+
ref: string,
24+
): string | undefined {
25+
return ref.startsWith('@') ? resolveModelRoleAlias(config, ref.slice(1)) : ref;
26+
}

0 commit comments

Comments
 (0)