feat: add an advisor that reviews each turn with a second model - #59
Conversation
📝 WalkthroughWalkthroughAdds an opt-in ChangesAdvisor runtime
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant MainAgent
participant SessionAdvisor
participant AdvisorAgent
MainAgent->>SessionAdvisor: report completed user turn
SessionAdvisor->>AdvisorAgent: request structured review
AdvisorAgent-->>SessionAdvisor: return advisory notes
SessionAdvisor->>MainAgent: inject notes into next turn
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
commit: |
bfed285 to
758590c
Compare
758590c to
afb2da8
Compare
6198c98 to
b8fff9b
Compare
After each completed main-agent turn, an opt-in advisor runs the conversation past a second model (the advisor model role or an explicit override) and buffers its notes; they are injected as an <advisory> block when the next turn starts, never launching a turn on their own. The advisor runs only when its model shares the session model's provider, and disables itself after three consecutive failures.
Limit deliveries to ten notes of 500 code points each, mark the reviewed conversation as untrusted data in the advisor system prompt, and document the one-turn lag and usage-reporting limitations.
b8fff9b to
be2728e
Compare
Greptile SummaryThis change adds an opt-in session advisor, advisor configuration persistence, documentation, and coverage for bounded review-note delivery. One blocking behavior remains: a delayed advisor result can be inserted into a later user turn that is already in progress. Confidence Score: 4/5Not merge-safe until delayed advisor notes are prevented from entering an already-active later turn. One verified non-security P1 finding remains. Under the scoring table, one non-security P1 results in a confidence score of 4. Files Needing Attention: packages/agent-core/src/session/session-advisor.ts
What T-Rex did
Prompt To Fix All With AI### Issue 1
packages/agent-core/src/session/session-advisor.ts:160-168
**Delayed advisor notes enter the wrong turn**
An advisor run is asynchronous, but pending notes are delivered whenever any main turn is active. If a review of user turn 1 finishes after user turn 2 has already started, `#deliverPending()` steers the turn-1 advice into the in-progress turn-2 conversation. This can redirect work already underway and breaks the intended next-turn delivery behavior. Associate pending advice with the reviewed turn and only inject it at the start of the next eligible user turn; retain it when that delivery boundary has already passed.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (3): Last reviewed commit: "test: build the surrogate-pair fixture f..." | Re-trigger Greptile |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (3)
packages/agent-core/test/session/session-advisor.test.ts (2)
21-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winClose sessions in
afterEach, not at the end of each test body.Every test calls
await fixture.session.close()as its last statement. If an assertion fails before that line, the session stays open. TheafterEachhook then removes the temporary directory while the session still holds handles, which can produce follow-on failures in unrelated tests.Track the created sessions in a module-level array inside
createFixture, and close them inafterEachbefore the directory removal.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/agent-core/test/session/session-advisor.test.ts` around lines 21 - 25, Update the session-advisor test cleanup by tracking each session created in createFixture in a module-level collection, then closing all tracked sessions at the start of afterEach before removing temporary directories. Remove the per-test session.close calls so cleanup remains reliable when assertions fail, and clear the collection after closing.
126-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the
parseNotesrejection branches.No test exercises the validation failures in
parseNotes. Four throw sites are uncovered: a missingnotesarray, a non-object entry, a non-stringnote, and an invalidseverity. Each one feeds#recordFailureand moves the advisor toward the three-failure shutdown, so the behavior is worth pinning.Add a case that queues a
StructuredOutputcall with{ notes: [{ note: 42 }] }and assert thatsession.log.debugrecords'advisor run failed'and thatsteeris not called.As per path instructions, "New behavior should come with vitest coverage."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/agent-core/test/session/session-advisor.test.ts` around lines 126 - 136, Add a Vitest case in the session advisor tests that queues StructuredOutput with { notes: [{ note: 42 }] }, runs the advisor flow, and asserts session.log.debug records “advisor run failed” while the main turn’s steer is not called. Reuse the existing fixture, queueReview, runMainTurn, and waitForAdvisor helpers, targeting the parseNotes rejection path and its failure recording behavior.Source: Path instructions
packages/agent-core/src/session/session-advisor.ts (1)
176-185: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove the
as AdvisoryNoteassertion.TypeScript does not narrow an
unknownvalue through!==literal comparisons, soseveritystaysunknownand the assertion hides that. Introduce a typed local instead.♻️ Proposed fix
- const { note, severity } = value as { note?: unknown; severity?: unknown }; + const { note, severity } = value as { note?: unknown; severity?: unknown }; if (typeof note !== 'string') throw new Error('Advisor returned an invalid note.'); - if ( - severity !== undefined && - severity !== 'nit' && - severity !== 'concern' && - severity !== 'blocker' - ) { + const severities: readonly string[] = ['nit', 'concern', 'blocker']; + if (severity !== undefined && (typeof severity !== 'string' || !severities.includes(severity))) { throw new Error('Advisor returned an invalid severity.'); } - return { note: Array.from(note.trim()).slice(0, 500).join(''), severity } as AdvisoryNote; + return { + note: Array.from(note.trim()).slice(0, 500).join(''), + severity: severity as AdvisoryNote['severity'], + };A
severity is AdvisoryNote['severity']type predicate removes the remaining assertion entirely.As per path instructions, "Flag any
any,@ts-ignore, or type assertions added to silence errors."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/agent-core/src/session/session-advisor.ts` around lines 176 - 185, In the advisory note mapping logic, remove the `as AdvisoryNote` assertion and introduce a typed local for severity using a type predicate that validates the allowed values (`nit`, `concern`, and `blocker`). Return the mapped object with that narrowed severity so TypeScript infers it as an `AdvisoryNote` without any type assertion.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/configuration/config-files.md`:
- Around line 180-190: Update the advisor documentation describing review
delivery timing to state that completed reviews are delivered during the next
turn, including when that turn is already active and receives the advisory via
steering. Remove claims that delivery always occurs at the turn start or that
the advisor never interrupts a running turn, while preserving the one-turn lag
guidance.
In `@packages/agent-core/src/agent/index.ts`:
- Around line 574-577: Update Agent’s emitEvent method to catch synchronous
failures from onEvent, log the callback error, and continue execution so
rpc.emitEvent is always attempted. Preserve the restoring guard and event
delivery behavior, and add a Vitest regression test covering a throwing onEvent
callback while verifying RPC delivery.
In `@packages/agent-core/src/session/index.ts`:
- Around line 1335-1344: The Session advisor currently drops completed turns
while a review is running. In packages/agent-core/src/session/index.ts lines
1335-1344, update the onEvent handling around SessionAdvisor.onMainTurnEnded()
to queue each completed-turn snapshot or sequence when busy and process queued
turns after the active review, preserving the “each completed user turn”
contract. In .changeset/advisor-runtime.md line 5, retain that contract only
after the runtime queues turns; otherwise revise the wording to state that
overlapping turns may be skipped.
In `@packages/agent-core/src/session/session-advisor.ts`:
- Around line 166-186: Update parseNotes to skip malformed note entries,
including invalid note text or severity, instead of throwing for individual
entries; only throw when the top-level notes array is missing or invalid. Apply
the ten-note cap to valid parsed notes rather than raw entries, and update the
corresponding session-advisor tests to reflect this behavior.
- Around line 44-49: Update onMainTurnStarted so the queued callback invokes
`#deliverPending` inside exception handling, preventing errors such as
main.turn.steer throws from escaping the microtask. Preserve the advisor’s
contract that it never throws and retain the existing review-state assignment
and asynchronous delivery behavior.
In `@packages/agent-core/test/session/session-advisor.test.ts`:
- Around line 21-25: Update
packages/agent-core/test/session/session-advisor.test.ts at lines 21-25 by
extending the afterEach teardown to restore Vitest mocks, close all tracked
sessions, then remove temporary directories. At lines 283-306, remove the manual
timeout.mockRestore() and narrow the AbortSignal.timeout stub to only the
120-second advisor wait, relying on the shared afterEach cleanup.
---
Nitpick comments:
In `@packages/agent-core/src/session/session-advisor.ts`:
- Around line 176-185: In the advisory note mapping logic, remove the `as
AdvisoryNote` assertion and introduce a typed local for severity using a type
predicate that validates the allowed values (`nit`, `concern`, and `blocker`).
Return the mapped object with that narrowed severity so TypeScript infers it as
an `AdvisoryNote` without any type assertion.
In `@packages/agent-core/test/session/session-advisor.test.ts`:
- Around line 21-25: Update the session-advisor test cleanup by tracking each
session created in createFixture in a module-level collection, then closing all
tracked sessions at the start of afterEach before removing temporary
directories. Remove the per-test session.close calls so cleanup remains reliable
when assertions fail, and clear the collection after closing.
- Around line 126-136: Add a Vitest case in the session advisor tests that
queues StructuredOutput with { notes: [{ note: 42 }] }, runs the advisor flow,
and asserts session.log.debug records “advisor run failed” while the main turn’s
steer is not called. Reuse the existing fixture, queueReview, runMainTurn, and
waitForAdvisor helpers, targeting the parseNotes rejection path and its failure
recording behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 9b58bb0b-a4d1-40d1-bdd1-a3e15582c5c0
📒 Files selected for processing (9)
.changeset/advisor-runtime.mddocs/configuration/config-files.mdpackages/agent-core/src/agent/index.tspackages/agent-core/src/config/schema.tspackages/agent-core/src/config/toml.tspackages/agent-core/src/session/index.tspackages/agent-core/src/session/session-advisor.tspackages/agent-core/test/config/configs.test.tspackages/agent-core/test/session/session-advisor.test.ts
Guard the event observer and the delivery microtask so a throwing consumer cannot escape into an unrelated turn, keep the valid notes when a response also carries malformed entries instead of burning a failure strike, and document that delivery can land mid-turn and that overlapping turns are skipped.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/agent-core/src/session/session-advisor.ts (1)
160-165: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winRetain the advisory when
steerfails.Line 164 clears
#pendingAdvisorybeforemain.turn.steerruns.steercan throw. The caller then only logs the error. The next main turn cannot receive the valid advisory.Clear
#pendingAdvisoryonly aftersteerreturns. Add a test that makes the first delivery throw and verifies delivery on the next turn.Proposed fix
const block = this.#pendingAdvisory; - this.#pendingAdvisory = undefined; main.turn.steer([{ type: 'text', text: block }], { kind: 'hook_result', event: 'advisor', }); + this.#pendingAdvisory = undefined;Also applies to: 146-146
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/agent-core/src/session/session-advisor.ts` around lines 160 - 165, Update `#deliverPending`() so `#pendingAdvisory` is cleared only after main.turn.steer returns successfully; retain it when steer throws so the next main turn retries delivery. Add a test covering a first delivery failure followed by successful delivery on the next turn.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/agent-core/test/session/session-advisor.test.ts`:
- Around line 211-212: Update the surrogate-pair fixtures in the session advisor
test, including the `note` declaration and the corresponding fixture at the
second referenced location, to construct the same code point with
`String.fromCodePoint(0x1d400)` instead of embedding `𝐀` directly. Preserve the
existing string contents and code-point slicing coverage.
---
Outside diff comments:
In `@packages/agent-core/src/session/session-advisor.ts`:
- Around line 160-165: Update `#deliverPending`() so `#pendingAdvisory` is cleared
only after main.turn.steer returns successfully; retain it when steer throws so
the next main turn retries delivery. Add a test covering a first delivery
failure followed by successful delivery on the next turn.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 7810214d-b966-42f8-b5b0-b79453597c47
📒 Files selected for processing (5)
.changeset/advisor-runtime.mddocs/configuration/config-files.mdpackages/agent-core/src/agent/index.tspackages/agent-core/src/session/session-advisor.tspackages/agent-core/test/session/session-advisor.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- .changeset/advisor-runtime.md
- docs/configuration/config-files.md
- packages/agent-core/src/agent/index.ts
Delivering as soon as a review finished could steer notes about an earlier turn into a turn already under way, redirecting work in progress. Notes now arrive only at the start of a turn, so a review that finishes mid-turn waits for the following one.
There was a problem hiding this comment.
This review was skipped because it would exceed your organization's monthly flex usage limit. Raise the limit in billing settings or wait until the next billing period resets limits.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/agent-core/test/session/session-advisor.test.ts`:
- Around line 81-87: Replace the conditional logic in the rawGenerate spy with
ordered mockImplementationOnce handlers: keep the default generation behavior,
then add explicit handlers for the advisor review call awaiting reviewGate and
the active main-turn call awaiting activeTurnGate. Preserve the existing call
ordering and return values while removing the currentCall checks.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 907dbf54-a9cb-4736-bec2-7a1c46702886
📒 Files selected for processing (3)
docs/configuration/config-files.mdpackages/agent-core/src/session/session-advisor.tspackages/agent-core/test/session/session-advisor.test.ts
💤 Files with no reviewable changes (1)
- packages/agent-core/src/session/session-advisor.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- docs/configuration/config-files.md
## Summary `packages/agent-core/test/session/session-advisor.test.ts` emitted two `vitest(no-conditional-in-test)` warnings from a call-counter mock that branched on which `rawGenerate` call it was handling. A reviewer raised this on #59 and I declined it incorrectly, citing an `oxlint --quiet` run as evidence the rule did not fire. That flag suppresses warnings, so the evidence was an artifact of the command rather than a fact about the code. This is the follow-up. ## Approach The three-call sequence is now expressed with ordered `mockImplementationOnce` handlers plus a trailing default, so an unexpected fourth call falls through to the real implementation instead of returning `undefined`. No production code and no assertions changed. ## Test plan - `pnpm --filter @pythoughts/agent-core exec vitest run test/session/session-advisor.test.ts` — all pass. - `npx oxlint packages/agent-core/test/session/session-advisor.test.ts` (no `--quiet`) — 2 warnings before, 0 after. - Red proof that the test still catches its bug: restoring the mid-turn `#deliverPending()` call in `session-advisor.ts` fails the test with `expected 1 to be +0` on `callsWhileActive`. [skip changeset] — tests-only change under `packages/agent-core/test/`. No production source is touched and nothing enters the CLI bundle, so per the repo's changeset rules there is no user-visible change to record. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Updated mid-turn advisor coverage to validate review and active-turn behavior using explicit response sequencing. * Improved test reliability by removing dependence on call-count state. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## @pythoughts/pythinker-code@0.16.0 ### Minor Changes - [#59](#59) [`6999b68`](6999b68) - 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. - [#56](#56) [`b71f094`](b71f094) - 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. - [#57](#57) [`99c427c`](99c427c) - Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a rotating placeholder; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. - [#58](#58) [`065bf2e`](065bf2e) - Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the prompt box uses a neutral border while permission mode appears in the status bar. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights. ### Patch Changes - [#62](#62) [`7fc36fd`](7fc36fd) - Repair invalid escape sequences and unescaped quotes in model-written tool arguments instead of failing the tool call. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Related Issue
No linked issue — directly requested feature; problem explained below. Based on the model-roles branch (#56) because the advisor consumes the
advisormodel role; retarget tomainafter #56 merges.Problem
There is no way to get a continuous second opinion on an agent session. A user who wants a stronger or different model to sanity-check the working agent's decisions has to interrupt and paste context manually.
What changed
An opt-in advisor reviews the session with a second model and feeds notes back to the working agent:
{note, severity: nit|concern|blocker}).<advisory>block ("weigh, don't blindly obey") when the next turn starts — or immediately if a turn is already running. Advisories never launch a turn on their own and never block or slow the primary turn (single-flight, 120s timeout, fire-and-forget).[advisor] modelor theadvisormodel role; it never falls back to the session model. Privacy default: it runs only when its model uses the same provider entry as the session model — a cross-provider advisor stays configured but inactive with one warning (transcript redaction / explicit cross-provider opt-in is future work).[advisor]table (enabled, default false;model;instructions). The advisor disables itself for the session after three consecutive failures.blockerseverity is deliberately deferred; blockers arrive as the first advisory of the next turn.Checklist
gen-changesetsskill, or this PR needs no changeset.gen-docsskill, or this PR needs no doc update.Summary by CodeRabbit
New Features
Documentation