Skip to content

Commit e398ec6

Browse files
committed
fix: persist advisor config, review only user turns, expand advisor role refs
1 parent a8fb7de commit e398ec6

5 files changed

Lines changed: 142 additions & 27 deletions

File tree

docs/configuration/config-files.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,6 @@ 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) |
80-
| `advisor` | `table` || Second-opinion reviewer → [`advisor`](#advisor) |
8179
| `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 |
8280
| `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) |
8381
| `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default |
@@ -88,6 +86,8 @@ Fields in the config file fall into two categories: **top-level scalars** that d
8886
| `workflow_size_guideline` | `string` | `medium` | Advisory subagent-count target for one Dynamic Workflow; one of `small` (about 5), `medium` (about 15), `large` (about 40), or `unrestricted` (no target). Exceeding it emits a warning rather than blocking the run; the `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` environment variable overrides it |
8987
| `providers` | `table` | `{}` | API provider table → [`providers`](#providers) |
9088
| `models` | `table` || Model alias table → [`models`](#models) |
89+
| `model_roles` | `table` || Model role assignments → [`model_roles`](#model_roles) |
90+
| `advisor` | `table` || Second-opinion reviewer → [`advisor`](#advisor) |
9191
| `thinking` | `table` || Default parameters for Thinking mode → [`thinking`](#thinking) |
9292
| `loop_control` | `table` || Agent loop control parameters → [`loop_control`](#loop_control) |
9393
| `background` | `table` || Background task runtime parameters → [`background`](#background) |
@@ -96,7 +96,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d
9696
| `permission` | `table` || Initial permission rules → [`permission`](#permission) |
9797
| `hooks` | `array<table>` || Lifecycle hooks; see [Hooks](../customization/hooks.md) |
9898

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

101101
## `providers`
102102

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
PythinkerConfigSchema,
99
formatConfigValidationError,
1010
getDefaultConfig,
11+
type AdvisorConfig,
1112
type BackgroundConfig,
1213
type ExperimentalConfig,
1314
type HookDefConfig,
@@ -500,6 +501,7 @@ export function configToTomlData(config: PythinkerConfig): Record<string, unknow
500501
out['model_roles'] = cloneUnknown(config.modelRoles);
501502
}
502503
setSection(out, 'thinking', config.thinking, thinkingToToml);
504+
setSection(out, 'advisor', config.advisor, advisorToToml);
503505
setSection(out, 'services', config.services, servicesToToml);
504506
setSection(out, 'loop_control', config.loopControl, loopControlToToml);
505507
setSection(out, 'background', config.background, backgroundToToml);
@@ -585,6 +587,14 @@ function thinkingToToml(thinking: ThinkingConfig, rawThinking: unknown): Record<
585587
return out;
586588
}
587589

590+
function advisorToToml(advisor: AdvisorConfig, rawAdvisor: unknown): Record<string, unknown> {
591+
const out = cloneRecord(rawAdvisor);
592+
for (const [key, value] of Object.entries(advisor)) {
593+
setDefined(out, camelToSnake(key), value);
594+
}
595+
return out;
596+
}
597+
588598
function permissionToToml(
589599
permission: PermissionConfig,
590600
rawPermission: unknown,

packages/agent-core/src/session/session-advisor.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PromptOrigin } from '../agent/context';
22
import { InMemoryAgentRecordPersistence } from '../agent/records';
3-
import { resolveModelRoleAlias } from '../config/model-roles';
3+
import { expandModelRef, resolveModelRoleAlias } from '../config/model-roles';
44
import { HookEngine } from './hooks';
55
import type { Session } from '.';
66

@@ -42,7 +42,8 @@ export class SessionAdvisor {
4242

4343
/** Called when a main-agent turn starts. Delivers notes without starting a new turn. */
4444
onMainTurnStarted(origin: PromptOrigin): void {
45-
this.#reviewCurrentTurn = origin.kind === 'user' || origin.kind === 'system_trigger';
45+
// Autonomous turns must not compound advisor cost.
46+
this.#reviewCurrentTurn = origin.kind === 'user';
4647
queueMicrotask(() => this.#deliverPending());
4748
}
4849

@@ -66,7 +67,10 @@ export class SessionAdvisor {
6667

6768
const main = this.session.getReadyAgent('main');
6869
if (main === undefined) return;
69-
const advisorAlias = config.advisor.model ?? resolveModelRoleAlias(config, 'advisor');
70+
const advisorAlias =
71+
config.advisor.model === undefined
72+
? resolveModelRoleAlias(config, 'advisor')
73+
: expandModelRef(config, config.advisor.model);
7074
if (!main.config.canResolveModel(advisorAlias) || advisorAlias === undefined) return;
7175

7276
const mainAlias = main.config.modelAlias;

packages/agent-core/test/config/configs.test.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest';
77

88
import { ErrorCodes, PythinkerError } from '../../src/errors';
99
import {
10+
type PythinkerConfig,
1011
PythinkerConfigSchema,
1112
ensureConfigFile,
1213
loadRuntimeConfig,
@@ -272,20 +273,16 @@ source = { kind = "apiJson", url = "https://registry.example/api.json", apiKey =
272273
expect(readConfigFile(configPath).modelRoles).toBeUndefined();
273274
});
274275

275-
it('round-trips advisor config', async () => {
276+
it('writes typed advisor config over stale raw data', async () => {
276277
const configPath = join(makeTempDir(), 'advisor.toml');
277-
const config = parseConfigString(
278-
'[advisor]\nenabled = true\nmodel = "reviewer"\ninstructions = "Check risks."\n',
279-
configPath,
280-
);
281-
282-
expect(config.advisor).toEqual({
283-
enabled: true,
284-
model: 'reviewer',
285-
instructions: 'Check risks.',
286-
});
278+
const config: PythinkerConfig = {
279+
providers: {},
280+
advisor: { enabled: true, model: 'reviewer' },
281+
raw: { advisor: { enabled: false, model: 'stale-reviewer' } },
282+
};
287283

288284
await writeConfigFile(configPath, config);
285+
expect(await readFile(configPath, 'utf-8')).toContain('[advisor]');
289286
expect(readConfigFile(configPath).advisor).toEqual(config.advisor);
290287
});
291288

packages/agent-core/test/session/session-advisor.test.ts

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { testKaos } from '../fixtures/test-kaos';
66
import { afterEach, describe, expect, it, vi } from 'vitest';
77

88
import type { Agent } from '../../src/agent';
9+
import type { PromptOrigin } from '../../src/agent/context';
910
import type { PythinkerConfig } from '../../src/config';
1011
import type { ResolvedAgentProfile } from '../../src/profile';
1112
import type { SDKSessionRPC } from '../../src/rpc';
@@ -40,7 +41,7 @@ describe('SessionAdvisor', () => {
4041
const steer = vi.spyOn(fixture.main.turn, 'steer').mockReturnValue(null);
4142
queueReview(fixture.scripted, 'Check the error path.', 'concern');
4243

43-
await runMainTurn(fixture.main);
44+
await runMainTurn(fixture.main, { kind: 'user' });
4445
await waitForAdvisor(fixture);
4546

4647
expect(spawn).toHaveBeenCalledOnce();
@@ -65,6 +66,30 @@ describe('SessionAdvisor', () => {
6566
await fixture.session.close();
6667
});
6768

69+
it('does not review system-trigger turns', async () => {
70+
const fixture = await createFixture({ advisorAlias: 'advisor' });
71+
const spawn = vi.spyOn(fixture.session, 'createAgent');
72+
73+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Continued.' });
74+
await runMainTurn(fixture.main, { kind: 'system_trigger', name: 'goal-continuation' });
75+
await flushAsync();
76+
77+
expect(spawn).not.toHaveBeenCalled();
78+
await fixture.session.close();
79+
});
80+
81+
it('expands an explicit advisor role reference', async () => {
82+
const fixture = await createFixture({ advisorAlias: 'reviewer', advisorModel: '@advisor' });
83+
const spawn = vi.spyOn(fixture.session, 'createAgent');
84+
queueReview(fixture.scripted);
85+
86+
await runMainTurn(fixture.main);
87+
await waitForAdvisor(fixture);
88+
89+
expect((await spawn.mock.results[0]!.value).agent.config.modelAlias).toBe('reviewer');
90+
await fixture.session.close();
91+
});
92+
6893
it('skips a cross-provider advisor and warns once', async () => {
6994
const fixture = await createFixture({ advisorAlias: 'cross-advisor' });
7095
const spawn = vi.spyOn(fixture.session, 'createAgent');
@@ -149,11 +174,87 @@ describe('SessionAdvisor', () => {
149174
expect(steer).not.toHaveBeenCalled();
150175
await fixture.session.close();
151176
});
177+
178+
it('contains advisor errors without affecting the main turn', async () => {
179+
const fixture = await createFixture({ advisorAlias: 'advisor' });
180+
const error = new Error('advisor failed');
181+
vi.spyOn(fixture.session, 'createAgent').mockRejectedValueOnce(error);
182+
const debug = vi.spyOn(fixture.session.log, 'debug');
183+
184+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Done.' });
185+
await expect(runMainTurn(fixture.main)).resolves.toBeUndefined();
186+
await vi.waitFor(() =>
187+
expect(debug).toHaveBeenCalledWith('advisor run failed', { error }),
188+
);
189+
190+
expect(fixture.main.turn.hasActiveTurn).toBe(false);
191+
await fixture.session.close();
192+
});
193+
194+
it('disables the advisor after three consecutive failures', async () => {
195+
const fixture = await createFixture({ advisorAlias: 'advisor' });
196+
const spawn = vi
197+
.spyOn(fixture.session, 'createAgent')
198+
.mockRejectedValue(new Error('advisor failed'));
199+
const warn = vi.spyOn(fixture.session.log, 'warn');
200+
201+
for (let turn = 0; turn < 3; turn += 1) {
202+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Done.' });
203+
await runMainTurn(fixture.main);
204+
await flushAsync();
205+
}
206+
await vi.waitFor(() =>
207+
expect(warn).toHaveBeenCalledWith('advisor disabled after three consecutive failures'),
208+
);
209+
210+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Done.' });
211+
await runMainTurn(fixture.main);
212+
await flushAsync();
213+
214+
expect(spawn).toHaveBeenCalledTimes(3);
215+
await fixture.session.close();
216+
});
217+
218+
it('counts an aborted advisor wait as a failure', async () => {
219+
const fixture = await createFixture({ advisorAlias: 'advisor' });
220+
const timeoutError = new Error('advisor timed out');
221+
const originalCreate = fixture.session.createAgent.bind(fixture.session);
222+
const spawn = vi
223+
.spyOn(fixture.session, 'createAgent')
224+
.mockRejectedValueOnce(new Error('first failure'))
225+
.mockRejectedValueOnce(new Error('second failure'))
226+
.mockImplementationOnce((...args) => originalCreate(...args));
227+
const timeout = vi
228+
.spyOn(AbortSignal, 'timeout')
229+
.mockReturnValue(AbortSignal.abort(timeoutError));
230+
const warn = vi.spyOn(fixture.session.log, 'warn');
231+
232+
for (let turn = 0; turn < 2; turn += 1) {
233+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Done.' });
234+
await runMainTurn(fixture.main);
235+
await flushAsync();
236+
}
237+
queueReview(fixture.scripted);
238+
await runMainTurn(fixture.main);
239+
await vi.waitFor(() =>
240+
expect(warn).toHaveBeenCalledWith('advisor disabled after three consecutive failures'),
241+
);
242+
243+
fixture.scripted.mockNextResponse({ type: 'text', text: 'Done.' });
244+
await runMainTurn(fixture.main);
245+
await flushAsync();
246+
247+
expect(timeout).toHaveBeenCalledWith(120_000);
248+
expect(spawn).toHaveBeenCalledTimes(3);
249+
timeout.mockRestore();
250+
await fixture.session.close();
251+
});
152252
});
153253

154254
interface FixtureOptions {
155255
readonly enabled?: boolean;
156-
readonly advisorAlias?: 'advisor' | 'cross-advisor';
256+
readonly advisorAlias?: 'advisor' | 'cross-advisor' | 'reviewer';
257+
readonly advisorModel?: string;
157258
}
158259

159260
async function createFixture(options: FixtureOptions = {}): Promise<{
@@ -193,18 +294,21 @@ function testConfig(options: FixtureOptions): PythinkerConfig {
193294
models: {
194295
main: { provider: 'primary', model: 'main', maxContextSize: 100_000 },
195296
advisor: { provider: 'primary', model: 'advisor', maxContextSize: 100_000 },
297+
reviewer: { provider: 'primary', model: 'reviewer', maxContextSize: 100_000 },
196298
'cross-advisor': {
197299
provider: 'secondary',
198300
model: 'cross-advisor',
199301
maxContextSize: 100_000,
200302
},
201303
},
202-
...(options.advisorAlias === undefined
203-
? {}
204-
: { modelRoles: { advisor: options.advisorAlias } }),
205-
...(options.enabled === true || options.advisorAlias !== undefined
206-
? { advisor: { enabled: true } }
207-
: {}),
304+
modelRoles:
305+
options.advisorAlias === undefined ? undefined : { advisor: options.advisorAlias },
306+
advisor:
307+
options.enabled === true ||
308+
options.advisorAlias !== undefined ||
309+
options.advisorModel !== undefined
310+
? { enabled: true, model: options.advisorModel }
311+
: undefined,
208312
};
209313
}
210314

@@ -222,8 +326,8 @@ function queueReview(
222326
});
223327
}
224328

225-
async function runMainTurn(main: Agent): Promise<void> {
226-
const turnId = main.turn.prompt([{ type: 'text', text: 'Continue.' }]);
329+
async function runMainTurn(main: Agent, origin?: PromptOrigin): Promise<void> {
330+
const turnId = main.turn.prompt([{ type: 'text', text: 'Continue.' }], origin);
227331
expect(turnId).not.toBeNull();
228332
await main.turn.waitForCurrentTurn();
229333
}

0 commit comments

Comments
 (0)