semantic-code-review 0.24.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- semantic_code_review/__init__.py +5 -0
- semantic_code_review/augment/__init__.py +3 -0
- semantic_code_review/augment/agents.py +95 -0
- semantic_code_review/augment/console.py +440 -0
- semantic_code_review/augment/extra_review.py +237 -0
- semantic_code_review/augment/fold_summary.py +447 -0
- semantic_code_review/augment/hunks.py +264 -0
- semantic_code_review/augment/mcp_server.py +156 -0
- semantic_code_review/augment/overview.py +233 -0
- semantic_code_review/augment/pass_.py +171 -0
- semantic_code_review/augment/pipeline.py +571 -0
- semantic_code_review/augment/progress.py +223 -0
- semantic_code_review/augment/prompts.py +93 -0
- semantic_code_review/augment/schemas.py +408 -0
- semantic_code_review/augment/tools.py +475 -0
- semantic_code_review/augment/trace_adapter.py +369 -0
- semantic_code_review/backends/__init__.py +95 -0
- semantic_code_review/backends/_cli_driver.py +571 -0
- semantic_code_review/backends/anthropic_sdk.py +56 -0
- semantic_code_review/backends/base.py +104 -0
- semantic_code_review/backends/claude_cli.py +421 -0
- semantic_code_review/backends/google_sdk.py +73 -0
- semantic_code_review/backends/openai_compat.py +42 -0
- semantic_code_review/cache/__init__.py +3 -0
- semantic_code_review/cache/store.py +87 -0
- semantic_code_review/cli/__init__.py +70 -0
- semantic_code_review/cli/_shared.py +157 -0
- semantic_code_review/cli/augment.py +74 -0
- semantic_code_review/cli/config_cmd.py +266 -0
- semantic_code_review/cli/fetch.py +33 -0
- semantic_code_review/cli/lint.py +27 -0
- semantic_code_review/cli/pr.py +98 -0
- semantic_code_review/cli/review.py +104 -0
- semantic_code_review/cli/runs_cmd.py +17 -0
- semantic_code_review/cli/show.py +19 -0
- semantic_code_review/cli/strip.py +20 -0
- semantic_code_review/config.py +563 -0
- semantic_code_review/config_template.py +154 -0
- semantic_code_review/fetch/__init__.py +61 -0
- semantic_code_review/fetch/anchor.py +244 -0
- semantic_code_review/fetch/github.py +229 -0
- semantic_code_review/fetch/github_comments.py +404 -0
- semantic_code_review/fetch/local.py +443 -0
- semantic_code_review/fetch/run_source.py +70 -0
- semantic_code_review/format/__init__.py +3 -0
- semantic_code_review/format/emit.py +163 -0
- semantic_code_review/format/lint.py +74 -0
- semantic_code_review/format/parse.py +574 -0
- semantic_code_review/format/sidecar.py +19 -0
- semantic_code_review/format/strip.py +20 -0
- semantic_code_review/git_ops.py +252 -0
- semantic_code_review/paths.py +61 -0
- semantic_code_review/review/__init__.py +3 -0
- semantic_code_review/review/comments.py +176 -0
- semantic_code_review/review/github.py +284 -0
- semantic_code_review/review/github_graphql.py +433 -0
- semantic_code_review/review/pr_flow.py +299 -0
- semantic_code_review/review/runner.py +407 -0
- semantic_code_review/review/server.py +1043 -0
- semantic_code_review/structural/__init__.py +41 -0
- semantic_code_review/structural/diff.py +102 -0
- semantic_code_review/structural/parse.py +330 -0
- semantic_code_review/structural/symbols.py +43 -0
- semantic_code_review/viewer/__init__.py +3 -0
- semantic_code_review/viewer/assets/annotations.ts +547 -0
- semantic_code_review/viewer/assets/boot.ts +273 -0
- semantic_code_review/viewer/assets/comment_store.ts +121 -0
- semantic_code_review/viewer/assets/comments.ts +624 -0
- semantic_code_review/viewer/assets/console.ts +426 -0
- semantic_code_review/viewer/assets/console_render.ts +213 -0
- semantic_code_review/viewer/assets/console_selection.ts +102 -0
- semantic_code_review/viewer/assets/data_store.ts +156 -0
- semantic_code_review/viewer/assets/file_rows.ts +49 -0
- semantic_code_review/viewer/assets/folds.ts +560 -0
- semantic_code_review/viewer/assets/index.html +62 -0
- semantic_code_review/viewer/assets/post_modal.ts +383 -0
- semantic_code_review/viewer/assets/progress.ts +138 -0
- semantic_code_review/viewer/assets/render.ts +937 -0
- semantic_code_review/viewer/assets/sidebar.ts +429 -0
- semantic_code_review/viewer/assets/sse.ts +78 -0
- semantic_code_review/viewer/assets/text_highlight.ts +215 -0
- semantic_code_review/viewer/assets/types.d.ts +388 -0
- semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
- semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
- semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
- semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
- semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
- semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
- semantic_code_review/viewer/assets/viewer.css +1202 -0
- semantic_code_review/viewer/assets/viewer.js +10588 -0
- semantic_code_review/viewer/build_json.py +546 -0
- semantic_code_review/viewer/hunk_layout.py +471 -0
- semantic_code_review-0.24.2.dist-info/METADATA +348 -0
- semantic_code_review-0.24.2.dist-info/RECORD +100 -0
- semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
- semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
- semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
- semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Pydantic-ai Agent factories for both SDK and CLI backends.
|
|
2
|
+
|
|
3
|
+
Two factories — one per pass — that wire the right `output_type`,
|
|
4
|
+
instructions, and tool set. The Agent itself is stateless across runs;
|
|
5
|
+
the per-run `RepoTools` is supplied via `deps=` to `Agent.run`.
|
|
6
|
+
|
|
7
|
+
`model` accepts either a fully-qualified pydantic-ai model id string
|
|
8
|
+
(e.g. `anthropic:claude-opus-4-7` or `google-vertex:gemini-2.5-pro`)
|
|
9
|
+
*or* a `pydantic_ai.models.Model` instance — the CLI driver in
|
|
10
|
+
`backends/claude_cli.py` is a Model subclass that wraps the
|
|
11
|
+
`claude -p` client.
|
|
12
|
+
`cli._select_client` is the single place that decides which form an
|
|
13
|
+
unqualified model name maps to.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
from pydantic_ai import Agent
|
|
21
|
+
from pydantic_ai.models import Model
|
|
22
|
+
from pydantic_ai.output import ToolOutput
|
|
23
|
+
|
|
24
|
+
from .prompts import HUNK_SYSTEM, OVERVIEW_SYSTEM
|
|
25
|
+
from .schemas import HunkAnnotations, OverviewSubmission
|
|
26
|
+
from .tools import TOOL_FUNCTIONS, RepoTools
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def make_overview_agent(model: str | Model) -> Agent[None, OverviewSubmission]:
|
|
30
|
+
"""Agent for the PR-level overview pass.
|
|
31
|
+
|
|
32
|
+
No repo tools are registered — the overview pass works purely from
|
|
33
|
+
the diffstat + hunk headers in its prompt. Output is constrained
|
|
34
|
+
via `ToolOutput(OverviewSubmission, name='submit_overview')`.
|
|
35
|
+
"""
|
|
36
|
+
return Agent(
|
|
37
|
+
model=model,
|
|
38
|
+
output_type=ToolOutput(OverviewSubmission, name="submit_overview"),
|
|
39
|
+
instructions=OVERVIEW_SYSTEM,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_hunk_agent(model: str | Model) -> Agent[RepoTools, HunkAnnotations]:
|
|
44
|
+
"""Agent for the per-hunk annotation pass.
|
|
45
|
+
|
|
46
|
+
Registers the repo tool functions so the SDK Agent can `read_file`,
|
|
47
|
+
`grep`, etc. against the run's worktree. CLI backends ignore
|
|
48
|
+
`function_tools` — they expose the same tools to the underlying
|
|
49
|
+
subprocess via the MCP server. Output is constrained via
|
|
50
|
+
`ToolOutput(HunkAnnotations, name='submit_annotations')`.
|
|
51
|
+
"""
|
|
52
|
+
return Agent(
|
|
53
|
+
model=model,
|
|
54
|
+
deps_type=RepoTools,
|
|
55
|
+
output_type=ToolOutput(HunkAnnotations, name="submit_annotations"),
|
|
56
|
+
instructions=HUNK_SYSTEM,
|
|
57
|
+
tools=TOOL_FUNCTIONS,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Client:
|
|
63
|
+
"""Pipeline-side handle for an LLM backend.
|
|
64
|
+
|
|
65
|
+
Holds either a pydantic-ai model id string (for SDK backends) or
|
|
66
|
+
a `pydantic_ai.models.Model` instance (for CLI subprocess backends —
|
|
67
|
+
the CLI drivers under `backends/`). The pipeline calls
|
|
68
|
+
`make_*_agent(client.model)` to build pass-specific agents.
|
|
69
|
+
|
|
70
|
+
`set_repo_tools` proxies to the inner CLI Model when present so the
|
|
71
|
+
subprocess can spawn an MCP server bound to the run's worktree;
|
|
72
|
+
SDK string models have no repo-tool concept here — the SDK Agent
|
|
73
|
+
receives `deps=repo_tools` at `Agent.run` call time. The pipeline
|
|
74
|
+
calls both: `client.set_repo_tools(rt)` for the CLI side, and
|
|
75
|
+
passes `rt` as `deps=` for the SDK side.
|
|
76
|
+
|
|
77
|
+
`aclose()` is delegated to the inner Model. SDK string models have
|
|
78
|
+
no per-run resources to release; the no-op fallthrough is fine.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
model: str | Model
|
|
82
|
+
is_subprocess_backend: bool = False
|
|
83
|
+
|
|
84
|
+
def set_repo_tools(self, repo_tools: RepoTools | None) -> None:
|
|
85
|
+
if isinstance(self.model, Model):
|
|
86
|
+
setter = getattr(self.model, "set_repo_tools", None)
|
|
87
|
+
if callable(setter):
|
|
88
|
+
setter(repo_tools)
|
|
89
|
+
|
|
90
|
+
async def aclose(self) -> None:
|
|
91
|
+
if isinstance(self.model, Model):
|
|
92
|
+
close = getattr(self.model, "aclose", None)
|
|
93
|
+
if callable(close):
|
|
94
|
+
# Dynamic probe: getattr erases the type, so pyright can't see the coroutine.
|
|
95
|
+
await close() # pyright: ignore[reportGeneralTypeIssues]
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Free-form review console agent.
|
|
2
|
+
|
|
3
|
+
The console is the first agent in the codebase without a `ToolOutput`
|
|
4
|
+
submit tool: it emits prose and calls the same read-only `RepoTools`
|
|
5
|
+
the augment passes use, plus a console-only `hunk(id)` diff accessor.
|
|
6
|
+
It is wired into the live `scr review` server after augmentation
|
|
7
|
+
completes, for both SDK and CLI subprocess backends (ADR 0002 —
|
|
8
|
+
console).
|
|
9
|
+
|
|
10
|
+
This module owns the agent factory, the compact first-turn seed, and
|
|
11
|
+
the turn drivers. The streaming driver (`stream_console_turn`, Slice 2)
|
|
12
|
+
drives `Agent.iter` and pumps text deltas + tool-activity out through
|
|
13
|
+
caller-supplied callbacks while polling a cancel flag between chunks.
|
|
14
|
+
CLI subprocess backends (Slice 5) can't stream, so `stream_console_turn`
|
|
15
|
+
detects them and falls back to a one-shot `Agent.run`
|
|
16
|
+
(`_run_console_turn_oneshot`): one `console-done` with the whole answer,
|
|
17
|
+
no intermediate deltas. `run_console_turn` is the Slice 1 blocking
|
|
18
|
+
shape, retained as a thin no-callback wrapper.
|
|
19
|
+
|
|
20
|
+
Context discipline (ADR 0002): **seed compact, pull on demand.** The
|
|
21
|
+
first turn carries the overview JSON + changed-file list + the
|
|
22
|
+
deterministic `SymbolDelta` (all bounded); bulk content comes through
|
|
23
|
+
tools, including `hunk(id)`. The seed rides the conversation's
|
|
24
|
+
`message_history`, so it is paid once, not re-injected per turn.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import threading
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from pydantic_ai import Agent
|
|
37
|
+
from pydantic_ai.messages import (
|
|
38
|
+
FunctionToolCallEvent,
|
|
39
|
+
PartDeltaEvent,
|
|
40
|
+
PartStartEvent,
|
|
41
|
+
TextPart,
|
|
42
|
+
TextPartDelta,
|
|
43
|
+
)
|
|
44
|
+
from pydantic_ai.models import Model
|
|
45
|
+
|
|
46
|
+
from .agents import Client
|
|
47
|
+
from .hunks import overview_to_prompt_json
|
|
48
|
+
from .tools import RepoTools, console_tool_functions
|
|
49
|
+
|
|
50
|
+
log = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
CONSOLE_SYSTEM = (
|
|
54
|
+
"You are a code-review console embedded in a live diff viewer. The "
|
|
55
|
+
"reviewer is reading one change (a PR or a local diff) and asks you "
|
|
56
|
+
"free-form questions about it: what calls this, why a guard exists, "
|
|
57
|
+
"what a refactor moved, whether an edge case is handled.\n\n"
|
|
58
|
+
"You have read-only tools over the change's base and head worktrees:\n"
|
|
59
|
+
" - read_file / read_file_at — file contents at head or any SHA\n"
|
|
60
|
+
" - grep — search the head worktree\n"
|
|
61
|
+
" - outline / symbol_at — deterministic tree-sitter symbol structure\n"
|
|
62
|
+
" - changed_symbols — the structural base->head delta\n"
|
|
63
|
+
" - list_dir / git_log — directory + history\n"
|
|
64
|
+
" - hunk(id) — the exact diff text of a hunk, by its 'H<file>_<hunk>' id\n\n"
|
|
65
|
+
"Ground every answer in the code. Prefer calling a tool over guessing; "
|
|
66
|
+
"when you state a fact about the code, cite it as `path:line` so the "
|
|
67
|
+
"reviewer can jump to it. The first message seeds you with the PR "
|
|
68
|
+
"overview, the changed-file list, and the deterministic symbol delta — "
|
|
69
|
+
"reach for tools for anything beyond that.\n\n"
|
|
70
|
+
"Be concise and direct. Answer the question asked; don't pad with "
|
|
71
|
+
"restatements or caveats. If the code doesn't settle the question, say "
|
|
72
|
+
"what you'd need to look at rather than speculating.\n\n"
|
|
73
|
+
"The reviewer may pin a selection to a question — highlighted code "
|
|
74
|
+
"(with its enclosing hunk inlined) or a comment. When a turn carries "
|
|
75
|
+
"a '# Reviewer selection' block, treat it as the subject of 'this' in "
|
|
76
|
+
"the question and ground your answer in it.\n\n"
|
|
77
|
+
"Your answers render as GitHub-flavoured markdown: use code spans for "
|
|
78
|
+
"identifiers and `path:line` citations, fenced code blocks (with a "
|
|
79
|
+
"language) for snippets, and lists or short headings to structure a "
|
|
80
|
+
"longer answer. Don't emit raw HTML. When — and only when — a diagram "
|
|
81
|
+
"genuinely clarifies the answer (a control- or data-flow, a call graph, "
|
|
82
|
+
"a state machine, a sequence of calls across files), emit it as a "
|
|
83
|
+
"```mermaid``` fenced block; for a plain factual answer, prose is "
|
|
84
|
+
"better than a diagram. Keep diagrams small and label nodes with the "
|
|
85
|
+
"real symbol names."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ConsoleNotReady(RuntimeError):
|
|
90
|
+
"""The run dir doesn't yet hold an `augmented.scr.json`.
|
|
91
|
+
|
|
92
|
+
Maps to HTTP 409 at the review-server boundary — augmentation is
|
|
93
|
+
still in flight or was skipped, so there's no diff to ground the
|
|
94
|
+
console against.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ConsoleCancelled(RuntimeError):
|
|
99
|
+
"""The reviewer cancelled an in-flight turn (Stop / Esc).
|
|
100
|
+
|
|
101
|
+
Raised by `stream_console_turn` when the cancel flag trips between
|
|
102
|
+
chunks. The background worker catches it and emits a `console-done`
|
|
103
|
+
with `cancelled: true`; the partial turn is discarded (its messages
|
|
104
|
+
never join the conversation history).
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def make_console_agent(model: str | Model) -> Agent[RepoTools, str]:
|
|
109
|
+
"""Free-form console agent: prose output (no `output_type`), the
|
|
110
|
+
`RepoTools` surface plus `hunk(id)`, and the `CONSOLE_SYSTEM`
|
|
111
|
+
persona. Stateless across turns; the per-turn `RepoTools` is passed
|
|
112
|
+
as `deps=` and the conversation rides `message_history`.
|
|
113
|
+
"""
|
|
114
|
+
return Agent(
|
|
115
|
+
model=model,
|
|
116
|
+
deps_type=RepoTools,
|
|
117
|
+
instructions=CONSOLE_SYSTEM,
|
|
118
|
+
tools=console_tool_functions(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_console_seed(diff: Any, *, symbol_delta_json: str | None) -> str:
|
|
123
|
+
"""Build the compact first-turn seed string.
|
|
124
|
+
|
|
125
|
+
Carries the overview JSON, a one-line-per-file changed-file list,
|
|
126
|
+
and the deterministic `SymbolDelta` JSON when available. Everything
|
|
127
|
+
here is already computed and bounded; bulk content is left to tools.
|
|
128
|
+
"""
|
|
129
|
+
files_lines: list[str] = []
|
|
130
|
+
for fp in diff.files:
|
|
131
|
+
role = fp.ann.role.value if getattr(fp.ann, "role", None) else "modified"
|
|
132
|
+
summary = (getattr(fp.ann, "summary", "") or "").strip().replace("\n", " ")
|
|
133
|
+
line = f"- {fp.path} ({role})"
|
|
134
|
+
if summary:
|
|
135
|
+
line += f" — {summary}"
|
|
136
|
+
files_lines.append(line)
|
|
137
|
+
files_block = "\n".join(files_lines) or "(no files)"
|
|
138
|
+
|
|
139
|
+
parts = [
|
|
140
|
+
f"# PR overview\n{overview_to_prompt_json(diff)}",
|
|
141
|
+
f"# Changed files\n{files_block}",
|
|
142
|
+
]
|
|
143
|
+
if symbol_delta_json:
|
|
144
|
+
parts.append(f"# Structural symbol delta (deterministic tree-sitter base->head)\n{symbol_delta_json}")
|
|
145
|
+
return "\n\n".join(parts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#: Defensive cap on the reviewer-supplied selection text folded into a
|
|
149
|
+
#: turn. The browser only ever sends what's visibly selected, but the
|
|
150
|
+
#: payload is untrusted, so bound it rather than trust the client.
|
|
151
|
+
_SELECTION_CAP = 4000
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_selection(selection: Any, repo_tools: RepoTools) -> str:
|
|
155
|
+
"""Render a reviewer's pinned selection as a prompt block, or ``""``.
|
|
156
|
+
|
|
157
|
+
The block names what was highlighted and quotes it; for a code
|
|
158
|
+
selection with a resolvable hunk id it also inlines the enclosing
|
|
159
|
+
hunk via the :meth:`RepoTools.hunk` accessor so the model sees the
|
|
160
|
+
selection in its diff context. Comment/plain selections carry just
|
|
161
|
+
the quoted text. An absent/empty/non-dict selection yields ``""``.
|
|
162
|
+
|
|
163
|
+
Wire shape (from the viewer's `console_selection.ts`):
|
|
164
|
+
``{selection_text, selection_kind, file?, side?, hunk_id?,
|
|
165
|
+
line_range?}``.
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(selection, dict):
|
|
168
|
+
return ""
|
|
169
|
+
text = str(selection.get("selection_text") or "").strip()
|
|
170
|
+
if not text:
|
|
171
|
+
return ""
|
|
172
|
+
if len(text) > _SELECTION_CAP:
|
|
173
|
+
text = text[:_SELECTION_CAP] + "\n…(truncated)"
|
|
174
|
+
kind = str(selection.get("selection_kind") or "plain")
|
|
175
|
+
|
|
176
|
+
where = ""
|
|
177
|
+
file = selection.get("file")
|
|
178
|
+
if kind == "code" and file:
|
|
179
|
+
where = f" in `{file}`"
|
|
180
|
+
rng = selection.get("line_range")
|
|
181
|
+
side = selection.get("side")
|
|
182
|
+
if isinstance(rng, (list, tuple)) and len(rng) == 2:
|
|
183
|
+
lo, hi = rng
|
|
184
|
+
span = f"line {lo}" if lo == hi else f"lines {lo}–{hi}"
|
|
185
|
+
side_txt = f", {side} side" if side in ("old", "new") else ""
|
|
186
|
+
where += f" ({span}{side_txt})"
|
|
187
|
+
|
|
188
|
+
parts = [
|
|
189
|
+
f"# Reviewer selection ({kind}){where}",
|
|
190
|
+
"The reviewer highlighted this and is asking about it:",
|
|
191
|
+
f"```\n{text}\n```",
|
|
192
|
+
]
|
|
193
|
+
if kind == "code":
|
|
194
|
+
hunk_id = selection.get("hunk_id")
|
|
195
|
+
if hunk_id:
|
|
196
|
+
hunk_text = repo_tools.hunk(str(hunk_id))
|
|
197
|
+
# A bad/absent hunk id degrades to text-only — never surface
|
|
198
|
+
# the accessor's error string into the prompt.
|
|
199
|
+
if not hunk_text.startswith("error:"):
|
|
200
|
+
parts += ["Enclosing hunk:", f"```diff\n{hunk_text}\n```"]
|
|
201
|
+
return "\n".join(parts)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _prepare_turn(
|
|
205
|
+
client: Client,
|
|
206
|
+
*,
|
|
207
|
+
run_dir: Path,
|
|
208
|
+
question: str,
|
|
209
|
+
history: list | None,
|
|
210
|
+
selection: Any = None,
|
|
211
|
+
) -> tuple[Agent[RepoTools, str], str, RepoTools]:
|
|
212
|
+
"""Shared per-turn setup: load the sidecar, bind `RepoTools`, build
|
|
213
|
+
the agent, and assemble the prompt (the compact seed prefix on the
|
|
214
|
+
first turn, the bare question thereafter).
|
|
215
|
+
|
|
216
|
+
When ``selection`` is present it is folded once into this turn's user
|
|
217
|
+
message, just ahead of the question — turn-anchored, so it rides the
|
|
218
|
+
conversation history and is never re-injected on later turns.
|
|
219
|
+
|
|
220
|
+
Raises :class:`ConsoleNotReady` if the augmented sidecar isn't on
|
|
221
|
+
disk yet. Returns ``(agent, prompt, repo_tools)`` ready to run or
|
|
222
|
+
iterate.
|
|
223
|
+
"""
|
|
224
|
+
sidecar = run_dir / "augmented.scr.json"
|
|
225
|
+
if not sidecar.exists():
|
|
226
|
+
raise ConsoleNotReady("augmented.scr.json missing — augment not complete")
|
|
227
|
+
|
|
228
|
+
# Lazy: keep the format machinery off the import path for the
|
|
229
|
+
# agent-factory-only callers.
|
|
230
|
+
from ..format.sidecar import load_sidecar
|
|
231
|
+
|
|
232
|
+
diff = load_sidecar(sidecar)
|
|
233
|
+
repo_tools = RepoTools(
|
|
234
|
+
head_worktree=run_dir / "head",
|
|
235
|
+
repo_git=run_dir / "repo.git",
|
|
236
|
+
base_sha=diff.pr.base_sha,
|
|
237
|
+
head_sha=diff.pr.head_sha,
|
|
238
|
+
diff=diff,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
selection_block = _format_selection(selection, repo_tools)
|
|
242
|
+
question_section = f"# Reviewer question\n{question}"
|
|
243
|
+
if selection_block:
|
|
244
|
+
question_section = f"{selection_block}\n\n{question_section}"
|
|
245
|
+
|
|
246
|
+
if history:
|
|
247
|
+
prompt = question_section
|
|
248
|
+
else:
|
|
249
|
+
# Best-effort structural seed: a parse failure leaves the seed
|
|
250
|
+
# without the delta rather than failing the turn.
|
|
251
|
+
symbol_delta_json: str | None = None
|
|
252
|
+
try:
|
|
253
|
+
symbol_delta_json = repo_tools.compute_symbol_delta().model_dump_json()
|
|
254
|
+
except Exception: # noqa: BLE001 — seed is best-effort
|
|
255
|
+
log.warning("console seed: symbol delta failed", exc_info=True)
|
|
256
|
+
seed = build_console_seed(diff, symbol_delta_json=symbol_delta_json)
|
|
257
|
+
prompt = f"{seed}\n\n{question_section}"
|
|
258
|
+
|
|
259
|
+
return make_console_agent(client.model), prompt, repo_tools
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _delta_text(event: Any) -> str:
|
|
263
|
+
"""Pull streamed assistant text out of a pydantic-ai stream event.
|
|
264
|
+
|
|
265
|
+
A `TextPart` opens with its first chunk in `PartStartEvent`; the
|
|
266
|
+
rest arrive as `TextPartDelta`s. Everything else (tool-call parts,
|
|
267
|
+
thinking deltas) yields the empty string and is skipped by callers.
|
|
268
|
+
"""
|
|
269
|
+
if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
|
|
270
|
+
return event.part.content
|
|
271
|
+
if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
|
|
272
|
+
return event.delta.content_delta
|
|
273
|
+
return ""
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _tool_label(part: Any) -> str:
|
|
277
|
+
"""A compact, human-readable label for a tool call — e.g.
|
|
278
|
+
``grep RepoTools`` or ``read_file src/users.py``.
|
|
279
|
+
|
|
280
|
+
Surfaces the tool name plus a representative scalar argument so the
|
|
281
|
+
reviewer sees *what* the console is reaching for, not just *that* it
|
|
282
|
+
is. Args arrive as a dict or a JSON string depending on the backend.
|
|
283
|
+
"""
|
|
284
|
+
name = getattr(part, "tool_name", None) or "tool"
|
|
285
|
+
args = getattr(part, "args", None)
|
|
286
|
+
if isinstance(args, str):
|
|
287
|
+
try:
|
|
288
|
+
args = json.loads(args)
|
|
289
|
+
except ValueError:
|
|
290
|
+
args = None
|
|
291
|
+
if isinstance(args, dict):
|
|
292
|
+
for value in args.values():
|
|
293
|
+
text = str(value).strip()
|
|
294
|
+
if text:
|
|
295
|
+
return f"{name} {text[:60]}"
|
|
296
|
+
return name
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def _run_console_turn_oneshot(
|
|
300
|
+
client: Client,
|
|
301
|
+
agent: Agent[RepoTools, str],
|
|
302
|
+
prompt: str,
|
|
303
|
+
repo_tools: RepoTools,
|
|
304
|
+
*,
|
|
305
|
+
history: list | None,
|
|
306
|
+
cancel: threading.Event | None,
|
|
307
|
+
) -> tuple[str, list]:
|
|
308
|
+
"""Run one console turn to completion via `Agent.run` (CLI backends).
|
|
309
|
+
|
|
310
|
+
The CLI driver exposes the worktree to the subprocess through MCP,
|
|
311
|
+
not pydantic-ai `deps`, so `RepoTools` is bound onto the client's
|
|
312
|
+
inner Model for the duration of the call (and unbound after, which
|
|
313
|
+
also cleans up the temp MCP-config file). `deps=` is still passed so
|
|
314
|
+
the agent's tool surface validates. Cancel is best-effort: the
|
|
315
|
+
subprocess runs as one opaque shot, so we honour the flag before and
|
|
316
|
+
after rather than mid-flight.
|
|
317
|
+
"""
|
|
318
|
+
if cancel is not None and cancel.is_set():
|
|
319
|
+
raise ConsoleCancelled("console turn cancelled")
|
|
320
|
+
client.set_repo_tools(repo_tools)
|
|
321
|
+
try:
|
|
322
|
+
result = await agent.run(prompt, deps=repo_tools, message_history=history)
|
|
323
|
+
finally:
|
|
324
|
+
client.set_repo_tools(None)
|
|
325
|
+
if cancel is not None and cancel.is_set():
|
|
326
|
+
raise ConsoleCancelled("console turn cancelled")
|
|
327
|
+
return result.output, list(result.all_messages())
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def stream_console_turn(
|
|
331
|
+
client: Client,
|
|
332
|
+
*,
|
|
333
|
+
run_dir: Path,
|
|
334
|
+
question: str,
|
|
335
|
+
history: list | None = None,
|
|
336
|
+
on_delta: Callable[[str], None] | None = None,
|
|
337
|
+
on_tool: Callable[[str], None] | None = None,
|
|
338
|
+
cancel: threading.Event | None = None,
|
|
339
|
+
selection: Any = None,
|
|
340
|
+
) -> tuple[str, list]:
|
|
341
|
+
"""Stream one console turn, returning ``(answer, new_history)``.
|
|
342
|
+
|
|
343
|
+
Drives the agent via ``Agent.iter`` so assistant text can be pumped
|
|
344
|
+
to ``on_delta`` chunk-by-chunk and each tool invocation announced to
|
|
345
|
+
``on_tool`` as it fires. Between chunks it polls ``cancel`` (a
|
|
346
|
+
``threading.Event`` flipped from another thread by ``/console/cancel``)
|
|
347
|
+
and raises :class:`ConsoleCancelled` when set — the partial turn is
|
|
348
|
+
abandoned and its messages never join the returned history.
|
|
349
|
+
|
|
350
|
+
`history` is the opaque prior `message_history` (None on the first
|
|
351
|
+
turn); the returned list is the full history to carry forward.
|
|
352
|
+
`selection`, when present, is the reviewer's pinned selection, folded
|
|
353
|
+
once into this turn's user message (see :func:`_format_selection`).
|
|
354
|
+
"""
|
|
355
|
+
agent, prompt, repo_tools = _prepare_turn(
|
|
356
|
+
client,
|
|
357
|
+
run_dir=run_dir,
|
|
358
|
+
question=question,
|
|
359
|
+
history=history,
|
|
360
|
+
selection=selection,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# CLI backends (Slice 5) can't stream: the subprocess runs its own
|
|
364
|
+
# opaque tool loop and returns the whole answer at once, and
|
|
365
|
+
# `Agent.iter`'s per-node streaming would hit the driver's
|
|
366
|
+
# unimplemented `request_stream`. Run one-shot via `Agent.run`
|
|
367
|
+
# instead — the worker emits a single `console-done` with the full
|
|
368
|
+
# text, which the frontend renders identically to "zero deltas, one
|
|
369
|
+
# done". `on_delta`/`on_tool` simply never fire.
|
|
370
|
+
if client.is_subprocess_backend:
|
|
371
|
+
return await _run_console_turn_oneshot(
|
|
372
|
+
client,
|
|
373
|
+
agent,
|
|
374
|
+
prompt,
|
|
375
|
+
repo_tools,
|
|
376
|
+
history=history,
|
|
377
|
+
cancel=cancel,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def cancelled() -> bool:
|
|
381
|
+
return cancel is not None and cancel.is_set()
|
|
382
|
+
|
|
383
|
+
abort = False
|
|
384
|
+
async with agent.iter(
|
|
385
|
+
prompt,
|
|
386
|
+
deps=repo_tools,
|
|
387
|
+
message_history=history,
|
|
388
|
+
) as run:
|
|
389
|
+
async for node in run:
|
|
390
|
+
if cancelled():
|
|
391
|
+
abort = True
|
|
392
|
+
break
|
|
393
|
+
if Agent.is_model_request_node(node):
|
|
394
|
+
async with node.stream(run.ctx) as stream:
|
|
395
|
+
async for event in stream:
|
|
396
|
+
if cancelled():
|
|
397
|
+
abort = True
|
|
398
|
+
break
|
|
399
|
+
chunk = _delta_text(event)
|
|
400
|
+
if chunk and on_delta is not None:
|
|
401
|
+
on_delta(chunk)
|
|
402
|
+
if abort:
|
|
403
|
+
break
|
|
404
|
+
elif Agent.is_call_tools_node(node):
|
|
405
|
+
async with node.stream(run.ctx) as stream:
|
|
406
|
+
async for event in stream:
|
|
407
|
+
if isinstance(event, FunctionToolCallEvent) and on_tool is not None:
|
|
408
|
+
on_tool(_tool_label(event.part))
|
|
409
|
+
|
|
410
|
+
if abort:
|
|
411
|
+
raise ConsoleCancelled("console turn cancelled")
|
|
412
|
+
result = run.result
|
|
413
|
+
# Non-None once the iteration runs to completion (only the cancel
|
|
414
|
+
# path leaves it unset, and that raised above).
|
|
415
|
+
assert result is not None, "agent run finished without a result"
|
|
416
|
+
return result.output, list(result.all_messages())
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def run_console_turn(
|
|
420
|
+
client: Client,
|
|
421
|
+
*,
|
|
422
|
+
run_dir: Path,
|
|
423
|
+
question: str,
|
|
424
|
+
history: list | None = None,
|
|
425
|
+
selection: Any = None,
|
|
426
|
+
) -> tuple[str, list]:
|
|
427
|
+
"""Run one console turn to completion and return (answer, new_history).
|
|
428
|
+
|
|
429
|
+
The Slice 1 blocking shape, retained for the non-streaming callers
|
|
430
|
+
and tests: a thin wrapper over :func:`stream_console_turn` with no
|
|
431
|
+
callbacks and no cancel flag, so it accumulates the full answer and
|
|
432
|
+
returns it once the agent finishes.
|
|
433
|
+
"""
|
|
434
|
+
return await stream_console_turn(
|
|
435
|
+
client,
|
|
436
|
+
run_dir=run_dir,
|
|
437
|
+
question=question,
|
|
438
|
+
history=history,
|
|
439
|
+
selection=selection,
|
|
440
|
+
)
|