@inneranimalmedia/agentsam-sdk 2.0.0 → 2.2.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 +28 -15
- package/docs/CAPABILITIES.md +93 -0
- package/docs/DEPLOY_RECEIPTS.md +83 -0
- package/docs/RECON.md +165 -0
- package/docs/RELEASES.md +4 -3
- package/docs/portable-knowledge.md +4 -4
- package/docs/sdk-2.0-release.md +19 -21
- package/package.json +8 -1
- package/packages/identity/package.json +1 -1
- package/packages/identity/src/frontend/auth-portal/README.md +1 -1
- package/packages/identity/src/index.js +2 -0
- package/protocol/capabilities/capability-manifest.schema.json +36 -0
- package/protocol/capabilities/manifest.json +179 -0
- package/protocol/capabilities/repository-audit-input.schema.json +14 -0
- package/protocol/capabilities/repository-audit.schema.json +30 -0
- package/protocol/capabilities/repository-snapshot-input.schema.json +11 -0
- package/protocol/capabilities/repository-snapshot.schema.json +20 -0
- package/protocol/knowledge/chunk.schema.json +15 -73
- package/protocol/knowledge/document.schema.json +10 -48
- package/protocol/knowledge/index-config.schema.json +2 -2
- package/protocol/knowledge/repository.schema.json +11 -52
- package/protocol/knowledge/retrieval-query.schema.json +13 -63
- package/protocol/knowledge/source.schema.json +9 -43
- package/protocol/presets/catalog.json +48 -0
- 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/knowledge/models.py +19 -7
- package/python/agentsam_sdk/repository/__main__.py +2 -2
- 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_knowledge_models.py +6 -2
- package/python/tests/test_recon.py +257 -0
- package/src/agent/capability-adapter.js +50 -0
- package/src/agent/index.js +2 -0
- package/src/agent/repository-audit.js +188 -0
- package/src/capabilities/index.js +7 -0
- package/src/capabilities/manifest.js +22 -0
- package/src/capabilities/repository-snapshot.js +180 -0
- package/src/cli.js +68 -8
- package/src/commands/deploy-receipt.js +129 -0
- package/src/commands/deploy.js +0 -1
- package/src/commands/knowledge.js +5 -6
- package/src/commands/product.js +119 -0
- package/src/commands/recon.js +71 -0
- package/src/index.js +17 -0
- package/src/knowledge/config.js +12 -6
- package/src/knowledge/contracts.js +1 -1
- package/src/knowledge/engine.js +1 -1
- package/src/knowledge/service/server.js +2 -2
- package/src/lib/deploy-receipt/index.js +246 -0
- package/src/lib/git-context.js +3 -1
- package/src/presets/index.js +20 -0
- package/src/repository/index.js +4 -0
- package/test/agent-capabilities.test.mjs +67 -0
- package/test/capabilities.test.mjs +84 -0
- package/test/deploy-receipt.test.mjs +91 -0
- package/test/portable-context.test.mjs +11 -1
|
@@ -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
|
|
@@ -12,10 +12,14 @@ class KnowledgeContractsTest(unittest.TestCase):
|
|
|
12
12
|
return {"run_id": "run", "source_id": payload["source_id"], "status": "succeeded"}
|
|
13
13
|
return {"query_id": "query", "hits": [{"chunk_id": "chunk", "content": "code", "score": 1, "lane": "code"}]}
|
|
14
14
|
client = KnowledgeClient(Transport())
|
|
15
|
-
query = RetrievalQuery(text="function"
|
|
15
|
+
query = RetrievalQuery(text="function")
|
|
16
16
|
self.assertEqual(asyncio.run(client.retrieve(query)).hits[0].content, "code")
|
|
17
17
|
self.assertEqual(asyncio.run(client.index("source")).status, "succeeded")
|
|
18
|
-
self.assertEqual(Source("s", "repository", "
|
|
18
|
+
self.assertEqual(Source("s", "repository", "file:///repo").source_id, "s")
|
|
19
|
+
|
|
20
|
+
def test_workspace_is_optional_compatibility_metadata(self):
|
|
21
|
+
query = RetrievalQuery(text="function", workspace_id="legacy-workspace")
|
|
22
|
+
self.assertEqual(query.workspace_id, "legacy-workspace")
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Unit tests for repository.recon -- packet building and report validation.
|
|
2
|
+
|
|
3
|
+
No git dependency required (base_sha falls back to "unknown" outside a repo);
|
|
4
|
+
no model calls; no network.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentsam_sdk.repository.recon import (
|
|
12
|
+
PacketError,
|
|
13
|
+
ReportError,
|
|
14
|
+
build_task_packet,
|
|
15
|
+
from_ast_grep,
|
|
16
|
+
from_matches,
|
|
17
|
+
from_ripgrep,
|
|
18
|
+
validate_report,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestBuildTaskPacket(unittest.TestCase):
|
|
23
|
+
def setUp(self):
|
|
24
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
25
|
+
self.root = Path(self.tmp.name)
|
|
26
|
+
(self.root / "backend").mkdir()
|
|
27
|
+
(self.root / "backend" / "workflows.js").write_text(
|
|
28
|
+
"\n".join(f"line {i}" for i in range(1, 201)), encoding="utf-8"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def tearDown(self):
|
|
32
|
+
self.tmp.cleanup()
|
|
33
|
+
|
|
34
|
+
def test_builds_bounded_packet_with_content(self):
|
|
35
|
+
packet = build_task_packet(
|
|
36
|
+
self.root,
|
|
37
|
+
question="Does this file depend on workspace_id?",
|
|
38
|
+
slices=[{"path": "backend/workflows.js", "start_line": 1, "end_line": 5}],
|
|
39
|
+
)
|
|
40
|
+
self.assertEqual(packet["schema_version"], 1)
|
|
41
|
+
self.assertEqual(len(packet["slices"]), 1)
|
|
42
|
+
self.assertEqual(packet["slices"][0]["content"], "line 1\nline 2\nline 3\nline 4\nline 5")
|
|
43
|
+
self.assertEqual(packet["ceilings"]["max_follow_up_reads"], 2)
|
|
44
|
+
self.assertTrue(packet["task_id"].startswith("recon-"))
|
|
45
|
+
|
|
46
|
+
def test_rejects_missing_question(self):
|
|
47
|
+
with self.assertRaises(PacketError):
|
|
48
|
+
build_task_packet(self.root, question=" ", slices=[{"path": "backend/workflows.js"}])
|
|
49
|
+
|
|
50
|
+
def test_rejects_more_than_five_slices(self):
|
|
51
|
+
slices = [{"path": "backend/workflows.js"} for _ in range(6)]
|
|
52
|
+
with self.assertRaises(PacketError):
|
|
53
|
+
build_task_packet(self.root, question="q", slices=slices)
|
|
54
|
+
|
|
55
|
+
def test_rejects_slice_outside_repo_root(self):
|
|
56
|
+
with self.assertRaises(PacketError):
|
|
57
|
+
build_task_packet(self.root, question="q", slices=[{"path": "../../etc/passwd"}])
|
|
58
|
+
|
|
59
|
+
def test_rejects_missing_file(self):
|
|
60
|
+
with self.assertRaises(PacketError):
|
|
61
|
+
build_task_packet(self.root, question="q", slices=[{"path": "backend/nope.js"}])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestFromMatches(unittest.TestCase):
|
|
65
|
+
def setUp(self):
|
|
66
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
67
|
+
self.root = Path(self.tmp.name)
|
|
68
|
+
(self.root / "backend").mkdir()
|
|
69
|
+
for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js"):
|
|
70
|
+
(self.root / "backend" / name).write_text(
|
|
71
|
+
"\n".join(f"line {i}" for i in range(1, 51)), encoding="utf-8"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def tearDown(self):
|
|
75
|
+
self.tmp.cleanup()
|
|
76
|
+
|
|
77
|
+
def test_chunks_more_than_five_files_into_multiple_packets(self):
|
|
78
|
+
# 6 files -> ceiling is 5 per packet -> 2 packets
|
|
79
|
+
matches = [
|
|
80
|
+
{"path": f"backend/{name}", "line": 10, "kind": "member"}
|
|
81
|
+
for name in ("a.js", "b.js", "c.js", "d.js", "e.js", "f.js")
|
|
82
|
+
]
|
|
83
|
+
packets = from_matches(self.root, question="q", matches=matches, task_id_prefix="wf")
|
|
84
|
+
self.assertEqual(len(packets), 2)
|
|
85
|
+
self.assertEqual(packets[0]["task_id"], "wf-1of2")
|
|
86
|
+
self.assertEqual(packets[1]["task_id"], "wf-2of2")
|
|
87
|
+
total_slices = sum(len(p["slices"]) for p in packets)
|
|
88
|
+
self.assertEqual(total_slices, 6)
|
|
89
|
+
for p in packets:
|
|
90
|
+
self.assertLessEqual(len(p["slices"]), 5)
|
|
91
|
+
|
|
92
|
+
def test_groups_multiple_hits_in_one_file_into_one_windowed_slice(self):
|
|
93
|
+
matches = [
|
|
94
|
+
{"path": "backend/a.js", "line": 5, "kind": "sql_string"},
|
|
95
|
+
{"path": "backend/a.js", "line": 20, "kind": "sql_string"},
|
|
96
|
+
]
|
|
97
|
+
packets = from_matches(self.root, question="q", matches=matches, context_lines=2)
|
|
98
|
+
slice_ = packets[0]["slices"][0]
|
|
99
|
+
self.assertEqual(slice_["start_line"], 3)
|
|
100
|
+
self.assertEqual(slice_["end_line"], 22)
|
|
101
|
+
self.assertEqual(slice_["hit_count"], 2)
|
|
102
|
+
self.assertEqual(slice_["kind"], "sql_string")
|
|
103
|
+
|
|
104
|
+
def test_rejects_empty_matches(self):
|
|
105
|
+
with self.assertRaises(PacketError):
|
|
106
|
+
from_matches(self.root, question="q", matches=[])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestFromRipgrep(unittest.TestCase):
|
|
110
|
+
def test_parses_ndjson_match_lines_only(self):
|
|
111
|
+
# Real `rg --json` shape: begin/match/end/summary records per file.
|
|
112
|
+
ndjson = "\n".join([
|
|
113
|
+
json.dumps({"type": "begin", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
|
|
114
|
+
json.dumps({
|
|
115
|
+
"type": "match",
|
|
116
|
+
"data": {
|
|
117
|
+
"path": {"text": "backend/http/workflows/scope.js"},
|
|
118
|
+
"line_number": 66,
|
|
119
|
+
"lines": {"text": " body.workspace_id\n"},
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
json.dumps({"type": "end", "data": {"path": {"text": "backend/http/workflows/scope.js"}}}),
|
|
123
|
+
json.dumps({"type": "summary", "data": {}}),
|
|
124
|
+
])
|
|
125
|
+
hits = from_ripgrep(ndjson)
|
|
126
|
+
self.assertEqual(hits, [{"path": "backend/http/workflows/scope.js", "line": 66}])
|
|
127
|
+
|
|
128
|
+
def test_accepts_a_list_of_lines_too(self):
|
|
129
|
+
lines = [json.dumps({"type": "match", "data": {"path": {"text": "a.js"}, "line_number": 1}})]
|
|
130
|
+
self.assertEqual(from_ripgrep(lines), [{"path": "a.js", "line": 1}])
|
|
131
|
+
|
|
132
|
+
def test_empty_input_yields_no_hits(self):
|
|
133
|
+
self.assertEqual(from_ripgrep(""), [])
|
|
134
|
+
|
|
135
|
+
def test_rejects_malformed_json_line(self):
|
|
136
|
+
with self.assertRaises(PacketError):
|
|
137
|
+
from_ripgrep("not json")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TestFromAstGrep(unittest.TestCase):
|
|
141
|
+
def test_parses_compact_json_array_with_caller_supplied_kind(self):
|
|
142
|
+
# Real `sg -p '...' --json=compact` shape.
|
|
143
|
+
raw = json.dumps([
|
|
144
|
+
{
|
|
145
|
+
"text": "body.workspace_id",
|
|
146
|
+
"range": {"start": {"line": 66, "column": 76}, "end": {"line": 66, "column": 93}},
|
|
147
|
+
"file": "backend/http/workflows/scope.js",
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"text": "ctx.workspaceId",
|
|
151
|
+
"range": {"start": {"line": 12, "column": 4}, "end": {"line": 12, "column": 19}},
|
|
152
|
+
"file": "backend/workflows/handlers/tool.js",
|
|
153
|
+
},
|
|
154
|
+
])
|
|
155
|
+
hits = from_ast_grep(raw, kind="member")
|
|
156
|
+
self.assertEqual(hits, [
|
|
157
|
+
{"path": "backend/http/workflows/scope.js", "line": 66, "kind": "member"},
|
|
158
|
+
{"path": "backend/workflows/handlers/tool.js", "line": 12, "kind": "member"},
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
def test_kind_is_optional(self):
|
|
162
|
+
raw = json.dumps([{"range": {"start": {"line": 1}}, "file": "a.js"}])
|
|
163
|
+
self.assertEqual(from_ast_grep(raw), [{"path": "a.js", "line": 1}])
|
|
164
|
+
|
|
165
|
+
def test_empty_array_yields_no_hits(self):
|
|
166
|
+
self.assertEqual(from_ast_grep("[]"), [])
|
|
167
|
+
|
|
168
|
+
def test_rejects_invalid_json(self):
|
|
169
|
+
with self.assertRaises(PacketError):
|
|
170
|
+
from_ast_grep("not json")
|
|
171
|
+
|
|
172
|
+
def test_adapters_compose_into_from_matches(self):
|
|
173
|
+
# The actual workflow: run several classified sg queries + one rg sweep,
|
|
174
|
+
# concatenate, then chunk into packets -- same shape as the iMac smoke test.
|
|
175
|
+
member_hits = from_ast_grep(
|
|
176
|
+
json.dumps([{"range": {"start": {"line": 5}}, "file": "backend/a.js"}]), kind="member"
|
|
177
|
+
)
|
|
178
|
+
sql_hits = from_ast_grep(
|
|
179
|
+
json.dumps([{"range": {"start": {"line": 20}}, "file": "backend/b.js"}]), kind="sql_string"
|
|
180
|
+
)
|
|
181
|
+
rg_hits = from_ripgrep(json.dumps({
|
|
182
|
+
"type": "match", "data": {"path": {"text": "backend/c.js"}, "line_number": 8},
|
|
183
|
+
}))
|
|
184
|
+
combined = member_hits + sql_hits + rg_hits
|
|
185
|
+
self.assertEqual(len(combined), 3)
|
|
186
|
+
self.assertEqual({h["path"] for h in combined}, {"backend/a.js", "backend/b.js", "backend/c.js"})
|
|
187
|
+
self.assertEqual(combined[2].get("kind"), None) # rg hit carries no kind
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestValidateReport(unittest.TestCase):
|
|
191
|
+
def setUp(self):
|
|
192
|
+
self.packet = {
|
|
193
|
+
"task_id": "recon-abc123",
|
|
194
|
+
"slices": [{"path": "backend/workflows.js"}],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
def test_accepts_answered_report_citing_known_file(self):
|
|
198
|
+
report = {
|
|
199
|
+
"schema_version": 1,
|
|
200
|
+
"task_id": "recon-abc123",
|
|
201
|
+
"status": "answered",
|
|
202
|
+
"findings": [
|
|
203
|
+
{
|
|
204
|
+
"severity": "high",
|
|
205
|
+
"file": "backend/workflows.js",
|
|
206
|
+
"finding": "still queries workspace_id",
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
}
|
|
210
|
+
self.assertEqual(validate_report(report, self.packet), report)
|
|
211
|
+
|
|
212
|
+
def test_accepts_needs_context_with_reason(self):
|
|
213
|
+
report = {
|
|
214
|
+
"schema_version": 1,
|
|
215
|
+
"task_id": "recon-abc123",
|
|
216
|
+
"status": "needs_context",
|
|
217
|
+
"reason": "definition of ensureWorkflowRun() not supplied",
|
|
218
|
+
}
|
|
219
|
+
self.assertEqual(validate_report(report, self.packet)["status"], "needs_context")
|
|
220
|
+
|
|
221
|
+
def test_rejects_needs_context_without_reason(self):
|
|
222
|
+
report = {"schema_version": 1, "task_id": "recon-abc123", "status": "needs_context"}
|
|
223
|
+
with self.assertRaises(ReportError):
|
|
224
|
+
validate_report(report, self.packet)
|
|
225
|
+
|
|
226
|
+
def test_rejects_finding_that_cites_a_file_outside_the_packet(self):
|
|
227
|
+
report = {
|
|
228
|
+
"schema_version": 1,
|
|
229
|
+
"task_id": "recon-abc123",
|
|
230
|
+
"status": "answered",
|
|
231
|
+
"findings": [
|
|
232
|
+
{"severity": "high", "file": "backend/other.js", "finding": "made up"}
|
|
233
|
+
],
|
|
234
|
+
}
|
|
235
|
+
with self.assertRaises(ReportError):
|
|
236
|
+
validate_report(report, self.packet)
|
|
237
|
+
|
|
238
|
+
def test_rejects_task_id_mismatch(self):
|
|
239
|
+
report = {"schema_version": 1, "task_id": "recon-different", "status": "needs_context", "reason": "x"}
|
|
240
|
+
with self.assertRaises(ReportError):
|
|
241
|
+
validate_report(report, self.packet)
|
|
242
|
+
|
|
243
|
+
def test_rejects_invalid_severity(self):
|
|
244
|
+
report = {
|
|
245
|
+
"schema_version": 1,
|
|
246
|
+
"task_id": "recon-abc123",
|
|
247
|
+
"status": "answered",
|
|
248
|
+
"findings": [
|
|
249
|
+
{"severity": "critical", "file": "backend/workflows.js", "finding": "x"}
|
|
250
|
+
],
|
|
251
|
+
}
|
|
252
|
+
with self.assertRaises(ReportError):
|
|
253
|
+
validate_report(report, self.packet)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if __name__ == "__main__":
|
|
257
|
+
unittest.main()
|