Skip to content

Commit 21fc085

Browse files
authored
feat(review): add deterministic reviewer target resolution (#208)
* docs(review): specify deterministic review targets * docs(review): plan deterministic target implementation * feat(review): add strict git target primitives * feat(review): resolve structured git targets * feat(subagents): transport resolved review targets * feat(agent): expose deterministic review targets * fix(review): harden target failure boundaries * docs(review): close deterministic target adoption * fix(review): address PR feedback * fix(review): protect target prompt boundaries
1 parent 6349497 commit 21fc085

26 files changed

Lines changed: 6978 additions & 116 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Reviewer subagents now receive deterministic Git scopes.** Structured automatic,
19+
uncommitted, base, and commit targets resolve to full commit anchors before dispatch, reject
20+
invalid or empty scopes explicitly, and keep repository metadata isolated from instructions
21+
across foreground and background runs.
1822
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
1923
- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models.
2024
- **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains.

docs/en/release-notes/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ GitHub Releases page; `0.8.0` is the new starting line.
1717

1818
## Unreleased
1919

20+
- **Reviewer subagents now receive deterministic Git scopes.** Structured automatic,
21+
uncommitted, base, and commit targets resolve to full commit anchors before dispatch, reject
22+
invalid or empty scopes explicitly, and keep repository metadata isolated from instructions
23+
across foreground and background runs.
2024
- **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown.
2125
- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models.
2226
- **Tool execution is now supervised as a terminal batch.** A private execution engine preserves the Toolset registry and legacy per-call API while centralizing ordered results, deduplication, callbacks, and batch summaries; cancellation is bounded, late work stays owned, and new batches fail closed until timed-out cleanup drains.

docs/superpowers/plans/2026-07-15-deterministic-review-target-resolution.md

Lines changed: 3226 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-07-15-review-target-resolution-design.md

Lines changed: 346 additions & 0 deletions
Large diffs are not rendered by default.

src/pythinker_code/background/agent_runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pythinker_code.subagents.builder import SubagentBuilder
1919
from pythinker_code.subagents.core import SubagentRunSpec, prepare_soul
2020
from pythinker_code.subagents.output import SubagentOutputWriter
21+
from pythinker_code.subagents.review_target import ResolvedReviewTarget
2122
from pythinker_code.subagents.runner import (
2223
_SUMMARY_MIN_LENGTH_BY_TYPE,
2324
_SUMMARY_MIN_LENGTH_DEFAULT,
@@ -68,6 +69,7 @@ def __init__(
6869
timeout_s: int | None = None,
6970
resumed: bool = False,
7071
isolation: str | None = None,
72+
resolved_review_target: ResolvedReviewTarget | None = None,
7173
) -> None:
7274
self._runtime = runtime
7375
self._manager = manager
@@ -79,6 +81,7 @@ def __init__(
7981
self._timeout_s = timeout_s
8082
self._resumed = resumed
8183
self._isolation = isolation
84+
self._resolved_review_target = resolved_review_target
8285
self._worktree_path: Path | None = None
8386
self._builder = SubagentBuilder(runtime)
8487
self._approval_update_tasks: set[asyncio.Task[None]] = set()
@@ -201,6 +204,7 @@ async def _run_core(self, output: SubagentOutputWriter) -> None:
201204
prompt=self._prompt,
202205
resumed=self._resumed,
203206
work_dir_override=work_dir_override,
207+
resolved_review_target=self._resolved_review_target,
204208
)
205209
soul, prompt = await prepare_soul(
206210
spec,

src/pythinker_code/background/manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pythinker_code.config import BackgroundConfig
1818
from pythinker_code.notifications import NotificationEvent, NotificationManager
1919
from pythinker_code.session import Session
20+
from pythinker_code.subagents.review_target import ResolvedReviewTarget
2021
from pythinker_code.utils.logging import logger
2122

2223
if TYPE_CHECKING:
@@ -336,6 +337,7 @@ def create_agent_task(
336337
dependencies: list[str] | None = None,
337338
budget_seconds: int | None = None,
338339
isolation: str | None = None,
340+
resolved_review_target: ResolvedReviewTarget | None = None,
339341
) -> TaskView:
340342
from .agent_runner import BackgroundAgentRunner
341343

@@ -378,6 +380,11 @@ def create_agent_task(
378380
"dependencies": list(dependencies or ()),
379381
"budget_seconds": budget_seconds,
380382
"isolation": isolation,
383+
"resolved_review_target": (
384+
resolved_review_target.model_dump(mode="json")
385+
if resolved_review_target is not None
386+
else None
387+
),
381388
},
382389
)
383390
self._store.create_task(spec)
@@ -402,6 +409,7 @@ def mark_agent_starting(runtime: TaskRuntime) -> bool:
402409
timeout_s=effective_timeout,
403410
resumed=resumed,
404411
isolation=isolation,
412+
resolved_review_target=resolved_review_target,
405413
).run()
406414
)
407415
self._live_agent_tasks[task_id] = task

src/pythinker_code/subagents/core.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import html
1112
import re
1213
from collections.abc import Callable, Sequence
1314
from dataclasses import dataclass, replace
@@ -21,12 +22,18 @@
2122
from pythinker_code.soul.message import is_system_reminder_message
2223
from pythinker_code.soul.pythinkersoul import PythinkerSoul
2324
from pythinker_code.subagents.builder import SubagentBuilder
25+
from pythinker_code.subagents.git_context import collect_git_context
2426
from pythinker_code.subagents.models import AgentLaunchSpec, AgentTypeDefinition
27+
from pythinker_code.subagents.review_target import (
28+
REVIEWER_AGENT_TYPES,
29+
ResolvedReviewTarget,
30+
revalidate_review_target_head,
31+
)
2532
from pythinker_code.subagents.store import SubagentStore
2633

2734
# NOTE: these must match the registered type names in agents/default/agent.yaml
2835
# (dashed), which _SUBAGENT_PROFILES also keys on — not the yaml file stems.
29-
GIT_CONTEXT_AGENT_TYPES = frozenset({"explore", "review", "code-reviewer", "security-reviewer"})
36+
GIT_CONTEXT_AGENT_TYPES = frozenset({"explore"}) | REVIEWER_AGENT_TYPES
3037
"""Read-oriented agent types whose first prompt gets a git-context prefix.
3138
3239
Exploration and review both orient on repo state (branch, dirty files,
@@ -63,6 +70,7 @@ class SubagentRunSpec:
6370
# Operational work-dir override (e.g. an isolation worktree); flows into
6471
# the child runtime via copy_for_subagent.
6572
work_dir_override: HostPath | None = None
73+
resolved_review_target: ResolvedReviewTarget | None = None
6674

6775

6876
_CHECKPOINT_MARKER_RE = re.compile(r"^CHECKPOINT \d+$")
@@ -116,6 +124,12 @@ def _prepend_output_language_instruction(prompt: str) -> str:
116124
return f"{SUBAGENT_OUTPUT_LANGUAGE_INSTRUCTION}\n\n{prompt}"
117125

118126

127+
def _compose_review_prompt(caller_prompt: str, target: ResolvedReviewTarget) -> str:
128+
"""Keep caller instructions subordinate to the authoritative resolved target."""
129+
safe_caller_prompt = html.escape(caller_prompt, quote=False)
130+
return f"<review-task>\n{safe_caller_prompt}\n</review-task>\n\n{target.prompt}"
131+
132+
119133
async def prepare_soul(
120134
spec: SubagentRunSpec,
121135
runtime: Runtime,
@@ -154,16 +168,30 @@ async def prepare_soul(
154168
if on_stage:
155169
on_stage("context_ready")
156170

157-
# 4. For new (non-resumed) read-oriented agents, prepend git context to the prompt
171+
# 4. Compose the authoritative prompt for this run.
158172
prompt = spec.prompt
173+
git_context_dir = spec.work_dir_override or runtime.builtin_args.PYTHINKER_WORK_DIR
174+
is_reviewer = spec.type_def.name in REVIEWER_AGENT_TYPES
175+
if spec.resumed and spec.resolved_review_target is not None:
176+
raise RuntimeError("A resumed subagent cannot receive a new resolved review target.")
177+
if not spec.resumed and is_reviewer and spec.resolved_review_target is None:
178+
raise RuntimeError("A fresh reviewer requires a resolved review target.")
179+
if not is_reviewer and spec.resolved_review_target is not None:
180+
raise RuntimeError("A non-reviewer cannot receive a resolved review target.")
181+
182+
git_ctx = ""
159183
if spec.type_def.name in GIT_CONTEXT_AGENT_TYPES and not spec.resumed:
160-
from pythinker_code.subagents.git_context import collect_git_context
161-
162-
git_context_dir = spec.work_dir_override or runtime.builtin_args.PYTHINKER_WORK_DIR
163-
git_ctx = await collect_git_context(git_context_dir)
164-
if git_ctx:
165-
prompt = f"{git_ctx}\n\n{prompt}"
184+
git_ctx = await collect_git_context(
185+
git_context_dir,
186+
include_merge_base=not is_reviewer,
187+
)
188+
if spec.resolved_review_target is not None:
189+
prompt = _compose_review_prompt(prompt, spec.resolved_review_target)
190+
if git_ctx:
191+
prompt = f"{git_ctx}\n\n{prompt}"
166192
prompt = _prepend_output_language_instruction(prompt)
193+
if spec.resolved_review_target is not None:
194+
await revalidate_review_target_head(spec.resolved_review_target, git_context_dir)
167195

168196
# 5. Write prompt snapshot (debugging aid)
169197
store.prompt_path(spec.agent_id).write_text(prompt, encoding="utf-8")

0 commit comments

Comments
 (0)