@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.
@@ -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