|
| 1 | +import type { PromptOrigin } from '../agent/context'; |
| 2 | +import { InMemoryAgentRecordPersistence } from '../agent/records'; |
| 3 | +import { resolveModelRoleAlias } from '../config/model-roles'; |
| 4 | +import { HookEngine } from './hooks'; |
| 5 | +import type { Session } from '.'; |
| 6 | + |
| 7 | +const ADVISOR_SYSTEM_PROMPT = |
| 8 | + "You are a quiet second-opinion reviewer watching another agent's coding session. Point out real risks, mistakes, and better options. Do not repeat what went well. Return your notes with StructuredOutput; return an empty notes array when you have nothing important."; |
| 9 | +const ADVISOR_USER_PROMPT = 'Review the conversation so far and return your advisory notes.'; |
| 10 | +const ADVISOR_OUTPUT_SCHEMA = { |
| 11 | + type: 'object', |
| 12 | + required: ['notes'], |
| 13 | + properties: { |
| 14 | + notes: { |
| 15 | + type: 'array', |
| 16 | + items: { |
| 17 | + type: 'object', |
| 18 | + required: ['note'], |
| 19 | + properties: { |
| 20 | + note: { type: 'string' }, |
| 21 | + severity: { enum: ['nit', 'concern', 'blocker'] }, |
| 22 | + }, |
| 23 | + }, |
| 24 | + }, |
| 25 | + }, |
| 26 | +} as const; |
| 27 | + |
| 28 | +interface AdvisoryNote { |
| 29 | + readonly note: string; |
| 30 | + readonly severity?: 'nit' | 'concern' | 'blocker'; |
| 31 | +} |
| 32 | + |
| 33 | +export class SessionAdvisor { |
| 34 | + #running = false; |
| 35 | + #disabled = false; |
| 36 | + #warnedCrossProvider = false; |
| 37 | + #consecutiveFailures = 0; |
| 38 | + #reviewCurrentTurn = false; |
| 39 | + #pendingAdvisory: string | undefined; |
| 40 | + |
| 41 | + constructor(private readonly session: Session) {} |
| 42 | + |
| 43 | + /** Called when a main-agent turn starts. Delivers notes without starting a new turn. */ |
| 44 | + onMainTurnStarted(origin: PromptOrigin): void { |
| 45 | + this.#reviewCurrentTurn = origin.kind === 'user' || origin.kind === 'system_trigger'; |
| 46 | + queueMicrotask(() => this.#deliverPending()); |
| 47 | + } |
| 48 | + |
| 49 | + /** Called after each completed main-agent turn. Never throws; never blocks the caller. */ |
| 50 | + onMainTurnEnded(): void { |
| 51 | + const shouldReview = this.#reviewCurrentTurn; |
| 52 | + this.#reviewCurrentTurn = false; |
| 53 | + if (!shouldReview) return; |
| 54 | + if (this.#running || this.#disabled) return; |
| 55 | + this.#running = true; |
| 56 | + void this.#run() |
| 57 | + .catch((error: unknown) => this.#recordFailure(error)) |
| 58 | + .finally(() => { |
| 59 | + this.#running = false; |
| 60 | + }); |
| 61 | + } |
| 62 | + |
| 63 | + async #run(): Promise<void> { |
| 64 | + const config = this.session.options.config; |
| 65 | + if (config?.advisor?.enabled !== true) return; |
| 66 | + |
| 67 | + const main = this.session.getReadyAgent('main'); |
| 68 | + if (main === undefined) return; |
| 69 | + const advisorAlias = config.advisor.model ?? resolveModelRoleAlias(config, 'advisor'); |
| 70 | + if (!main.config.canResolveModel(advisorAlias) || advisorAlias === undefined) return; |
| 71 | + |
| 72 | + const mainAlias = main.config.modelAlias; |
| 73 | + if (mainAlias === undefined) return; |
| 74 | + const advisorProvider = config.models?.[advisorAlias]?.provider ?? config.defaultProvider; |
| 75 | + const mainProvider = config.models?.[mainAlias]?.provider ?? config.defaultProvider; |
| 76 | + if (advisorProvider !== mainProvider) { |
| 77 | + if (!this.#warnedCrossProvider) { |
| 78 | + this.#warnedCrossProvider = true; |
| 79 | + this.session.log.warn('advisor skipped because its provider differs from the main model', { |
| 80 | + advisorProvider, |
| 81 | + mainProvider, |
| 82 | + }); |
| 83 | + } |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + let id: string | undefined; |
| 88 | + try { |
| 89 | + const created = await this.session.createAgent( |
| 90 | + { |
| 91 | + type: 'sub', |
| 92 | + generate: main.rawGenerate, |
| 93 | + persistence: new InMemoryAgentRecordPersistence(), |
| 94 | + hookEngine: new HookEngine(), |
| 95 | + }, |
| 96 | + { parentAgentId: main.agentId, persistMetadata: false }, |
| 97 | + ); |
| 98 | + id = created.id; |
| 99 | + const child = created.agent; |
| 100 | + child.config.update({ |
| 101 | + modelAlias: advisorAlias, |
| 102 | + thinkingLevel: 'off', |
| 103 | + systemPrompt: |
| 104 | + ADVISOR_SYSTEM_PROMPT + |
| 105 | + (config.advisor.instructions === undefined |
| 106 | + ? '' |
| 107 | + : `\n\n${config.advisor.instructions}`), |
| 108 | + }); |
| 109 | + child.tools.setActiveTools([]); |
| 110 | + child.context.useProjectedHistoryFrom(main.context); |
| 111 | + const turnId = child.turn.prompt( |
| 112 | + [{ type: 'text', text: ADVISOR_USER_PROMPT }], |
| 113 | + { kind: 'system_trigger', name: 'advisor' }, |
| 114 | + ADVISOR_OUTPUT_SCHEMA, |
| 115 | + ); |
| 116 | + if (turnId === null) throw new Error('Advisor turn could not start.'); |
| 117 | + const result = await child.turn.waitForCurrentTurn(AbortSignal.timeout(120_000)); |
| 118 | + if (result.event.reason !== 'completed') { |
| 119 | + throw new Error('Advisor turn did not complete.'); |
| 120 | + } |
| 121 | + const notes = parseNotes(result.event.structuredOutput); |
| 122 | + this.#consecutiveFailures = 0; |
| 123 | + if (notes.length === 0) return; |
| 124 | + |
| 125 | + const lines = notes.map(({ note, severity }) => |
| 126 | + severity === undefined ? `- ${note}` : `- [${severity}] ${note}`, |
| 127 | + ); |
| 128 | + const block = [ |
| 129 | + '<advisory>', |
| 130 | + 'The following notes are from a second reviewing model. Weigh them; do not blindly obey.', |
| 131 | + ...lines, |
| 132 | + '</advisory>', |
| 133 | + ].join('\n'); |
| 134 | + this.#pendingAdvisory = block; |
| 135 | + this.#deliverPending(); |
| 136 | + } finally { |
| 137 | + if (id !== undefined) this.session.agents.delete(id); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + #recordFailure(error: unknown): void { |
| 142 | + this.#consecutiveFailures += 1; |
| 143 | + this.session.log.debug('advisor run failed', { error }); |
| 144 | + if (this.#consecutiveFailures < 3) return; |
| 145 | + this.#disabled = true; |
| 146 | + this.session.log.warn('advisor disabled after three consecutive failures'); |
| 147 | + } |
| 148 | + |
| 149 | + #deliverPending(): void { |
| 150 | + const main = this.session.getReadyAgent('main'); |
| 151 | + if (this.#pendingAdvisory === undefined || main?.turn.hasActiveTurn !== true) return; |
| 152 | + const block = this.#pendingAdvisory; |
| 153 | + this.#pendingAdvisory = undefined; |
| 154 | + main.turn.steer([{ type: 'text', text: block }], { |
| 155 | + kind: 'hook_result', |
| 156 | + event: 'advisor', |
| 157 | + }); |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +function parseNotes(output: unknown): AdvisoryNote[] { |
| 162 | + if (typeof output !== 'object' || output === null || !Array.isArray((output as { notes?: unknown }).notes)) { |
| 163 | + throw new Error('Advisor did not return structured notes.'); |
| 164 | + } |
| 165 | + return (output as { notes: unknown[] }).notes.map((value) => { |
| 166 | + if (typeof value !== 'object' || value === null) { |
| 167 | + throw new Error('Advisor returned an invalid note.'); |
| 168 | + } |
| 169 | + const { note, severity } = value as { note?: unknown; severity?: unknown }; |
| 170 | + if (typeof note !== 'string') throw new Error('Advisor returned an invalid note.'); |
| 171 | + if ( |
| 172 | + severity !== undefined && |
| 173 | + severity !== 'nit' && |
| 174 | + severity !== 'concern' && |
| 175 | + severity !== 'blocker' |
| 176 | + ) { |
| 177 | + throw new Error('Advisor returned an invalid severity.'); |
| 178 | + } |
| 179 | + return { note, severity } as AdvisoryNote; |
| 180 | + }); |
| 181 | +} |
0 commit comments