coderio 0.4.0__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.
- coderio/__init__.py +14 -0
- coderio/agent/__init__.py +5 -0
- coderio/agent/_deepagents_compat.py +139 -0
- coderio/agent/command_review.py +140 -0
- coderio/agent/deep_loop.py +758 -0
- coderio/agent/harness.py +768 -0
- coderio/agent/harness_middleware.py +293 -0
- coderio/agent/permission_middleware.py +52 -0
- coderio/agent/prompts.py +304 -0
- coderio/agent/skill_tool.py +70 -0
- coderio/agent/state.py +144 -0
- coderio/agent/stream.py +93 -0
- coderio/cli/__init__.py +0 -0
- coderio/cli/app.py +257 -0
- coderio/cli/commands.py +339 -0
- coderio/cli/credentials.py +70 -0
- coderio/cli/mcp_cmd.py +227 -0
- coderio/cli/multimodal.py +94 -0
- coderio/cli/onboarding.py +270 -0
- coderio/cli/providers.py +113 -0
- coderio/cli/render.py +28 -0
- coderio/cli/repl.py +189 -0
- coderio/cli/run_cmd.py +171 -0
- coderio/cli/skills_cmd.py +77 -0
- coderio/cli/stream.py +224 -0
- coderio/cli/tui.py +1584 -0
- coderio/cli/tui_onboarding.py +465 -0
- coderio/cli/tui_screens.py +334 -0
- coderio/cli/tui_widgets.py +453 -0
- coderio/config/__init__.py +21 -0
- coderio/config/bootstrap.py +38 -0
- coderio/config/loader.py +273 -0
- coderio/config/models.py +193 -0
- coderio/config/trust.py +136 -0
- coderio/llm/__init__.py +3 -0
- coderio/llm/factory.py +164 -0
- coderio/llm/probe.py +153 -0
- coderio/mcp_loader.py +274 -0
- coderio/session/__init__.py +3 -0
- coderio/session/message.py +79 -0
- coderio/session/store.py +244 -0
- coderio/skills/__init__.py +4 -0
- coderio/skills/bundled/executing-plans/SKILL.md +15 -0
- coderio/skills/lion-skills/LICENSE +21 -0
- coderio/skills/lion-skills/README.md +114 -0
- coderio/skills/lion-skills/skills/clarifying-questions/SKILL.md +119 -0
- coderio/skills/lion-skills/skills/clarifying-questions/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/code-review/SKILL.md +102 -0
- coderio/skills/lion-skills/skills/code-review/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/commit-message/SKILL.md +97 -0
- coderio/skills/lion-skills/skills/commit-message/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/debugging/SKILL.md +116 -0
- coderio/skills/lion-skills/skills/debugging/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/error-handling/SKILL.md +74 -0
- coderio/skills/lion-skills/skills/error-handling/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/lion-writing-skills/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/naming/SKILL.md +85 -0
- coderio/skills/lion-skills/skills/naming/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/SKILL.md +95 -0
- coderio/skills/lion-skills/skills/onboarding-unknown-codebase/evals/evals.json +20 -0
- coderio/skills/lion-skills/skills/spec-writing/SKILL.md +154 -0
- coderio/skills/lion-skills/skills/spec-writing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/task-breakdown/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/task-breakdown/evals/evals.json +46 -0
- coderio/skills/lion-skills/skills/testing/SKILL.md +134 -0
- coderio/skills/lion-skills/skills/testing/evals/evals.json +40 -0
- coderio/skills/lion-skills/skills/verify-and-fix/SKILL.md +132 -0
- coderio/skills/lion-skills/skills/verify-and-fix/evals/evals.json +40 -0
- coderio/skills/models.py +66 -0
- coderio/skills/parser.py +50 -0
- coderio/skills/store.py +102 -0
- coderio/tools/__init__.py +94 -0
- coderio/tools/base.py +55 -0
- coderio/tools/bash.py +168 -0
- coderio/tools/command_policy.py +364 -0
- coderio/tools/edit_file.py +55 -0
- coderio/tools/glob_tool.py +21 -0
- coderio/tools/grep_tool.py +90 -0
- coderio/tools/linux_sandbox.py +196 -0
- coderio/tools/list_dir.py +58 -0
- coderio/tools/multi_edit.py +62 -0
- coderio/tools/note.py +72 -0
- coderio/tools/permission.py +214 -0
- coderio/tools/read_file.py +51 -0
- coderio/tools/sandbox_runner.py +113 -0
- coderio/tools/todo.py +71 -0
- coderio/tools/web_fetch.py +188 -0
- coderio/tools/web_search.py +49 -0
- coderio/tools/win_job.py +214 -0
- coderio/tools/win_sandbox.py +639 -0
- coderio/tools/write_file.py +25 -0
- coderio-0.4.0.dist-info/METADATA +490 -0
- coderio-0.4.0.dist-info/RECORD +97 -0
- coderio-0.4.0.dist-info/WHEEL +4 -0
- coderio-0.4.0.dist-info/entry_points.txt +2 -0
- coderio-0.4.0.dist-info/licenses/LICENSE +21 -0
coderio/agent/harness.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
"""Harness state control for the single-agent loop (spec: 2026-06-26-coderio-harness-state-control).
|
|
2
|
+
|
|
3
|
+
This module turns the "write code then claim done without verifying" failure mode
|
|
4
|
+
from a soft prompt rule into a STRUCTURAL constraint. The harness sits inside
|
|
5
|
+
``run_deep_agent`` and controls the one thing the model cannot override: whether a
|
|
6
|
+
"no tool_calls" response actually TERMINATES the turn, or gets force-continued with
|
|
7
|
+
an injected message. All decisions are based on observed tool calls/results
|
|
8
|
+
(ground truth), never on the model's self-report.
|
|
9
|
+
|
|
10
|
+
Four gates (spec §1):
|
|
11
|
+
* PlanGate (soft) — nudge to decompose before writing, when no todos exist.
|
|
12
|
+
* VerifyGate (hard) — block "done" while code is written-but-not-run.
|
|
13
|
+
* CompletionGate (hard) — block "done" while non-trivial todos remain pending.
|
|
14
|
+
* GroundingGate (hard) — block "done" when the model cites code locations it
|
|
15
|
+
never actually read (analyses built on assumptions,
|
|
16
|
+
not evidence). Guards the ANALYZE/QA failure mode where
|
|
17
|
+
the model trusts documentation or memory over source.
|
|
18
|
+
|
|
19
|
+
VerifyGate + CompletionGate + GroundingGate use progressive escalation:
|
|
20
|
+
force-continue twice, then release with a visible warning (never silently, never
|
|
21
|
+
infinite-loop).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from coderio.tools.todo import TodoStore
|
|
32
|
+
|
|
33
|
+
# Tools that mutate files on disk. A successful one of these creates an
|
|
34
|
+
# "unverified write" that the VerifyGate watches.
|
|
35
|
+
WRITE_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "multi_edit"})
|
|
36
|
+
|
|
37
|
+
# Read-only tools that "ground" a claim about a file — if the model cited a path
|
|
38
|
+
# and one of these tools touched it, the citation is evidence-backed.
|
|
39
|
+
READ_TOOLS: frozenset[str] = frozenset({"read_file", "grep", "list_dir", "glob"})
|
|
40
|
+
# Tools that actually READ FILE CONTENTS. Only these ground a citation about a
|
|
41
|
+
# file's internals — grep only matches a pattern (the model never sees the full
|
|
42
|
+
# file), and list_dir/glob return NAMES without contents. Crediting them would
|
|
43
|
+
# let the model cite "loader.py:81 does X" after a `grep pattern="loader"` or a
|
|
44
|
+
# `list_dir("src")` that only returned filenames.
|
|
45
|
+
CONTENT_READ_TOOLS: frozenset[str] = frozenset({"read_file"})
|
|
46
|
+
|
|
47
|
+
# Running a shell command counts as a verification attempt — even a failing one
|
|
48
|
+
# means the agent tried to run its code, so we stop nagging. This avoids both
|
|
49
|
+
# "wrote then claimed done" and infinite "it errored, keep trying" loops.
|
|
50
|
+
VERIFY_TOOL: str = "bash"
|
|
51
|
+
|
|
52
|
+
# After this many forced-continues a gate gives up and releases with a warning.
|
|
53
|
+
_MAX_GATE_ATTEMPTS: int = 2
|
|
54
|
+
|
|
55
|
+
# Match file paths the model might cite in an analysis: "loader.py", "src/x.py",
|
|
56
|
+
# "loader.py:81", "agent/loop.py". Requires a dotted extension so ordinary prose
|
|
57
|
+
# like "the loader" or "go to step 2" doesn't false-positive. Anchored to a word
|
|
58
|
+
# boundary on the left; the path body may include /, \, word chars, dash, dot.
|
|
59
|
+
_CITED_FILE_RE = re.compile(
|
|
60
|
+
r"(?<![\w/.\\-])" # not preceded by word/slash/dot/dash (prevents matching
|
|
61
|
+
# `self._live.py` — the `.` before `_live` blocks it)
|
|
62
|
+
r"(?:[\w./\\-]+[\\/])?" # optional dir prefix (src/, agent\)
|
|
63
|
+
r"(?:"
|
|
64
|
+
r"[\w-]+\." # basename stem + dot + extension
|
|
65
|
+
r"(?:py|js|ts|tsx|jsx|md|json|toml|yaml|yml|rs|go|java|rb|sh|css|html|sql|c|cpp|h|hpp|php|swift|kt|scala|lua|vim|el)"
|
|
66
|
+
r"|"
|
|
67
|
+
r"(?:Dockerfile|Makefile|makefile|Gemfile|Rakefile|CMakeLists\.txt|\.env|\.gitignore|\.dockerignore|requirements\.txt|package\.json|tsconfig\.json|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock)"
|
|
68
|
+
r")"
|
|
69
|
+
r"(?::\d+)?" # optional :line-number
|
|
70
|
+
r"(?![\w])" # not followed by a word char
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _norm_path(p: str) -> str:
|
|
75
|
+
"""Normalize a path for read-state dedup.
|
|
76
|
+
|
|
77
|
+
Lowercases, converts backslashes to forward slashes, collapses redundant
|
|
78
|
+
'./' and '../' segments, and strips whitespace. On case-insensitive
|
|
79
|
+
filesystems (NTFS, APFS default) this correctly treats 'Loop.py' ==
|
|
80
|
+
'loop.py'; on case-sensitive filesystems it's slightly over-eager but the
|
|
81
|
+
worst case is one missed grounding check, never a false positive.
|
|
82
|
+
|
|
83
|
+
This is the single source of truth for path comparison in the harness —
|
|
84
|
+
observe() normalizes on write, _was_read() normalizes on compare, and the
|
|
85
|
+
cross-turn pre-fill normalizes when seeding content_read_files.
|
|
86
|
+
Without it, a model that reads 'Loop.py' then cites 'loop.py:81' gets
|
|
87
|
+
force-re-read by GroundingGate (observed in real sessions: 'Loop.py' was
|
|
88
|
+
read 5x in one turn purely from case drift).
|
|
89
|
+
"""
|
|
90
|
+
if not p:
|
|
91
|
+
return ""
|
|
92
|
+
p = p.strip().replace("\\", "/")
|
|
93
|
+
parts: list[str] = []
|
|
94
|
+
for part in p.split("/"):
|
|
95
|
+
if part in ("", "."):
|
|
96
|
+
continue
|
|
97
|
+
if part == "..":
|
|
98
|
+
if parts:
|
|
99
|
+
parts.pop()
|
|
100
|
+
continue
|
|
101
|
+
parts.append(part)
|
|
102
|
+
return "/".join(parts).lower()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_success(result: str) -> bool:
|
|
106
|
+
"""A write tool result counts as a real write unless it errored.
|
|
107
|
+
|
|
108
|
+
All three write tools report failures as ``"Error: ..."`` (verified against
|
|
109
|
+
write_file.py / edit_file.py / multi_edit.py). A failed write changed nothing
|
|
110
|
+
on disk, so it must not trigger the verify gate. Permission rejections from
|
|
111
|
+
the PermissionGate start with ``"Permission denied:"`` — those also changed
|
|
112
|
+
nothing, so they must not count as writes either.
|
|
113
|
+
"""
|
|
114
|
+
if not result:
|
|
115
|
+
return False
|
|
116
|
+
return not (result.startswith("Error") or result.startswith("Permission denied"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Matches BOTH exit-code marker formats the VerifyGate may see:
|
|
120
|
+
# 1. ``[exit_code: N]`` — coderio's legacy BashTool format (bash.py).
|
|
121
|
+
# 2. ``[Command failed with exit code N]`` / ``[Command succeeded with exit
|
|
122
|
+
# code N]`` — deepagents' native format (deepagents/middleware/
|
|
123
|
+
# filesystem.py appends this to every execute result's content).
|
|
124
|
+
#
|
|
125
|
+
# REGRESSION (2026-08-14 report P0-1): the production engine (deepagents)
|
|
126
|
+
# wraps ExecuteResponse into a ToolMessage whose .exit_code attribute is
|
|
127
|
+
# GONE — the exit code only survives as text in the content. The regex below
|
|
128
|
+
# originally only matched format 1, so _parse_exit_code returned None for
|
|
129
|
+
# every production execute call → failed tests were treated as "neutral
|
|
130
|
+
# pass" → writes_since_verify was cleared → VerifyGate never blocked "done"
|
|
131
|
+
# after a FAILING test run. The gate (the project's headline feature) was
|
|
132
|
+
# completely inert in production. Both formats must match from now on.
|
|
133
|
+
_EXIT_CODE_RE = re.compile(r"\[exit_code:\s*(-?\d+)\]" r"|\[Command (?:succeeded|failed) with exit code (-?\d+)\]")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_exit_code(result: str) -> int | None:
|
|
137
|
+
"""Extract the exit code from a shell-tool result, or None if absent.
|
|
138
|
+
|
|
139
|
+
Handles both marker formats (see _EXIT_CODE_RE): coderio's legacy
|
|
140
|
+
``\\n[exit_code: N]`` and deepagents' ``[Command failed|succeeded with
|
|
141
|
+
exit code N]``. When present, a non-zero value means the verification
|
|
142
|
+
FAILED (tests errored, build broke, lint failed) and must NOT clear the
|
|
143
|
+
unverified-writes list.
|
|
144
|
+
"""
|
|
145
|
+
m = _EXIT_CODE_RE.search(result or "")
|
|
146
|
+
if m:
|
|
147
|
+
# group(1) = coderio format, group(2) = deepagents format.
|
|
148
|
+
return int(m.group(1) or m.group(2))
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# File extensions that represent executable/buildable code — writing these
|
|
153
|
+
# triggers the VerifyGate (the model must run/test them before claiming done).
|
|
154
|
+
# Files NOT in this set (.md docs, .json configs, .yaml manifests, .txt notes,
|
|
155
|
+
# .toml, .cfg, .ini, .csv, images, etc.) skip the gate — they're verified by
|
|
156
|
+
# reading, not by running. This prevents the most common UX complaint: agent
|
|
157
|
+
# writes a .md analysis doc, harness forces pytest, pytest fails (wrong Python
|
|
158
|
+
# in bash env), agent concludes "environment is broken".
|
|
159
|
+
_VERIFIABLE_EXTS: frozenset[str] = frozenset(
|
|
160
|
+
{
|
|
161
|
+
".py",
|
|
162
|
+
".js",
|
|
163
|
+
".ts",
|
|
164
|
+
".tsx",
|
|
165
|
+
".jsx",
|
|
166
|
+
".go",
|
|
167
|
+
".rs",
|
|
168
|
+
".java",
|
|
169
|
+
".rb",
|
|
170
|
+
".sh",
|
|
171
|
+
".bash",
|
|
172
|
+
".zsh",
|
|
173
|
+
".c",
|
|
174
|
+
".cpp",
|
|
175
|
+
".h",
|
|
176
|
+
".hpp",
|
|
177
|
+
".cs",
|
|
178
|
+
".swift",
|
|
179
|
+
".kt",
|
|
180
|
+
".scala",
|
|
181
|
+
".lua",
|
|
182
|
+
".php",
|
|
183
|
+
".vim",
|
|
184
|
+
".sql",
|
|
185
|
+
# Build files that affect compilation/testing
|
|
186
|
+
".cmake",
|
|
187
|
+
".makefile",
|
|
188
|
+
# Web files that need to be tested in a browser/runtime
|
|
189
|
+
".html",
|
|
190
|
+
".css",
|
|
191
|
+
".vue",
|
|
192
|
+
".svelte",
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_verifiable_code(path: str) -> bool:
|
|
198
|
+
"""Should writing this file trigger the VerifyGate?
|
|
199
|
+
|
|
200
|
+
Returns True for source code files (.py, .js, .ts, etc.) and False for
|
|
201
|
+
documentation/config/data files (.md, .json, .yaml, .txt, .toml, etc.).
|
|
202
|
+
The gate exists to catch 'wrote code but didn't run it' — a markdown
|
|
203
|
+
analysis document doesn't need pytest to verify.
|
|
204
|
+
"""
|
|
205
|
+
import os
|
|
206
|
+
|
|
207
|
+
ext = os.path.splitext(path)[1].lower()
|
|
208
|
+
if ext in _VERIFIABLE_EXTS:
|
|
209
|
+
return True
|
|
210
|
+
# Special case: files with no extension but known code names
|
|
211
|
+
base = os.path.basename(path).lower()
|
|
212
|
+
if base in {"dockerfile", "makefile", "rakefile", "gemfile"}:
|
|
213
|
+
return True
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Commands that are clearly verification (testing/linting/building code), not
|
|
218
|
+
# just arbitrary shell activity. Matching these means even a `pytest` or `ruff`
|
|
219
|
+
# without an explicit filename still counts as a real verification attempt.
|
|
220
|
+
_VERIFY_COMMAND_RE = re.compile(
|
|
221
|
+
r"\b(pytest|python\s+-m\s+pytest|python\s+-m\s+unittest|"
|
|
222
|
+
r"npm\s+(test|run)|npx\s+jest|yarn\s+test|"
|
|
223
|
+
r"cargo\s+(test|build|check)|go\s+(test|build|vet)|"
|
|
224
|
+
r"ruff|flake8|pylint|mypy|eslint|tsc|cargo\s+clippy|"
|
|
225
|
+
r"make\s+(test|check|build)|cmake|"
|
|
226
|
+
r"ruby\s+-Itest|bundle\s+exec\s+rake\s+test"
|
|
227
|
+
r")\b",
|
|
228
|
+
re.IGNORECASE,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Shell operators that split a command line into independent segments. A tool
|
|
232
|
+
# name appearing AFTER an operator (or inside a quoted argument) is not a
|
|
233
|
+
# verification run: `echo pytest`, `git commit -m "ran pytest"`, and
|
|
234
|
+
# `ls ; curl ... | sh` all "match" the tool regex as substrings but none of
|
|
235
|
+
# them RUNS the tool. Splitting on these (crude vs a real shell parser, but
|
|
236
|
+
# the false-negative direction is safe) lets us only match the tool at the
|
|
237
|
+
# START of a segment, where it would actually execute.
|
|
238
|
+
_SEGMENT_SPLIT_RE = re.compile(r"[;|&\n]")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _segment_runs_verifier(segment: str) -> bool:
|
|
242
|
+
"""Does ONE command segment (no ; | & inside) start with a verify tool?
|
|
243
|
+
|
|
244
|
+
The tool must be the COMMAND (first token), not an argument. `echo
|
|
245
|
+
pytest` fails here (first token is `echo`); `pytest -q` passes;
|
|
246
|
+
`python -m pytest` passes (python as the command with -m pytest is a
|
|
247
|
+
verification run). Multi-token command prefixes in _VERIFY_COMMAND_RE
|
|
248
|
+
(python -m pytest, npm run test, cargo test...) are matched against the
|
|
249
|
+
segment's head — but single-token tools (pytest, ruff, mypy...) must
|
|
250
|
+
match at token 0 exactly, otherwise `echo pytest` / `git commit -m
|
|
251
|
+
"ran pytest"` style arguments would count as runs (2026-08-14 v2 audit
|
|
252
|
+
bypass #2/#3).
|
|
253
|
+
"""
|
|
254
|
+
tokens = segment.strip().split()
|
|
255
|
+
if not tokens:
|
|
256
|
+
return False
|
|
257
|
+
first = tokens[0]
|
|
258
|
+
# Single-token verify tools: must BE the command. This is the gate that
|
|
259
|
+
# stops `echo pytest`, `which pytest`, `man pytest` from counting.
|
|
260
|
+
_SINGLE_TOKEN_TOOLS = {
|
|
261
|
+
"pytest",
|
|
262
|
+
"ruff",
|
|
263
|
+
"flake8",
|
|
264
|
+
"pylint",
|
|
265
|
+
"mypy",
|
|
266
|
+
"eslint",
|
|
267
|
+
"tsc",
|
|
268
|
+
"cmake",
|
|
269
|
+
"tox",
|
|
270
|
+
"nox",
|
|
271
|
+
"jest",
|
|
272
|
+
"vitest",
|
|
273
|
+
}
|
|
274
|
+
if first.lower() in _SINGLE_TOKEN_TOOLS:
|
|
275
|
+
return True
|
|
276
|
+
# Multi-token command prefixes (python -m pytest, npm run test, cargo
|
|
277
|
+
# test, go test, make test, ...): match against the first 3 tokens where
|
|
278
|
+
# the pattern anchors naturally (these patterns all START with the
|
|
279
|
+
# command name, so a match in the head means the command IS the tool).
|
|
280
|
+
head = " ".join(tokens[:3])
|
|
281
|
+
return bool(_VERIFY_COMMAND_RE.search(head)) and first.lower() in {
|
|
282
|
+
"python",
|
|
283
|
+
"python3",
|
|
284
|
+
"py",
|
|
285
|
+
"npm",
|
|
286
|
+
"npx",
|
|
287
|
+
"yarn",
|
|
288
|
+
"pnpm",
|
|
289
|
+
"bun",
|
|
290
|
+
"cargo",
|
|
291
|
+
"go",
|
|
292
|
+
"make",
|
|
293
|
+
"ruby",
|
|
294
|
+
"bundle",
|
|
295
|
+
"uv",
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _command_verifies_written(command: str, written_files: list[str]) -> bool:
|
|
300
|
+
"""Does this bash command plausibly run or test the written code?
|
|
301
|
+
|
|
302
|
+
Returns True if:
|
|
303
|
+
- Any SEGMENT of the command starts with a known test/lint/build tool
|
|
304
|
+
(pytest, npm test, cargo test, ruff, etc.). Segments are split on
|
|
305
|
+
``; | &`` — a tool name that appears only as an ARGUMENT (`echo
|
|
306
|
+
pytest`, commit messages) does NOT count (2026-08-14 v2 audit:
|
|
307
|
+
three bypass paths through substring matching, all closed here).
|
|
308
|
+
- The command references a written file by basename or path — e.g.
|
|
309
|
+
``python src/foo.py`` or ``node app.js``.
|
|
310
|
+
|
|
311
|
+
Returns False for commands that don't touch the written files at all,
|
|
312
|
+
like ``echo done``, ``ls``, ``pwd``, ``git status`` — these would let the
|
|
313
|
+
agent bypass VerifyGate without actually running its code.
|
|
314
|
+
"""
|
|
315
|
+
if not command:
|
|
316
|
+
return False
|
|
317
|
+
# Known verification tools count even without explicit file references —
|
|
318
|
+
# but only as the COMMAND of a segment, never as a string argument.
|
|
319
|
+
for segment in _SEGMENT_SPLIT_RE.split(command):
|
|
320
|
+
if _segment_runs_verifier(segment):
|
|
321
|
+
return True
|
|
322
|
+
# Check if any written file's basename or path appears in the command.
|
|
323
|
+
cmd_lower = command.lower()
|
|
324
|
+
for f in written_files:
|
|
325
|
+
basename = f.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
326
|
+
if basename and (basename.lower() in cmd_lower or f.lower() in cmd_lower):
|
|
327
|
+
return True
|
|
328
|
+
return False
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass
|
|
332
|
+
class HarnessState:
|
|
333
|
+
"""Per-turn harness state, derived purely from observed tool calls.
|
|
334
|
+
|
|
335
|
+
No field here is writable by the model — it is all ground truth accumulated
|
|
336
|
+
by ``Harness.observe`` as tools execute.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
# File paths written since the last verification (bash). Non-empty here means
|
|
340
|
+
# "there is unverified code on disk".
|
|
341
|
+
writes_since_verify: list[str] = field(default_factory=list)
|
|
342
|
+
# Times the verify gate has force-continued this turn (resets to 0 on bash).
|
|
343
|
+
verify_attempts: int = 0
|
|
344
|
+
# Times the completion gate has force-continued this turn.
|
|
345
|
+
completion_attempts: int = 0
|
|
346
|
+
# Whether the plan gate has nudged already this turn (nudge at most once).
|
|
347
|
+
plan_nudged: bool = False
|
|
348
|
+
# Whether ANY write happened this turn (write_file/edit_file). Used by
|
|
349
|
+
# GroundingGate to distinguish CODE mode (writes → check citations) from
|
|
350
|
+
# ANALYZE mode (pure reads → skip, analysis is allowed to mention filenames).
|
|
351
|
+
# Unlike writes_since_verify, this is NOT cleared by verification.
|
|
352
|
+
has_wrote_this_turn: bool = False
|
|
353
|
+
# Files the model has actually READ this turn (via read_file/grep/glob/list_dir).
|
|
354
|
+
# The GroundingGate checks citations against this set: claiming "loader.py:81
|
|
355
|
+
# does X" without loader.py in here is an ungrounded assertion.
|
|
356
|
+
read_files: set[str] = field(default_factory=set)
|
|
357
|
+
# Subset of read_files opened with read_file (content-level reads). The
|
|
358
|
+
# GroundingGate trusts these for file-content citations; grep/list_dir/glob
|
|
359
|
+
# only land in read_files (directory/path awareness), NOT here — seeing a
|
|
360
|
+
# filename in `list_dir` output doesn't mean the model read the file's
|
|
361
|
+
# contents, so a citation like "loader.py:81 does X" should require an actual
|
|
362
|
+
# read_file of loader.py.
|
|
363
|
+
content_read_files: set[str] = field(default_factory=set)
|
|
364
|
+
# Times the grounding gate has force-continued this turn.
|
|
365
|
+
grounding_attempts: int = 0
|
|
366
|
+
# Paths the model TRIED to read_file but got "file not found". The
|
|
367
|
+
# GroundingGate skips these in its ungrounded-citation check — the regex
|
|
368
|
+
# can match path-like strings in prose (e.g. '.coderio/config.toml' in a
|
|
369
|
+
# README analysis) that aren't real files. Once the agent has confirmed a
|
|
370
|
+
# path doesn't exist, the gate stops forcing it to "read" it again.
|
|
371
|
+
not_found_files: set[str] = field(default_factory=set)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@dataclass
|
|
375
|
+
class Harness:
|
|
376
|
+
"""The structural constraint layer. Stateless across turns except via state.
|
|
377
|
+
|
|
378
|
+
``enabled`` short-circuits every method to a no-op/passthrough, so the loop
|
|
379
|
+
can always hold a Harness object and just flip this flag (or pass harness=None).
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
state: HarnessState
|
|
383
|
+
todos: "TodoStore"
|
|
384
|
+
enabled: bool = True
|
|
385
|
+
# Optional: agent state tracker + stream for phase observability. When set,
|
|
386
|
+
# observe()/check_termination() derive the current AgentState from ground
|
|
387
|
+
# truth and fire on_phase_change. None = phase tracking off (back-compat).
|
|
388
|
+
state_tracker: Any = None
|
|
389
|
+
stream: Any = None
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------------ observe
|
|
392
|
+
def observe(self, name: str, args: dict, result: str) -> None:
|
|
393
|
+
"""Update internal state from a tool execution (called after every tool).
|
|
394
|
+
|
|
395
|
+
- successful write tool -> record path as an unverified write
|
|
396
|
+
- read tool -> record the path/pattern as "actually read"
|
|
397
|
+
- bash (any result) -> writes are now "verified attempt", clear + reset
|
|
398
|
+
"""
|
|
399
|
+
if not self.enabled:
|
|
400
|
+
return
|
|
401
|
+
just_verified = False
|
|
402
|
+
if name in WRITE_TOOLS and _is_success(result):
|
|
403
|
+
path = str(args.get("path", ""))
|
|
404
|
+
self.state.has_wrote_this_turn = True
|
|
405
|
+
if path and path not in self.state.writes_since_verify:
|
|
406
|
+
# Skip non-code files — VerifyGate exists to catch "wrote code
|
|
407
|
+
# but didn't run it". Writing a .md doc, .json config, .yaml
|
|
408
|
+
# manifest, or .txt note does NOT need pytest/bash verification.
|
|
409
|
+
# The model reads it back to confirm formatting, which is the
|
|
410
|
+
# appropriate verification for documentation. Forcing bash on
|
|
411
|
+
# docs wastes rounds and triggers false "environment broken"
|
|
412
|
+
# conclusions (the most common UX complaint).
|
|
413
|
+
if _is_verifiable_code(path):
|
|
414
|
+
self.state.writes_since_verify.append(path)
|
|
415
|
+
elif name in READ_TOOLS:
|
|
416
|
+
# Record whatever path/pattern the read tool was aimed at. For grep
|
|
417
|
+
# the relevant arg is `pattern`/`path`; for read_file/list_dir/glob
|
|
418
|
+
# it is `path`/`file_path`/`pattern`. Store all candidates loosely —
|
|
419
|
+
# the GroundingGate matches by basename substring, so a dir read
|
|
420
|
+
# (list_dir "src/agent") still counts as having looked at files there.
|
|
421
|
+
for key in ("path", "file_path", "pattern"):
|
|
422
|
+
v = str(args.get(key, "")).strip()
|
|
423
|
+
if v:
|
|
424
|
+
self.state.read_files.add(v)
|
|
425
|
+
# read_file is the only tool that actually reads file CONTENTS. A
|
|
426
|
+
# grep only matches a pattern, list_dir/glob return names — none of
|
|
427
|
+
# those let the model truthfully cite a specific line's behavior, so
|
|
428
|
+
# they don't ground a content-level citation.
|
|
429
|
+
if name in CONTENT_READ_TOOLS:
|
|
430
|
+
for key in ("path", "file_path"):
|
|
431
|
+
v = str(args.get(key, "")).strip()
|
|
432
|
+
if v:
|
|
433
|
+
# Normalize on write so later _was_read comparisons are
|
|
434
|
+
# case- and slash-insensitive (see _norm_path).
|
|
435
|
+
self.state.content_read_files.add(_norm_path(v))
|
|
436
|
+
# If the read returned "file not found", record it so the
|
|
437
|
+
# GroundingGate doesn't force re-reading a nonexistent path
|
|
438
|
+
# (regex false positives on prose path-like strings).
|
|
439
|
+
# Match "not found" (not just "file not found") because
|
|
440
|
+
# deepagents' ReadResult uses "File '...' not found"
|
|
441
|
+
# and the backend uses "file not found" — both
|
|
442
|
+
# contain "not found".
|
|
443
|
+
if result and "not found" in result.lower():
|
|
444
|
+
self.state.not_found_files.add(_norm_path(v))
|
|
445
|
+
elif name == VERIFY_TOOL:
|
|
446
|
+
# A bash call clears "unverified writes" ONLY if it plausibly runs
|
|
447
|
+
# or tests the written code AND the run actually succeeded (exit 0).
|
|
448
|
+
# Two failure modes the old code missed:
|
|
449
|
+
# (1) a bare `echo done` / `ls` — doesn't test anything → reject.
|
|
450
|
+
# (_command_verifies_written already handles this.)
|
|
451
|
+
# (2) `pytest` ran but FAILED (exit != 0) → the code is NOT verified.
|
|
452
|
+
# The old code treated "ran = verified", letting the agent claim
|
|
453
|
+
# completion after a failing test run. Now we parse exit_code:
|
|
454
|
+
# only exit 0 clears the unverified-writes list.
|
|
455
|
+
command = str(args.get("command", ""))
|
|
456
|
+
if _command_verifies_written(command, self.state.writes_since_verify):
|
|
457
|
+
exit_code = _parse_exit_code(result)
|
|
458
|
+
# A command that FAILED TO RUN at all (permission denied, tool
|
|
459
|
+
# error) can never count as verification — parse its exit code
|
|
460
|
+
# would miss these because they carry no marker at all.
|
|
461
|
+
# (2026-08-14 v2 audit: `pytest -q` blocked by the permission
|
|
462
|
+
# gate returned "Permission denied: ..." with no marker, which
|
|
463
|
+
# fell into the "neutral pass" branch below and cleared the
|
|
464
|
+
# unverified-writes list. _is_success already recognizes the
|
|
465
|
+
# "Permission denied"/"Error" prefixes — it just wasn't called
|
|
466
|
+
# in this branch.)
|
|
467
|
+
if not _is_success(result):
|
|
468
|
+
pass # Did not run → not verified; writes stay pending.
|
|
469
|
+
elif exit_code is not None and exit_code != 0:
|
|
470
|
+
# Verification FAILED. Do NOT clear writes_since_verify — the
|
|
471
|
+
# agent must fix the code and re-run. Do NOT increment
|
|
472
|
+
# verify_attempts here: that counter is the _verify_gate's
|
|
473
|
+
# interception budget (how many times completion was refused).
|
|
474
|
+
# A failed bash run is a genuine verify attempt, not a
|
|
475
|
+
# completion attempt — it should NOT consume the gate budget.
|
|
476
|
+
# The gate still escalates after _MAX_GATE_ATTEMPTS
|
|
477
|
+
# interceptions regardless of whether the intervening bash
|
|
478
|
+
# calls passed or failed.
|
|
479
|
+
pass
|
|
480
|
+
else:
|
|
481
|
+
# Either exit 0 (passed) or no exit_code marker at all (old
|
|
482
|
+
# providers/tools that don't report it — preserve back-compat
|
|
483
|
+
# by treating absence as a neutral pass, same as before).
|
|
484
|
+
self.state.writes_since_verify.clear()
|
|
485
|
+
self.state.verify_attempts = 0
|
|
486
|
+
just_verified = True
|
|
487
|
+
# Derive + fire phase change after state is updated.
|
|
488
|
+
self._track_phase(just_verified=just_verified, hint=name)
|
|
489
|
+
|
|
490
|
+
def _track_phase(self, just_verified: bool = False, hint: str = "") -> None:
|
|
491
|
+
"""Derive the current AgentState from ground truth and fire a transition.
|
|
492
|
+
|
|
493
|
+
No-op when state_tracker is None (phase tracking off). Debounce is inside
|
|
494
|
+
AgentStateTracker.transition — repeated same-phase calls don't bloat the
|
|
495
|
+
timeline.
|
|
496
|
+
"""
|
|
497
|
+
if self.state_tracker is None:
|
|
498
|
+
return
|
|
499
|
+
phase = self.state_tracker.derive_phase(
|
|
500
|
+
writes_since_verify=self.state.writes_since_verify,
|
|
501
|
+
todos_exist=bool(self.todos.todos),
|
|
502
|
+
just_verified=just_verified,
|
|
503
|
+
)
|
|
504
|
+
# Only notify the stream when the phase ACTUALLY changed (debounce) —
|
|
505
|
+
# otherwise 10 read_file calls would fire 10 redundant on_phase_change.
|
|
506
|
+
if self.state_tracker.transition(phase, hint=hint):
|
|
507
|
+
if self.stream is not None and hasattr(self.stream, "on_phase_change"):
|
|
508
|
+
self.stream.on_phase_change(str(phase), 0, hint)
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------- after_tool_call (plan gate)
|
|
511
|
+
def after_tool_call(self, name: str, args: dict, result: str) -> str | None:
|
|
512
|
+
"""PlanGate (soft): if writing code with no task list, append a one-time nudge.
|
|
513
|
+
|
|
514
|
+
Returns text to APPEND to the tool result (the tool still ran), or None.
|
|
515
|
+
Soft = never blocks; only nudges. The hard check on todo completion lives
|
|
516
|
+
in check_termination (CompletionGate).
|
|
517
|
+
"""
|
|
518
|
+
if not self.enabled:
|
|
519
|
+
return None
|
|
520
|
+
if name not in WRITE_TOOLS:
|
|
521
|
+
return None
|
|
522
|
+
if self.state.plan_nudged:
|
|
523
|
+
return None
|
|
524
|
+
if self.todos.todos: # already has a plan -> no nudge
|
|
525
|
+
return None
|
|
526
|
+
self.state.plan_nudged = True
|
|
527
|
+
return (
|
|
528
|
+
"\n[nudge] You're writing code but have no task list yet. For non-trivial "
|
|
529
|
+
'work, call todo(action="add", ...) first to decompose the task into '
|
|
530
|
+
"verifiable steps. (Trivial fixes like a typo can ignore this.)"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# ------------------------------------------------------ check_termination (hard gates)
|
|
534
|
+
def check_termination(self, final_text: str) -> tuple[bool, str | None, str | None]:
|
|
535
|
+
"""Decide whether a "no tool_calls" response may actually end the turn.
|
|
536
|
+
|
|
537
|
+
Returns ``(should_continue, inject_message, warn_message)``:
|
|
538
|
+
* should_continue=True, inject set -> loop must NOT return; append inject
|
|
539
|
+
as a user message and keep going.
|
|
540
|
+
* should_continue=False, warn set -> loop returns normally, but stream
|
|
541
|
+
must show the warning (escalation release).
|
|
542
|
+
* should_continue=False, warn None -> truly done, return silently.
|
|
543
|
+
"""
|
|
544
|
+
if not self.enabled:
|
|
545
|
+
return (False, None, None)
|
|
546
|
+
|
|
547
|
+
# VerifyGate has priority: code written but never run is the core failure.
|
|
548
|
+
cont, inject, warn = self._verify_gate()
|
|
549
|
+
if cont or warn:
|
|
550
|
+
return (cont, inject, warn)
|
|
551
|
+
|
|
552
|
+
# CompletionGate: only meaningful when a non-trivial todo list exists.
|
|
553
|
+
cont, inject, warn = self._completion_gate()
|
|
554
|
+
if cont or warn:
|
|
555
|
+
return (cont, inject, warn)
|
|
556
|
+
|
|
557
|
+
# GroundingGate: only active in CODE mode (turn has writes). In ANALYZE
|
|
558
|
+
# mode (pure reads, no writes), citing filenames from docs/README is
|
|
559
|
+
# normal structural description, not a false code claim. Full-session
|
|
560
|
+
# audit (105 sessions, 203 outputs): 98.2% of grounding triggers were
|
|
561
|
+
# false positives on filename mentions in analysis — the gate never
|
|
562
|
+
# caught a real "hallucinated code citation" in practice. Restricting it
|
|
563
|
+
# to CODE mode eliminates the false positives while keeping the (rare)
|
|
564
|
+
# protection for code-change scenarios.
|
|
565
|
+
if self.state.has_wrote_this_turn:
|
|
566
|
+
return self._grounding_gate(final_text)
|
|
567
|
+
return (False, None, None)
|
|
568
|
+
|
|
569
|
+
# --------------------------------------------------------------- VerifyGate
|
|
570
|
+
def _verify_gate(self) -> tuple[bool, str | None, str | None]:
|
|
571
|
+
"""Hard gate: unverified writes may not silently end the turn."""
|
|
572
|
+
if not self.state.writes_since_verify:
|
|
573
|
+
return (False, None, None)
|
|
574
|
+
|
|
575
|
+
attempt = self.state.verify_attempts
|
|
576
|
+
self.state.verify_attempts += 1
|
|
577
|
+
|
|
578
|
+
if attempt >= _MAX_GATE_ATTEMPTS:
|
|
579
|
+
# Escalation exhausted: release, but loudly. Never silent.
|
|
580
|
+
files = ", ".join(self.state.writes_since_verify)
|
|
581
|
+
return (
|
|
582
|
+
False,
|
|
583
|
+
None,
|
|
584
|
+
f"agent wrote code to [{files}] but never ran it to verify, "
|
|
585
|
+
"then declared completion. Output is UNVERIFIED — please review.",
|
|
586
|
+
)
|
|
587
|
+
if attempt == 0:
|
|
588
|
+
return (
|
|
589
|
+
True,
|
|
590
|
+
"[harness] You wrote code but haven't verified it. You MUST run it "
|
|
591
|
+
"(use bash to execute/test/lint the files you changed) before "
|
|
592
|
+
"declaring done. Do not summarize or claim completion — call bash now.",
|
|
593
|
+
None,
|
|
594
|
+
)
|
|
595
|
+
# attempt == 1: second interception, name the files and tighten the screws.
|
|
596
|
+
files = ", ".join(self.state.writes_since_verify)
|
|
597
|
+
return (
|
|
598
|
+
True,
|
|
599
|
+
f"[harness] STILL no verification. Files written but not run: {files}. "
|
|
600
|
+
"Run them with bash now. Do NOT reply with text — call bash.",
|
|
601
|
+
None,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# ----------------------------------------------------------- CompletionGate
|
|
605
|
+
def _completion_gate(self) -> tuple[bool, str | None, str | None]:
|
|
606
|
+
"""Hard gate: pending todos may not silently end the turn.
|
|
607
|
+
|
|
608
|
+
Skipped entirely when there is no todo list (trivial-task exemption): a
|
|
609
|
+
small Q&A or typo fix that never built a plan should not be blocked here.
|
|
610
|
+
The PlanGate's soft nudge is the only pressure in that case.
|
|
611
|
+
"""
|
|
612
|
+
if not self.todos.todos:
|
|
613
|
+
return (False, None, None)
|
|
614
|
+
pending = [t for t in self.todos.todos if t.status != "completed"]
|
|
615
|
+
if not pending:
|
|
616
|
+
return (False, None, None)
|
|
617
|
+
|
|
618
|
+
attempt = self.state.completion_attempts
|
|
619
|
+
self.state.completion_attempts += 1
|
|
620
|
+
|
|
621
|
+
if attempt >= _MAX_GATE_ATTEMPTS:
|
|
622
|
+
return (
|
|
623
|
+
False,
|
|
624
|
+
None,
|
|
625
|
+
f"agent declared completion with {len(pending)} unfinished todo(s). "
|
|
626
|
+
"Some planned work may be incomplete — please review the task list.",
|
|
627
|
+
)
|
|
628
|
+
return (
|
|
629
|
+
True,
|
|
630
|
+
f"[harness] Your task list has {len(pending)} unfinished item(s). Mark "
|
|
631
|
+
'them complete via todo(action="update", status="completed") only if '
|
|
632
|
+
"truly done, or finish the remaining work. Do not claim overall "
|
|
633
|
+
"completion with pending todos.",
|
|
634
|
+
None,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# ----------------------------------------------------------- GroundingGate
|
|
638
|
+
def _grounding_gate(self, final_text: str) -> tuple[bool, str | None, str | None]:
|
|
639
|
+
"""Hard gate: an analysis that cites code it never read is ungrounded.
|
|
640
|
+
|
|
641
|
+
Failure mode this catches: the model produces a confident-sounding review
|
|
642
|
+
("loader.py:81 does X", "the harness never reads config.harness") built on
|
|
643
|
+
documentation, memory, or assumption — WITHOUT having opened the file. When
|
|
644
|
+
source and docs diverge (the docs are stale), this silently produces wrong
|
|
645
|
+
conclusions. This gate checks citations against the set of files actually
|
|
646
|
+
read this turn (``state.read_files``, populated by observe()).
|
|
647
|
+
|
|
648
|
+
Only fires when the model EXPLICITLY cites a file path (``foo.py``,
|
|
649
|
+
``src/x.py:42``) — a pure-conversation answer with no file references is
|
|
650
|
+
left alone (it isn't claiming code-level facts).
|
|
651
|
+
"""
|
|
652
|
+
cited = _cited_files(final_text)
|
|
653
|
+
if not cited:
|
|
654
|
+
return (False, None, None)
|
|
655
|
+
# Filter out cited paths that the agent already TRIED to read but got
|
|
656
|
+
# "file not found" — these aren't real code files, just regex false
|
|
657
|
+
# positives on path-like strings in prose. The agent already attempted
|
|
658
|
+
# and confirmed they don't exist; forcing another read wastes rounds.
|
|
659
|
+
ungrounded = [c for c in cited if not self._was_read(c) and c not in self.state.not_found_files]
|
|
660
|
+
if not ungrounded:
|
|
661
|
+
return (False, None, None)
|
|
662
|
+
|
|
663
|
+
attempt = self.state.grounding_attempts
|
|
664
|
+
self.state.grounding_attempts += 1
|
|
665
|
+
|
|
666
|
+
if attempt >= _MAX_GATE_ATTEMPTS:
|
|
667
|
+
files = ", ".join(sorted(ungrounded))
|
|
668
|
+
return (
|
|
669
|
+
False,
|
|
670
|
+
None,
|
|
671
|
+
f"agent cited code in [{files}] without reading it this turn. "
|
|
672
|
+
"Conclusions about that code may rest on stale docs or memory — "
|
|
673
|
+
"please verify against the actual source.",
|
|
674
|
+
)
|
|
675
|
+
if attempt == 0:
|
|
676
|
+
files = ", ".join(sorted(ungrounded))
|
|
677
|
+
return (
|
|
678
|
+
True,
|
|
679
|
+
f"[harness] You cited {files} but did not read it this turn. A claim "
|
|
680
|
+
"about code must be grounded in the actual source, not documentation "
|
|
681
|
+
"or memory (docs go stale; source is ground truth). Read the cited "
|
|
682
|
+
"file(s) with read_file now, then CONTINUE your analysis with the "
|
|
683
|
+
"grounded details. Do not restate or 'correct' your previous output — "
|
|
684
|
+
"just add what you find by reading the source.",
|
|
685
|
+
None,
|
|
686
|
+
)
|
|
687
|
+
files = ", ".join(sorted(ungrounded))
|
|
688
|
+
return (
|
|
689
|
+
True,
|
|
690
|
+
f"[harness] STILL unread: {files}. Read them with read_file before you "
|
|
691
|
+
"make any claim about their contents. If the file doesn't exist, say so "
|
|
692
|
+
"explicitly instead of asserting things about it.",
|
|
693
|
+
None,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
@staticmethod
|
|
697
|
+
def _basename(path: str) -> str:
|
|
698
|
+
"""Last path component (basename), slash/backslash agnostic. 'a/b.py' -> 'b.py'.
|
|
699
|
+
|
|
700
|
+
Operates on the NORMALIZED form (see _norm_path) so case-insensitive
|
|
701
|
+
filesystems (NTFS, APFS default) treat 'Loop.py' == 'loop.py' correctly —
|
|
702
|
+
otherwise the model citing 'loop.py:81' after reading 'Loop.py' would
|
|
703
|
+
trip the GroundingGate into a re-read loop."""
|
|
704
|
+
n = _norm_path(path)
|
|
705
|
+
if "/" in n:
|
|
706
|
+
n = n.rsplit("/", 1)[1]
|
|
707
|
+
return n
|
|
708
|
+
|
|
709
|
+
def _was_read(self, cited: str) -> bool:
|
|
710
|
+
"""Did the model actually READ the cited file's contents this turn?
|
|
711
|
+
|
|
712
|
+
Only content-level reads (read_file) ground a citation about a file's
|
|
713
|
+
internals — grep/list_dir/glob don't, because they never show the file's
|
|
714
|
+
actual content (grep matches a pattern; list_dir/glob return names).
|
|
715
|
+
|
|
716
|
+
Matching: basename OR full-path exact match against the files opened with
|
|
717
|
+
read_file. The old loose substring match (``c in r or r in c``) was too
|
|
718
|
+
permissive — it let ``grep pattern="loop"`` satisfy a citation of
|
|
719
|
+
``loop.py``, and ``list_dir("src")`` cover every file in src/.
|
|
720
|
+
A bare ``:line`` suffix on the citation is stripped first.
|
|
721
|
+
|
|
722
|
+
All comparisons go through _norm_path so 'Loop.py' == 'loop.py' and
|
|
723
|
+
'a\\b.py' == 'a/b.py' (Windows path + case-insensitive filesystems).
|
|
724
|
+
Without this, the model citing 'loop.py:81' after reading 'Loop.py'
|
|
725
|
+
triggers a needless re-read — observed in real sessions as 'Loop.py'
|
|
726
|
+
being read 5x in one turn.
|
|
727
|
+
"""
|
|
728
|
+
c = cited.rsplit(":", 1)[0] if ":" in cited else cited # drop :line
|
|
729
|
+
c_norm = _norm_path(c)
|
|
730
|
+
c_base = self._basename(c)
|
|
731
|
+
for r in self.state.content_read_files:
|
|
732
|
+
if not r:
|
|
733
|
+
continue
|
|
734
|
+
r_norm = _norm_path(r)
|
|
735
|
+
r_base = self._basename(r)
|
|
736
|
+
# Exact basename match (loader.py == loader.py) or full-path match
|
|
737
|
+
# on the normalized form (covers src/agent/loop.py, slash style, case).
|
|
738
|
+
if c_base == r_base or c_norm == r_norm:
|
|
739
|
+
return True
|
|
740
|
+
return False
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _cited_files(text: str) -> list[str]:
|
|
744
|
+
"""Extract file-path citations from prose. Returns deduped list (order kept).
|
|
745
|
+
|
|
746
|
+
Matches patterns like ``loader.py``, ``src/agent/loop.py``, ``a.py:81``.
|
|
747
|
+
Requires a code-ish extension so ordinary words ("step 2", "the loader") do
|
|
748
|
+
not false-positive. De-dupes on basename+line so the same file cited twice
|
|
749
|
+
with different line numbers only lists once for the user-facing message.
|
|
750
|
+
|
|
751
|
+
EXCLUDES file paths inside markdown table rows (``| src/foo.py | ... |``):
|
|
752
|
+
those are structural listings in a project map ("this file does X"), not
|
|
753
|
+
code-level citations claiming specific behavior. Without this exclusion,
|
|
754
|
+
a project-analysis table listing every module triggers the GroundingGate
|
|
755
|
+
on files the model listed but didn't deep-read — defeating the purpose of
|
|
756
|
+
the onboarding skill's first-pass overview.
|
|
757
|
+
"""
|
|
758
|
+
# Strip markdown table rows before matching — a path inside | ... | is a
|
|
759
|
+
# directory listing, not a citation. Only paths in prose/inline-code count.
|
|
760
|
+
prose = re.sub(r"^\s*\|.*\|\s*$", "", text, flags=re.MULTILINE)
|
|
761
|
+
seen: set[str] = set()
|
|
762
|
+
out: list[str] = []
|
|
763
|
+
for m in _CITED_FILE_RE.finditer(prose):
|
|
764
|
+
path = m.group(0)
|
|
765
|
+
if path not in seen:
|
|
766
|
+
seen.add(path)
|
|
767
|
+
out.append(path)
|
|
768
|
+
return out
|