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/advisor-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": minor
---

Add an opt-in advisor: a second model reviews the conversation after a completed user turn unless another review is already running, and its notes appear as an `<advisory>` block in the agent's next turn; enable with `[advisor] enabled = true` plus an advisor model (the `advisor` model role or `[advisor] model`), and it runs only when the advisor shares the session model's provider.
27 changes: 25 additions & 2 deletions docs/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ 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 @@ -87,6 +86,8 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `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 |
| `providers` | `table` | `{}` | API provider table → [`providers`](#providers) |
| `models` | `table` | — | Model alias table → [`models`](#models) |
| `model_roles` | `table` | — | Model role assignments → [`model_roles`](#model_roles) |
| `advisor` | `table` | — | Second-opinion reviewer → [`advisor`](#advisor) |
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
Expand All @@ -95,7 +96,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`, `model_roles`, `thinking`, `loop_control`, `background`, `experimental`, `services`, and `permission`.
The following sections cover each of the nested tables in turn: `providers`, `models`, `model_roles`, `advisor`, `thinking`, `loop_control`, `background`, `experimental`, `services`, and `permission`.

## `providers`

Expand Down Expand Up @@ -174,6 +175,28 @@ Roles take effect in two places:

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

## `advisor`

`advisor` enables a second-opinion reviewer: after a completed user turn, a second model reviews the conversation and returns notes. Notes are delivered at the start of the next turn after the review finishes, so a review may lag a turn.

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `false` | Turn the advisor on. It also needs a model: set `model` here or lock one to the `advisor` role |
| `model` | `string` | — | Model alias for the advisor; when unset, the `advisor` entry in `model_roles` is used |
| `instructions` | `string` | — | Extra instructions appended to the advisor's system prompt |

The advisor sends the session conversation to the advisor model. As a safety default, it runs only when the advisor model uses the same provider entry as the session model; a cross-provider advisor stays inactive and logs one warning.

Reviews run only for user-started turns, and a turn is skipped when a review is already running. The advisor's token usage is not yet included in usage reporting.

```toml
[advisor]
enabled = true

[model_roles]
advisor = "reviewer-model"
```

## `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
8 changes: 8 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface AgentOptions {
readonly lsp?: LspManager;
readonly additionalDirs?: readonly string[];
readonly fileCheckpoints?: SessionFileCheckpointStore;
readonly onEvent?: (event: AgentEvent) => void;
}

export class Agent {
Expand Down Expand Up @@ -150,6 +151,7 @@ export class Agent {
readonly worktree?: SessionWorktree;
readonly lsp?: LspManager;
private readonly fileCheckpoints?: SessionFileCheckpointStore;
private readonly onEvent?: (event: AgentEvent) => void;
private currentFileCheckpointId?: string;

readonly llmRequestLogger: LlmRequestLogger;
Expand Down Expand Up @@ -195,6 +197,7 @@ export class Agent {
this.worktree = options.worktree;
this.lsp = options.lsp;
this.fileCheckpoints = options.fileCheckpoints;
this.onEvent = options.onEvent;

this.llmRequestLogger = new LlmRequestLogger(this.log);
this.blobStore = options.homedir
Expand Down Expand Up @@ -570,6 +573,11 @@ export class Agent {

emitEvent(event: AgentEvent): void {
if (this.records.restoring) return;
try {
this.onEvent?.(event);
} catch (error) {
this.log.warn('agent event observer failed', { error });
}
void this.rpc?.emitEvent?.(event);
Comment thread
elkaix marked this conversation as resolved.
}

Expand Down
11 changes: 11 additions & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export const ThinkingConfigSchema = z.object({

export type ThinkingConfig = z.infer<typeof ThinkingConfigSchema>;

export const AdvisorConfigSchema = z.object({
enabled: z.boolean().optional(),
model: z.string().optional(),
instructions: z.string().optional(),
});

export type AdvisorConfig = z.infer<typeof AdvisorConfigSchema>;

export const PermissionModeSchema = z.enum(['yolo', 'manual', 'auto']);

export const WorkflowSizeGuidelineSchema = z.enum(['small', 'medium', 'large', 'unrestricted']);
Expand Down Expand Up @@ -283,6 +291,7 @@ export const PythinkerConfigSchema = z.object({
outputStyle: z.string().trim().min(1).optional(),
models: z.record(z.string(), ModelAliasSchema).optional(),
thinking: ThinkingConfigSchema.optional(),
advisor: AdvisorConfigSchema.optional(),
planMode: z.boolean().optional(),
yolo: z.boolean().optional(),
defaultThinking: z.boolean().optional(),
Expand Down Expand Up @@ -310,6 +319,7 @@ export type PythinkerConfig = z.infer<typeof PythinkerConfigSchema>;
const ProviderConfigPatchSchema = ProviderConfigFieldsSchema.partial();
const ModelAliasPatchSchema = ModelAliasSchema.partial();
const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial();
const AdvisorConfigPatchSchema = AdvisorConfigSchema.partial();
const PermissionConfigPatchSchema = PermissionConfigSchema.partial();
const LoopControlPatchSchema = LoopControlSchema.partial();
const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial();
Expand All @@ -329,6 +339,7 @@ export const PythinkerConfigPatchSchema = z
outputStyle: z.string().trim().min(1).optional(),
models: z.record(z.string(), ModelAliasPatchSchema).optional(),
thinking: ThinkingConfigPatchSchema.optional(),
advisor: AdvisorConfigPatchSchema.optional(),
planMode: z.boolean().optional(),
yolo: z.boolean().optional(),
defaultThinking: z.boolean().optional(),
Expand Down
12 changes: 12 additions & 0 deletions packages/agent-core/src/config/toml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
PythinkerConfigSchema,
formatConfigValidationError,
getDefaultConfig,
type AdvisorConfig,
type BackgroundConfig,
type ExperimentalConfig,
type HookDefConfig,
Expand Down Expand Up @@ -303,6 +304,8 @@ export function transformTomlData(data: Record<string, unknown>): Record<string,
result[targetKey] = transformRecord(value, transformModelData);
} else if (targetKey === 'thinking' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'advisor' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'permission' && isPlainObject(value)) {
result[targetKey] = transformPermissionData(value);
} else if (targetKey === 'hooks' && Array.isArray(value)) {
Expand Down Expand Up @@ -498,6 +501,7 @@ export function configToTomlData(config: PythinkerConfig): Record<string, unknow
out['model_roles'] = cloneUnknown(config.modelRoles);
}
setSection(out, 'thinking', config.thinking, thinkingToToml);
setSection(out, 'advisor', config.advisor, advisorToToml);
setSection(out, 'services', config.services, servicesToToml);
setSection(out, 'loop_control', config.loopControl, loopControlToToml);
setSection(out, 'background', config.background, backgroundToToml);
Expand Down Expand Up @@ -583,6 +587,14 @@ function thinkingToToml(thinking: ThinkingConfig, rawThinking: unknown): Record<
return out;
}

function advisorToToml(advisor: AdvisorConfig, rawAdvisor: unknown): Record<string, unknown> {
const out = cloneRecord(rawAdvisor);
for (const [key, value] of Object.entries(advisor)) {
setDefined(out, camelToSnake(key), value);
}
return out;
}

function permissionToToml(
permission: PermissionConfig,
rawPermission: unknown,
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from '../skill';
import { noopTelemetryClient, type TelemetryClient } from '../telemetry';
import { SessionSubagentHost } from './subagent-host';
import { SessionAdvisor } from './session-advisor';
import type { ToolServices } from '../tools/support/services';
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
import { abortError } from '../utils/abort';
Expand Down Expand Up @@ -366,6 +367,7 @@ export class Session {
readonly worktree: SessionWorktree;
readonly lsp: LspManager;
readonly fileCheckpoints: SessionFileCheckpointStore | undefined;
readonly advisor: SessionAdvisor;
private fileChangedWatcher?: FSWatcher;
private readonly fileChangedWatcherReady: Promise<void>;
private fileChangedWatchCwd: string;
Expand Down Expand Up @@ -399,6 +401,7 @@ export class Session {
this.log =
this.logHandle?.logger ??
(options.id === undefined ? log : log.createChild({ sessionId: options.id }));
this.advisor = new SessionAdvisor(this);
this.rpc = options.rpc;
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
this.agentProfiles = {
Expand Down Expand Up @@ -1329,6 +1332,16 @@ export class Session {
lsp: this.lsp,
additionalDirs: this.listWorkspaceDirectories().map((entry) => entry.path),
fileCheckpoints: this.fileCheckpoints,
onEvent:
id === 'main'
? (event) => {
if (event.type === 'turn.started') {
this.advisor.onMainTurnStarted(event.origin);
} else if (event.type === 'turn.ended' && event.reason === 'completed') {
this.advisor.onMainTurnEnded();
}
}
: undefined,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
agent.setFileCheckpointId(parentAgent?.fileCheckpointId);
return agent;
Expand Down
Loading
Loading