@inneranimalmedia/agentsam-sdk 2.0.0 → 2.1.0
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.
- package/README.md +1 -0
- package/docs/DEPLOY_RECEIPTS.md +83 -0
- package/docs/RECON.md +165 -0
- package/docs/RELEASES.md +3 -3
- package/docs/sdk-2.0-release.md +19 -21
- package/package.json +2 -1
- package/packages/identity/package.json +1 -1
- package/protocol/recon/README.md +19 -0
- package/protocol/recon/finding-report.schema.json +46 -0
- package/protocol/recon/task-packet.schema.json +79 -0
- package/python/agentsam_sdk/repository/recon/__init__.py +28 -0
- package/python/agentsam_sdk/repository/recon/__main__.py +3 -0
- package/python/agentsam_sdk/repository/recon/cli.py +178 -0
- package/python/agentsam_sdk/repository/recon/packet.py +294 -0
- package/python/agentsam_sdk/repository/recon/validate.py +76 -0
- package/python/tests/test_recon.py +257 -0
- package/src/cli.js +15 -7
- package/src/commands/deploy-receipt.js +129 -0
- package/src/commands/recon.js +71 -0
- package/src/index.js +8 -0
- package/src/lib/deploy-receipt/index.js +246 -0
- package/test/deploy-receipt.test.mjs +91 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""CLI and ToolInput adapter for the recon bounded-worker harness.
|
|
2
|
+
|
|
3
|
+
Two read-only operations, matching the controller/validator halves of the pipeline
|
|
4
|
+
described in docs/RECON.md:
|
|
5
|
+
|
|
6
|
+
python -m agentsam_sdk.repository.recon pack --repo-root . \\
|
|
7
|
+
--question "..." --slice backend/workflows/repository/workflows.js:1-180 \\
|
|
8
|
+
--out packet.json
|
|
9
|
+
|
|
10
|
+
python -m agentsam_sdk.repository.recon validate --packet packet.json \\
|
|
11
|
+
--report report.json
|
|
12
|
+
|
|
13
|
+
Neither operation calls a model. `pack` is the deterministic controller step;
|
|
14
|
+
`validate` is the deterministic gate a worker's report must pass before a capable
|
|
15
|
+
agent ever sees it.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from agentsam_sdk.runtime.contract import ToolInput, ToolResult, start_timer, write_receipt
|
|
24
|
+
|
|
25
|
+
from .packet import TOOL_NAME_PACK, build_task_packet
|
|
26
|
+
from .validate import TOOL_NAME_VALIDATE, ReportError, validate_report
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_slice_arg(raw: str) -> dict[str, str | int]:
|
|
30
|
+
# path[:start-end]
|
|
31
|
+
if ":" in raw:
|
|
32
|
+
path, rng = raw.rsplit(":", 1)
|
|
33
|
+
if "-" in rng:
|
|
34
|
+
start, end = rng.split("-", 1)
|
|
35
|
+
return {"path": path, "start_line": int(start), "end_line": int(end)}
|
|
36
|
+
return {"path": raw}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_pack(tool_input: ToolInput) -> ToolResult:
|
|
40
|
+
started = start_timer()
|
|
41
|
+
try:
|
|
42
|
+
tool_input.assert_read_only()
|
|
43
|
+
params = tool_input.params
|
|
44
|
+
packet = build_task_packet(
|
|
45
|
+
params.get("repo_root") or ".",
|
|
46
|
+
question=params["question"],
|
|
47
|
+
slices=params.get("slices") or [],
|
|
48
|
+
task_id=params.get("task_id"),
|
|
49
|
+
allowed_diagnostics=params.get("allowed_diagnostics") or (),
|
|
50
|
+
max_follow_up_reads=int(params.get("max_follow_up_reads", 2)),
|
|
51
|
+
max_output_tokens=int(params.get("max_output_tokens", 800)),
|
|
52
|
+
timeout_seconds=int(params.get("timeout_seconds", 90)),
|
|
53
|
+
)
|
|
54
|
+
artifacts: list[str] = []
|
|
55
|
+
output_dir = tool_input.output_path()
|
|
56
|
+
if output_dir:
|
|
57
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
out = output_dir / f"recon-packet-{packet['task_id']}.json"
|
|
59
|
+
out.write_text(json.dumps(packet, indent=2), encoding="utf-8")
|
|
60
|
+
artifacts.append(str(out))
|
|
61
|
+
result = ToolResult(
|
|
62
|
+
ok=True,
|
|
63
|
+
tool=TOOL_NAME_PACK,
|
|
64
|
+
mode=tool_input.mode or "read-only",
|
|
65
|
+
request_id=tool_input.request_id,
|
|
66
|
+
started_at=started,
|
|
67
|
+
finished_at=start_timer(),
|
|
68
|
+
summary=f"Built bounded task packet {packet['task_id']} ({len(packet['slices'])} slice(s)).",
|
|
69
|
+
data=packet,
|
|
70
|
+
artifacts=artifacts,
|
|
71
|
+
)
|
|
72
|
+
except Exception as exc: # noqa: BLE001 - normalized into ToolResult
|
|
73
|
+
result = ToolResult(
|
|
74
|
+
ok=False,
|
|
75
|
+
tool=TOOL_NAME_PACK,
|
|
76
|
+
mode=tool_input.mode or "read-only",
|
|
77
|
+
request_id=tool_input.request_id,
|
|
78
|
+
started_at=started,
|
|
79
|
+
finished_at=start_timer(),
|
|
80
|
+
summary="recon pack failed",
|
|
81
|
+
error=str(exc)[:500],
|
|
82
|
+
)
|
|
83
|
+
write_receipt(result, tool_input.output_path())
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_validate(tool_input: ToolInput) -> ToolResult:
|
|
88
|
+
started = start_timer()
|
|
89
|
+
try:
|
|
90
|
+
tool_input.assert_read_only()
|
|
91
|
+
params = tool_input.params
|
|
92
|
+
packet = params["packet"]
|
|
93
|
+
report = params["report"]
|
|
94
|
+
try:
|
|
95
|
+
validated = validate_report(report, packet)
|
|
96
|
+
ok = True
|
|
97
|
+
summary = f"Report for {report.get('task_id')} is valid ({report.get('status')})."
|
|
98
|
+
error = None
|
|
99
|
+
except ReportError as exc:
|
|
100
|
+
validated = {}
|
|
101
|
+
ok = False
|
|
102
|
+
summary = "Report rejected; do not forward to the capable agent."
|
|
103
|
+
error = str(exc)
|
|
104
|
+
result = ToolResult(
|
|
105
|
+
ok=ok,
|
|
106
|
+
tool=TOOL_NAME_VALIDATE,
|
|
107
|
+
mode=tool_input.mode or "read-only",
|
|
108
|
+
request_id=tool_input.request_id,
|
|
109
|
+
started_at=started,
|
|
110
|
+
finished_at=start_timer(),
|
|
111
|
+
summary=summary,
|
|
112
|
+
data={"validated_report": validated} if ok else {},
|
|
113
|
+
error=error,
|
|
114
|
+
)
|
|
115
|
+
except Exception as exc: # noqa: BLE001 - normalized into ToolResult
|
|
116
|
+
result = ToolResult(
|
|
117
|
+
ok=False,
|
|
118
|
+
tool=TOOL_NAME_VALIDATE,
|
|
119
|
+
mode=tool_input.mode or "read-only",
|
|
120
|
+
request_id=tool_input.request_id,
|
|
121
|
+
started_at=started,
|
|
122
|
+
finished_at=start_timer(),
|
|
123
|
+
summary="recon validate failed",
|
|
124
|
+
error=str(exc)[:500],
|
|
125
|
+
)
|
|
126
|
+
write_receipt(result, tool_input.output_path())
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main_cli(argv: list[str] | None = None) -> int:
|
|
131
|
+
parser = argparse.ArgumentParser(
|
|
132
|
+
prog="python -m agentsam_sdk.repository.recon",
|
|
133
|
+
description="Build bounded recon task packets and validate worker findings.",
|
|
134
|
+
)
|
|
135
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
136
|
+
|
|
137
|
+
pack_p = sub.add_parser("pack", help="Build a bounded ReconTaskPacket.")
|
|
138
|
+
pack_p.add_argument("--repo-root", default=".")
|
|
139
|
+
pack_p.add_argument("--question", required=True)
|
|
140
|
+
pack_p.add_argument(
|
|
141
|
+
"--slice", dest="slices", action="append", default=[],
|
|
142
|
+
help="path[:start-end], repeatable, up to 5.",
|
|
143
|
+
)
|
|
144
|
+
pack_p.add_argument("--task-id")
|
|
145
|
+
pack_p.add_argument("--out")
|
|
146
|
+
|
|
147
|
+
validate_p = sub.add_parser("validate", help="Validate a ReconFindingReport against its packet.")
|
|
148
|
+
validate_p.add_argument("--packet", required=True, help="Path to a packet JSON file.")
|
|
149
|
+
validate_p.add_argument("--report", required=True, help="Path to a report JSON file.")
|
|
150
|
+
|
|
151
|
+
args = parser.parse_args(argv)
|
|
152
|
+
|
|
153
|
+
if args.command == "pack":
|
|
154
|
+
packet = build_task_packet(
|
|
155
|
+
args.repo_root,
|
|
156
|
+
question=args.question,
|
|
157
|
+
slices=[_parse_slice_arg(s) for s in args.slices],
|
|
158
|
+
task_id=args.task_id,
|
|
159
|
+
)
|
|
160
|
+
rendered = json.dumps(packet, indent=2)
|
|
161
|
+
if args.out:
|
|
162
|
+
Path(args.out).expanduser().write_text(rendered, encoding="utf-8")
|
|
163
|
+
else:
|
|
164
|
+
print(rendered)
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
if args.command == "validate":
|
|
168
|
+
packet = json.loads(Path(args.packet).expanduser().read_text(encoding="utf-8"))
|
|
169
|
+
report = json.loads(Path(args.report).expanduser().read_text(encoding="utf-8"))
|
|
170
|
+
try:
|
|
171
|
+
validate_report(report, packet)
|
|
172
|
+
except ReportError as exc:
|
|
173
|
+
print(json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
174
|
+
return 1
|
|
175
|
+
print(json.dumps({"ok": True}, indent=2))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
return 2
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Build a bounded ReconTaskPacket from an explicit repo root and an explicit file list.
|
|
2
|
+
|
|
3
|
+
Design law (mirrors protocol/README.md rule 6 and docs/REPOSITORY_INTELLIGENCE.md):
|
|
4
|
+
- Read-only. This module never writes into the target repository.
|
|
5
|
+
- Accepts an explicit repo_root; never assumes one repository's layout.
|
|
6
|
+
- Never hardcodes a tenant/workspace/provider ID as the task handle.
|
|
7
|
+
- The caller (a capable agent, or repository.intelligence output) selects the files.
|
|
8
|
+
This module does not go searching for "relevant" files on its own — that discovery
|
|
9
|
+
step belongs upstream, where budget and reasoning are cheap.
|
|
10
|
+
|
|
11
|
+
Stdlib only.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Iterable
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 1
|
|
23
|
+
TOOL_NAME_PACK = "repository.recon.pack"
|
|
24
|
+
MAX_SLICES = 5
|
|
25
|
+
MAX_FOLLOW_UP_READS = 2
|
|
26
|
+
DEFAULT_TIMEOUT_SECONDS = 90
|
|
27
|
+
DEFAULT_MAX_OUTPUT_TOKENS = 800
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PacketError(ValueError):
|
|
31
|
+
"""Raised when a task packet cannot be built as specified."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _head_sha(repo_root: Path) -> str:
|
|
35
|
+
try:
|
|
36
|
+
out = subprocess.check_output(
|
|
37
|
+
["git", "rev-parse", "HEAD"], cwd=repo_root, stderr=subprocess.DEVNULL
|
|
38
|
+
)
|
|
39
|
+
return out.decode("utf-8", errors="surrogateescape").strip()
|
|
40
|
+
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
|
41
|
+
return "unknown"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_slice(repo_root: Path, rel_path: str, start_line: int | None, end_line: int | None) -> str:
|
|
45
|
+
target = (repo_root / rel_path).resolve()
|
|
46
|
+
try:
|
|
47
|
+
target.relative_to(repo_root.resolve())
|
|
48
|
+
except ValueError as exc:
|
|
49
|
+
raise PacketError(f"slice_escapes_repo_root:{rel_path}") from exc
|
|
50
|
+
if not target.is_file():
|
|
51
|
+
raise PacketError(f"slice_not_found:{rel_path}")
|
|
52
|
+
try:
|
|
53
|
+
text = target.read_text(encoding="utf-8", errors="strict")
|
|
54
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
55
|
+
raise PacketError(f"slice_unreadable:{rel_path}") from exc
|
|
56
|
+
if start_line is None and end_line is None:
|
|
57
|
+
return text
|
|
58
|
+
lines = text.splitlines()
|
|
59
|
+
start = max(1, start_line or 1)
|
|
60
|
+
end = min(len(lines), end_line or len(lines))
|
|
61
|
+
if start > end:
|
|
62
|
+
raise PacketError(f"slice_range_invalid:{rel_path}:{start}-{end}")
|
|
63
|
+
return "\n".join(lines[start - 1 : end])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_task_packet(
|
|
67
|
+
repo_root: str | Path,
|
|
68
|
+
*,
|
|
69
|
+
question: str,
|
|
70
|
+
slices: Iterable[dict[str, Any]],
|
|
71
|
+
task_id: str | None = None,
|
|
72
|
+
allowed_diagnostics: Iterable[str] = (),
|
|
73
|
+
max_follow_up_reads: int = MAX_FOLLOW_UP_READS,
|
|
74
|
+
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
|
75
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
76
|
+
embed_content: bool = True,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Build one bounded ReconTaskPacket.
|
|
79
|
+
|
|
80
|
+
`slices` is a list of {"path", "start_line"?, "end_line"?, "reason"?} — exact,
|
|
81
|
+
caller-chosen material. This function never expands that list; it only reads and
|
|
82
|
+
validates it, and enforces the hard ceilings from protocol/recon/task-packet.schema.json.
|
|
83
|
+
"""
|
|
84
|
+
root = Path(repo_root).expanduser().resolve()
|
|
85
|
+
if not root.is_dir():
|
|
86
|
+
raise PacketError(f"repo_root_not_found:{root}")
|
|
87
|
+
if not question or not question.strip():
|
|
88
|
+
raise PacketError("question_required")
|
|
89
|
+
|
|
90
|
+
slice_list = list(slices)
|
|
91
|
+
if not slice_list:
|
|
92
|
+
raise PacketError("at_least_one_slice_required")
|
|
93
|
+
if len(slice_list) > MAX_SLICES:
|
|
94
|
+
raise PacketError(f"too_many_slices:{len(slice_list)}>{MAX_SLICES}")
|
|
95
|
+
if not (0 <= max_follow_up_reads <= MAX_FOLLOW_UP_READS):
|
|
96
|
+
raise PacketError(f"max_follow_up_reads_out_of_range:{max_follow_up_reads}")
|
|
97
|
+
|
|
98
|
+
resolved_slices: list[dict[str, Any]] = []
|
|
99
|
+
for item in slice_list:
|
|
100
|
+
path = item.get("path")
|
|
101
|
+
if not path:
|
|
102
|
+
raise PacketError("slice_missing_path")
|
|
103
|
+
start_line = item.get("start_line")
|
|
104
|
+
end_line = item.get("end_line")
|
|
105
|
+
entry: dict[str, Any] = {"path": path}
|
|
106
|
+
if start_line is not None:
|
|
107
|
+
entry["start_line"] = int(start_line)
|
|
108
|
+
if end_line is not None:
|
|
109
|
+
entry["end_line"] = int(end_line)
|
|
110
|
+
if item.get("reason"):
|
|
111
|
+
entry["reason"] = str(item["reason"])
|
|
112
|
+
if item.get("kind"):
|
|
113
|
+
entry["kind"] = str(item["kind"])
|
|
114
|
+
if item.get("hit_count"):
|
|
115
|
+
entry["hit_count"] = int(item["hit_count"])
|
|
116
|
+
if embed_content:
|
|
117
|
+
entry["content"] = _read_slice(root, path, entry.get("start_line"), entry.get("end_line"))
|
|
118
|
+
resolved_slices.append(entry)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"schema_version": SCHEMA_VERSION,
|
|
122
|
+
"task_id": task_id or f"recon-{uuid.uuid4().hex[:12]}",
|
|
123
|
+
"repo_root": str(root),
|
|
124
|
+
"base_sha": _head_sha(root),
|
|
125
|
+
"generated_at_unix": int(time.time()),
|
|
126
|
+
"question": question.strip(),
|
|
127
|
+
"slices": resolved_slices,
|
|
128
|
+
"allowed_diagnostics": list(allowed_diagnostics),
|
|
129
|
+
"ceilings": {
|
|
130
|
+
"max_follow_up_reads": max_follow_up_reads,
|
|
131
|
+
"max_output_tokens": max_output_tokens,
|
|
132
|
+
"timeout_seconds": timeout_seconds,
|
|
133
|
+
},
|
|
134
|
+
"response_schema_ref": "./finding-report.schema.json",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def from_matches(
|
|
139
|
+
repo_root: str | Path,
|
|
140
|
+
*,
|
|
141
|
+
question: str,
|
|
142
|
+
matches: Iterable[dict[str, Any]],
|
|
143
|
+
task_id_prefix: str = "recon",
|
|
144
|
+
max_files_per_packet: int = MAX_SLICES,
|
|
145
|
+
context_lines: int = 3,
|
|
146
|
+
**packet_kwargs: Any,
|
|
147
|
+
) -> list[dict[str, Any]]:
|
|
148
|
+
"""Turn raw, tool-agnostic search hits into one or more bounded packets.
|
|
149
|
+
|
|
150
|
+
This is the adapter for "I already ran rg/ast-grep and have a hit list, now I
|
|
151
|
+
need packets" -- it does not run any search itself. `matches` is any iterable of
|
|
152
|
+
{"path": str, "line": int, "kind": str?} -- output from from_ripgrep()/from_ast_grep()
|
|
153
|
+
(or any hand-built equivalent) works directly.
|
|
154
|
+
|
|
155
|
+
Hits are grouped by file into one slice per file (a [min-context, max+context]
|
|
156
|
+
line window covering every hit in that file), then chunked into packets of at
|
|
157
|
+
most `max_files_per_packet` files each -- so a 16-file, 41-hit sweep becomes
|
|
158
|
+
several small packets instead of one that violates the slice ceiling or silently
|
|
159
|
+
drops files.
|
|
160
|
+
|
|
161
|
+
The worker still never searches. This function is what the controller runs
|
|
162
|
+
*instead of* handing the worker rg.
|
|
163
|
+
"""
|
|
164
|
+
if max_files_per_packet > MAX_SLICES:
|
|
165
|
+
raise PacketError(f"max_files_per_packet_exceeds_ceiling:{max_files_per_packet}>{MAX_SLICES}")
|
|
166
|
+
|
|
167
|
+
by_path: dict[str, dict[str, Any]] = {}
|
|
168
|
+
for hit in matches:
|
|
169
|
+
path = hit.get("path")
|
|
170
|
+
if not path:
|
|
171
|
+
raise PacketError("match_missing_path")
|
|
172
|
+
line = hit.get("line")
|
|
173
|
+
entry = by_path.setdefault(path, {"lines": [], "kinds": set()})
|
|
174
|
+
if line is not None:
|
|
175
|
+
entry["lines"].append(int(line))
|
|
176
|
+
if hit.get("kind"):
|
|
177
|
+
entry["kinds"].add(str(hit["kind"]))
|
|
178
|
+
|
|
179
|
+
if not by_path:
|
|
180
|
+
raise PacketError("no_matches_supplied")
|
|
181
|
+
|
|
182
|
+
files = sorted(by_path)
|
|
183
|
+
packets: list[dict[str, Any]] = []
|
|
184
|
+
chunks = [files[i : i + max_files_per_packet] for i in range(0, len(files), max_files_per_packet)]
|
|
185
|
+
for idx, chunk in enumerate(chunks, start=1):
|
|
186
|
+
slices = []
|
|
187
|
+
for path in chunk:
|
|
188
|
+
entry = by_path[path]
|
|
189
|
+
lines = entry["lines"]
|
|
190
|
+
if lines:
|
|
191
|
+
start = max(1, min(lines) - context_lines)
|
|
192
|
+
end = max(lines) + context_lines
|
|
193
|
+
else:
|
|
194
|
+
start = end = None
|
|
195
|
+
kinds = sorted(entry["kinds"])
|
|
196
|
+
reason = f"{len(lines) or 'unknown-count of'} hit(s)"
|
|
197
|
+
if kinds:
|
|
198
|
+
reason += f" ({', '.join(kinds)})"
|
|
199
|
+
slice_entry: dict[str, Any] = {"path": path, "reason": reason}
|
|
200
|
+
if start is not None:
|
|
201
|
+
slice_entry["start_line"] = start
|
|
202
|
+
slice_entry["end_line"] = end
|
|
203
|
+
if lines:
|
|
204
|
+
slice_entry["hit_count"] = len(lines)
|
|
205
|
+
if len(kinds) == 1:
|
|
206
|
+
slice_entry["kind"] = kinds[0]
|
|
207
|
+
slices.append(slice_entry)
|
|
208
|
+
suffix = f"-{idx}of{len(chunks)}" if len(chunks) > 1 else ""
|
|
209
|
+
packets.append(
|
|
210
|
+
build_task_packet(
|
|
211
|
+
repo_root,
|
|
212
|
+
question=question,
|
|
213
|
+
slices=slices,
|
|
214
|
+
task_id=f"{task_id_prefix}{suffix}",
|
|
215
|
+
**packet_kwargs,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return packets
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def from_ripgrep(rg_json: str | Iterable[str]) -> list[dict[str, Any]]:
|
|
222
|
+
"""Parse `rg --json` NDJSON output into `from_matches()`-shaped hits.
|
|
223
|
+
|
|
224
|
+
Runs no search itself -- pass the captured stdout of an `rg --json ...` call
|
|
225
|
+
(a single string, or an iterable of its lines). Only `type: "match"` records are
|
|
226
|
+
kept. rg is lexical only, so hits carry no `kind` -- pair with `from_ast_grep()`
|
|
227
|
+
output in the same `matches` list when structural classification is available.
|
|
228
|
+
|
|
229
|
+
Example::
|
|
230
|
+
|
|
231
|
+
raw = subprocess.run(
|
|
232
|
+
["rg", "--json", "-e", "workspace_id", "backend/workflows"],
|
|
233
|
+
capture_output=True, text=True,
|
|
234
|
+
).stdout
|
|
235
|
+
matches = recon.from_ripgrep(raw)
|
|
236
|
+
"""
|
|
237
|
+
lines = rg_json.splitlines() if isinstance(rg_json, str) else rg_json
|
|
238
|
+
hits: list[dict[str, Any]] = []
|
|
239
|
+
for line in lines:
|
|
240
|
+
line = line.strip()
|
|
241
|
+
if not line:
|
|
242
|
+
continue
|
|
243
|
+
try:
|
|
244
|
+
obj = json.loads(line)
|
|
245
|
+
except json.JSONDecodeError as exc:
|
|
246
|
+
raise PacketError(f"rg_json_line_invalid:{line[:80]}") from exc
|
|
247
|
+
if obj.get("type") != "match":
|
|
248
|
+
continue
|
|
249
|
+
data = obj.get("data", {})
|
|
250
|
+
path = (data.get("path") or {}).get("text")
|
|
251
|
+
line_number = data.get("line_number")
|
|
252
|
+
if not path or line_number is None:
|
|
253
|
+
continue
|
|
254
|
+
hits.append({"path": path, "line": int(line_number)})
|
|
255
|
+
return hits
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def from_ast_grep(sg_json_compact: str, *, kind: str | None = None) -> list[dict[str, Any]]:
|
|
259
|
+
"""Parse `sg`/`ast-grep ... --json=compact` array output into `from_matches()`-shaped hits.
|
|
260
|
+
|
|
261
|
+
Runs no search itself. One ast-grep invocation is one pattern or one inline rule --
|
|
262
|
+
i.e. one structural shape -- so `kind` is a caller-supplied label applied to every
|
|
263
|
+
hit from that call (e.g. "member", "member_camel", "sql_string"), not something
|
|
264
|
+
this function infers. Redirect the deprecation banner ast-grep prints on stderr
|
|
265
|
+
away from stdout (it does not appear in --json=compact stdout, but callers piping
|
|
266
|
+
`2>&1` will see it mixed in).
|
|
267
|
+
|
|
268
|
+
Example::
|
|
269
|
+
|
|
270
|
+
raw = subprocess.run(
|
|
271
|
+
["sg", "-p", "$X.workspace_id", "-l", "js", "--json=compact",
|
|
272
|
+
"backend/workflows"],
|
|
273
|
+
capture_output=True, text=True,
|
|
274
|
+
).stdout
|
|
275
|
+
matches = recon.from_ast_grep(raw, kind="member")
|
|
276
|
+
"""
|
|
277
|
+
text = sg_json_compact.strip()
|
|
278
|
+
if not text:
|
|
279
|
+
return []
|
|
280
|
+
try:
|
|
281
|
+
records = json.loads(text)
|
|
282
|
+
except json.JSONDecodeError as exc:
|
|
283
|
+
raise PacketError(f"sg_json_invalid:{text[:80]}") from exc
|
|
284
|
+
hits: list[dict[str, Any]] = []
|
|
285
|
+
for record in records:
|
|
286
|
+
path = record.get("file")
|
|
287
|
+
line_number = ((record.get("range") or {}).get("start") or {}).get("line")
|
|
288
|
+
if not path or line_number is None:
|
|
289
|
+
continue
|
|
290
|
+
hit: dict[str, Any] = {"path": path, "line": int(line_number)}
|
|
291
|
+
if kind:
|
|
292
|
+
hit["kind"] = kind
|
|
293
|
+
hits.append(hit)
|
|
294
|
+
return hits
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Validate a worker's ReconFindingReport against its originating packet.
|
|
2
|
+
|
|
3
|
+
This is the deterministic validator in the pipeline:
|
|
4
|
+
controller -> packet -> mini worker -> [this module] -> capable agent
|
|
5
|
+
|
|
6
|
+
It does not trust the worker. It checks structure, checks that every referenced file
|
|
7
|
+
was actually inside the packet's slices (a worker cannot invent evidence for a file it
|
|
8
|
+
was never given), and enforces the "never guess" rule: a report that isn't clearly
|
|
9
|
+
`answered` or `needs_context` is rejected rather than passed upstream.
|
|
10
|
+
|
|
11
|
+
Stdlib only.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
TOOL_NAME_VALIDATE = "repository.recon.validate"
|
|
18
|
+
REQUIRED_TOP_LEVEL = ("schema_version", "task_id", "status")
|
|
19
|
+
VALID_STATUSES = ("answered", "needs_context")
|
|
20
|
+
VALID_SEVERITIES = ("low", "medium", "high")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ReportError(ValueError):
|
|
24
|
+
"""Raised when a finding report fails validation and must not be forwarded."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _known_paths(packet: dict[str, Any]) -> set[str]:
|
|
28
|
+
return {s["path"] for s in packet.get("slices", [])}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_report(report: dict[str, Any], packet: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""Return the report unchanged if valid; raise ReportError otherwise.
|
|
33
|
+
|
|
34
|
+
Callers should treat a ReportError as "discard this worker output, do not hand it
|
|
35
|
+
to the capable agent" — never as something to silently patch and pass along.
|
|
36
|
+
"""
|
|
37
|
+
if not isinstance(report, dict):
|
|
38
|
+
raise ReportError("report_not_an_object")
|
|
39
|
+
|
|
40
|
+
missing = [key for key in REQUIRED_TOP_LEVEL if key not in report]
|
|
41
|
+
if missing:
|
|
42
|
+
raise ReportError(f"missing_fields:{','.join(missing)}")
|
|
43
|
+
|
|
44
|
+
if report.get("schema_version") != 1:
|
|
45
|
+
raise ReportError(f"unsupported_schema_version:{report.get('schema_version')}")
|
|
46
|
+
|
|
47
|
+
if report.get("task_id") != packet.get("task_id"):
|
|
48
|
+
raise ReportError("task_id_mismatch")
|
|
49
|
+
|
|
50
|
+
status = report.get("status")
|
|
51
|
+
if status not in VALID_STATUSES:
|
|
52
|
+
raise ReportError(f"invalid_status:{status}")
|
|
53
|
+
|
|
54
|
+
if status == "needs_context":
|
|
55
|
+
if not report.get("reason"):
|
|
56
|
+
raise ReportError("needs_context_missing_reason")
|
|
57
|
+
return report
|
|
58
|
+
|
|
59
|
+
# status == "answered"
|
|
60
|
+
known = _known_paths(packet)
|
|
61
|
+
for finding in report.get("findings", []):
|
|
62
|
+
for key in ("severity", "file", "finding"):
|
|
63
|
+
if key not in finding:
|
|
64
|
+
raise ReportError(f"finding_missing_field:{key}")
|
|
65
|
+
if finding["severity"] not in VALID_SEVERITIES:
|
|
66
|
+
raise ReportError(f"invalid_severity:{finding['severity']}")
|
|
67
|
+
if finding["file"] not in known:
|
|
68
|
+
raise ReportError(
|
|
69
|
+
f"finding_cites_file_outside_packet:{finding['file']}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
for path in report.get("affected_files", []):
|
|
73
|
+
if path not in known:
|
|
74
|
+
raise ReportError(f"affected_file_outside_packet:{path}")
|
|
75
|
+
|
|
76
|
+
return report
|