qaas-python 0.1.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.
- qaas/adapters/__init__.py +19 -0
- qaas/adapters/tracker.py +1350 -0
- qaas/adapters/vcs.py +494 -0
- qaas/cli.py +1564 -0
- qaas/conductor.py +527 -0
- qaas/config.py +407 -0
- qaas/defaults/config/agents/arbiter.yaml +19 -0
- qaas/defaults/config/agents/cartographer.yaml +20 -0
- qaas/defaults/config/agents/clerk.yaml +21 -0
- qaas/defaults/config/agents/conduit.yaml +19 -0
- qaas/defaults/config/agents/forge.yaml +22 -0
- qaas/defaults/config/agents/mender.yaml +56 -0
- qaas/defaults/config/agents/proof.yaml +21 -0
- qaas/defaults/config/agents/surface.yaml +16 -0
- qaas/defaults/config/system.yaml +69 -0
- qaas/discover.py +227 -0
- qaas/envelope.py +290 -0
- qaas/guardrails.py +431 -0
- qaas/mcp/__init__.py +0 -0
- qaas/mcp/context.py +70 -0
- qaas/mcp/contract_diff.py +937 -0
- qaas/mcp/defect_memory.py +495 -0
- qaas/mcp/env_control.py +905 -0
- qaas/mcp/envelope_server.py +463 -0
- qaas/mcp/test_runner.py +773 -0
- qaas/mcp/tracker.py +412 -0
- qaas/mcp/vcs.py +506 -0
- qaas/paths.py +317 -0
- qaas/plugin/.claude-plugin/plugin.json +9 -0
- qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
- qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
- qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
- qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
- qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
- qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
- qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
- qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
- qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
- qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
- qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
- qaas/plugin/skills/flake-detection/SKILL.md +39 -0
- qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
- qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
- qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
- qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
- qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
- qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
- qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
- qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
- qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
- qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
- qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
- qaas/plugin/skills/routing-rules/SKILL.md +34 -0
- qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
- qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
- qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
- qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
- qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
- qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
- qaas/prompts/ARBITER.md +53 -0
- qaas/prompts/CARTOGRAPHER.md +46 -0
- qaas/prompts/CLERK.md +45 -0
- qaas/prompts/CONDUIT.md +44 -0
- qaas/prompts/FORGE.md +43 -0
- qaas/prompts/MENDER.md +55 -0
- qaas/prompts/PROOF.md +41 -0
- qaas/prompts/SURFACE.md +46 -0
- qaas/prompts/_shared.md +45 -0
- qaas/registry.py +465 -0
- qaas/runner.py +192 -0
- qaas/scorecard.py +425 -0
- qaas/sdk_compat.py +52 -0
- qaas/store.py +290 -0
- qaas/target.py +261 -0
- qaas/tasks.py +361 -0
- qaas/trace.py +270 -0
- qaas_python-0.1.0.dist-info/METADATA +388 -0
- qaas_python-0.1.0.dist-info/RECORD +81 -0
- qaas_python-0.1.0.dist-info/WHEEL +4 -0
- qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
- qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""The `defect_memory` MCP server — the thing that stops duplicate storms.
|
|
2
|
+
|
|
3
|
+
§10 names duplicate storms as a top failure mode and makes this server
|
|
4
|
+
mandatory. The memory outlives the run: it lives in one SQLite file under the
|
|
5
|
+
state root, because dedupe across *runs* is the whole point — a defect found
|
|
6
|
+
again next week must land on the ticket filed today, not beside it.
|
|
7
|
+
|
|
8
|
+
Similarity here is deterministic and offline on purpose. An embedding model
|
|
9
|
+
would make matching fuzzier and the system less explainable: two agents asking
|
|
10
|
+
the same question a minute apart must get the same answer, and a reviewer must
|
|
11
|
+
be able to say exactly why two reports were called the same defect. So the
|
|
12
|
+
score combines three things a human would also use — the structural fingerprint,
|
|
13
|
+
the code location, and the words — with fixed weights.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sqlite3
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from claude_agent_sdk import create_sdk_mcp_server, tool
|
|
25
|
+
from pydantic import ValidationError
|
|
26
|
+
|
|
27
|
+
from qaas.envelope import DefectEnvelope, Location, _strip_line_number
|
|
28
|
+
from qaas.mcp.context import ToolContext, err, ok
|
|
29
|
+
|
|
30
|
+
MEMORY_DB = "memory.db"
|
|
31
|
+
|
|
32
|
+
# Weights sum to 1.0. The fingerprint is the strongest single signal because it
|
|
33
|
+
# is already a structural identity; prose is the weakest because two agents
|
|
34
|
+
# describing one defect share surprisingly few words.
|
|
35
|
+
W_FINGERPRINT = 0.40
|
|
36
|
+
W_LOCATION = 0.35
|
|
37
|
+
W_TOKENS = 0.25
|
|
38
|
+
|
|
39
|
+
# Below this, a candidate is not worth an agent's attention. Set so that a
|
|
40
|
+
# location match alone clears it (0.35) but prose overlap alone never does.
|
|
41
|
+
MIN_SIMILARITY = 0.35
|
|
42
|
+
|
|
43
|
+
# A different domain is strong evidence of a different defect, so it halves an
|
|
44
|
+
# otherwise convincing score rather than being another additive term.
|
|
45
|
+
CROSS_DOMAIN_PENALTY = 0.5
|
|
46
|
+
|
|
47
|
+
DEFAULT_LIMIT = 10
|
|
48
|
+
|
|
49
|
+
# Words that appear in nearly every defect report carry no discriminating
|
|
50
|
+
# signal; leaving them in makes every pair of reports look 30% alike.
|
|
51
|
+
_STOPWORDS = frozenset(
|
|
52
|
+
"""
|
|
53
|
+
the and for with that this from when then than but not are was were will
|
|
54
|
+
have has had does did doing any all its it's you your our their there here
|
|
55
|
+
into onto over under also only just some more most much very can could
|
|
56
|
+
should would may might must shall while which who whom whose what where why
|
|
57
|
+
how because since about after before between during without within
|
|
58
|
+
bug issue defect error problem fault failure broken breaks fails failing
|
|
59
|
+
endpoint request response server client user users page screen app
|
|
60
|
+
""".split()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_SCHEMA = """
|
|
64
|
+
CREATE TABLE IF NOT EXISTS defects (
|
|
65
|
+
fingerprint TEXT PRIMARY KEY,
|
|
66
|
+
title TEXT NOT NULL,
|
|
67
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
68
|
+
domain TEXT NOT NULL,
|
|
69
|
+
defect_class TEXT NOT NULL DEFAULT '',
|
|
70
|
+
service TEXT,
|
|
71
|
+
endpoint TEXT,
|
|
72
|
+
ui_route TEXT,
|
|
73
|
+
paths TEXT NOT NULL DEFAULT '[]',
|
|
74
|
+
ticket_key TEXT,
|
|
75
|
+
occurrence_count INTEGER NOT NULL DEFAULT 1,
|
|
76
|
+
first_seen TEXT NOT NULL,
|
|
77
|
+
last_seen TEXT NOT NULL,
|
|
78
|
+
last_run_id TEXT,
|
|
79
|
+
resolved_at TEXT,
|
|
80
|
+
resolved_ticket_key TEXT
|
|
81
|
+
);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS defects_domain ON defects(domain);
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _utcnow_iso() -> str:
|
|
87
|
+
return datetime.now(timezone.utc).isoformat()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def connect(root: Path | str) -> sqlite3.Connection:
|
|
91
|
+
"""Open (and, first time, create) the shared defect memory."""
|
|
92
|
+
root = Path(root)
|
|
93
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
conn = sqlite3.connect(root / MEMORY_DB)
|
|
95
|
+
conn.row_factory = sqlite3.Row
|
|
96
|
+
conn.executescript(_SCHEMA)
|
|
97
|
+
return conn
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -- similarity -----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tokens(text: str) -> set[str]:
|
|
104
|
+
"""Content words of a report, lowercased and de-noised."""
|
|
105
|
+
words = "".join(c if c.isalnum() else " " for c in text.lower()).split()
|
|
106
|
+
return {w for w in words if len(w) >= 3 and w not in _STOPWORDS}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def jaccard(a: set[str], b: set[str]) -> float:
|
|
110
|
+
if not a or not b:
|
|
111
|
+
return 0.0
|
|
112
|
+
return len(a & b) / len(a | b)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _paths(raw: Any) -> set[str]:
|
|
116
|
+
"""Normalised path set. Line numbers are dropped for the same reason
|
|
117
|
+
`DefectEnvelope.fingerprint` drops them: code moves, the defect does not."""
|
|
118
|
+
if isinstance(raw, str):
|
|
119
|
+
raw = json.loads(raw or "[]")
|
|
120
|
+
return {_strip_line_number(p) for p in (raw or []) if p}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def location_score(query: dict[str, Any], row: dict[str, Any]) -> float:
|
|
124
|
+
"""How much of the location the two reports agree on.
|
|
125
|
+
|
|
126
|
+
Only fields *both* sides supplied are compared — a query that omits
|
|
127
|
+
`ui_route` should not be penalised against a row that has one, or every
|
|
128
|
+
partially-specified search would score zero.
|
|
129
|
+
"""
|
|
130
|
+
parts: list[float] = []
|
|
131
|
+
for field in ("service", "endpoint", "ui_route"):
|
|
132
|
+
a, b = (query.get(field) or "").strip().lower(), (row.get(field) or "").strip().lower()
|
|
133
|
+
if a and b:
|
|
134
|
+
parts.append(1.0 if a == b else 0.0)
|
|
135
|
+
qp, rp = _paths(query.get("paths")), _paths(row.get("paths"))
|
|
136
|
+
if qp and rp:
|
|
137
|
+
parts.append(len(qp & rp) / len(qp | rp))
|
|
138
|
+
return sum(parts) / len(parts) if parts else 0.0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def similarity(query: dict[str, Any], row: dict[str, Any]) -> float:
|
|
142
|
+
"""Deterministic 0..1 similarity between a search and a remembered defect."""
|
|
143
|
+
fingerprint_match = 1.0 if query.get("fingerprint") and query["fingerprint"] == row.get("fingerprint") else 0.0
|
|
144
|
+
location = location_score(query, row)
|
|
145
|
+
prose = jaccard(
|
|
146
|
+
tokens(f"{query.get('title', '')} {query.get('summary', '')}"),
|
|
147
|
+
tokens(f"{row.get('title', '')} {row.get('summary', '')}"),
|
|
148
|
+
)
|
|
149
|
+
score = W_FINGERPRINT * fingerprint_match + W_LOCATION * location + W_TOKENS * prose
|
|
150
|
+
if query.get("domain") and row.get("domain") and query["domain"] != row["domain"]:
|
|
151
|
+
score *= CROSS_DOMAIN_PENALTY
|
|
152
|
+
return round(score, 4)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _probe_fingerprint(ctx: ToolContext, args: dict[str, Any]) -> str | None:
|
|
156
|
+
"""The structural fingerprint the searched-for defect *would* have.
|
|
157
|
+
|
|
158
|
+
Built by constructing a throwaway envelope so the hash comes from
|
|
159
|
+
`DefectEnvelope.fingerprint()` and cannot drift from the one stored on real
|
|
160
|
+
envelopes. Needs the defect class, so it returns None when the caller only
|
|
161
|
+
has a partial description — the other two signals still apply.
|
|
162
|
+
"""
|
|
163
|
+
if not args.get("class"):
|
|
164
|
+
return None
|
|
165
|
+
try:
|
|
166
|
+
probe = DefectEnvelope(
|
|
167
|
+
run_id=ctx.store.run_id,
|
|
168
|
+
discovered_by=ctx.agent.name,
|
|
169
|
+
domain=args["domain"],
|
|
170
|
+
**{"class": args["class"]},
|
|
171
|
+
title=(args.get("title") or "probe")[:90],
|
|
172
|
+
summary=args.get("summary") or "probe",
|
|
173
|
+
severity="minor",
|
|
174
|
+
confidence=0.5,
|
|
175
|
+
location=Location(
|
|
176
|
+
service=args.get("service"),
|
|
177
|
+
endpoint=args.get("endpoint"),
|
|
178
|
+
ui_route=args.get("ui_route"),
|
|
179
|
+
paths=list(args.get("paths") or []),
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
except ValidationError:
|
|
183
|
+
return None
|
|
184
|
+
return probe.fingerprint()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _row_view(row: sqlite3.Row, score: float | None = None) -> dict[str, Any]:
|
|
188
|
+
view: dict[str, Any] = {
|
|
189
|
+
"fingerprint": row["fingerprint"],
|
|
190
|
+
"title": row["title"],
|
|
191
|
+
"domain": row["domain"],
|
|
192
|
+
"class": row["defect_class"],
|
|
193
|
+
"ticket_key": row["ticket_key"],
|
|
194
|
+
"occurrence_count": row["occurrence_count"],
|
|
195
|
+
"first_seen": row["first_seen"],
|
|
196
|
+
"last_seen": row["last_seen"],
|
|
197
|
+
"resolved": row["resolved_at"] is not None,
|
|
198
|
+
"location": {
|
|
199
|
+
"service": row["service"],
|
|
200
|
+
"endpoint": row["endpoint"],
|
|
201
|
+
"ui_route": row["ui_route"],
|
|
202
|
+
"paths": json.loads(row["paths"] or "[]"),
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
if score is not None:
|
|
206
|
+
view["similarity"] = score
|
|
207
|
+
return view
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
SEARCH_SCHEMA: dict[str, Any] = {
|
|
211
|
+
"type": "object",
|
|
212
|
+
"required": ["title", "summary", "domain"],
|
|
213
|
+
"properties": {
|
|
214
|
+
"title": {"type": "string", "description": "The candidate defect's title."},
|
|
215
|
+
"summary": {"type": "string", "description": "The candidate defect's summary."},
|
|
216
|
+
"domain": {
|
|
217
|
+
"type": "string",
|
|
218
|
+
"enum": ["architecture", "database", "api", "websocket", "frontend", "ux", "security", "performance"],
|
|
219
|
+
},
|
|
220
|
+
"class": {
|
|
221
|
+
"type": "string",
|
|
222
|
+
"enum": ["bug", "regression", "ux-friction", "tech-debt", "vulnerability", "perf-regression"],
|
|
223
|
+
"description": "Supply it when you have it: it enables exact fingerprint matching.",
|
|
224
|
+
},
|
|
225
|
+
"service": {"type": "string"},
|
|
226
|
+
"endpoint": {"type": "string", "description": "e.g. 'GET /v1/orders'"},
|
|
227
|
+
"ui_route": {"type": "string", "description": "e.g. '/checkout/review'"},
|
|
228
|
+
"paths": {"type": "array", "items": {"type": "string"}, "description": "Repo-relative paths."},
|
|
229
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
235
|
+
"""The defect memory tools, bound to one agent's run context.
|
|
236
|
+
|
|
237
|
+
Split from `build` so tests can call the handlers directly without standing
|
|
238
|
+
up an MCP transport.
|
|
239
|
+
"""
|
|
240
|
+
root = ctx.store.root
|
|
241
|
+
|
|
242
|
+
@tool(
|
|
243
|
+
"search_similar",
|
|
244
|
+
"Search remembered defects for prior reports of this one. Call this BEFORE filing "
|
|
245
|
+
"anything: a match means increment the existing ticket, not open a second one.",
|
|
246
|
+
SEARCH_SCHEMA,
|
|
247
|
+
)
|
|
248
|
+
async def search_similar(args: dict[str, Any]) -> dict[str, Any]:
|
|
249
|
+
query = dict(args)
|
|
250
|
+
query["fingerprint"] = _probe_fingerprint(ctx, args)
|
|
251
|
+
limit = int(args.get("limit") or DEFAULT_LIMIT)
|
|
252
|
+
|
|
253
|
+
conn = connect(root)
|
|
254
|
+
try:
|
|
255
|
+
rows = conn.execute("SELECT * FROM defects").fetchall()
|
|
256
|
+
finally:
|
|
257
|
+
conn.close()
|
|
258
|
+
|
|
259
|
+
scored = [(similarity(query, dict(r)), r) for r in rows]
|
|
260
|
+
matches = sorted(
|
|
261
|
+
((s, r) for s, r in scored if s >= MIN_SIMILARITY),
|
|
262
|
+
key=lambda pair: (-pair[0], pair[1]["fingerprint"]),
|
|
263
|
+
)[:limit]
|
|
264
|
+
|
|
265
|
+
if not matches:
|
|
266
|
+
return ok(
|
|
267
|
+
f"No prior defect resembles '{args['title']}' "
|
|
268
|
+
f"({len(rows)} in memory, none above {MIN_SIMILARITY}). This looks new.",
|
|
269
|
+
candidates=[],
|
|
270
|
+
searched=len(rows),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
candidates = [_row_view(r, s) for s, r in matches]
|
|
274
|
+
lines = [
|
|
275
|
+
f" {c['similarity']:.2f} {c['ticket_key'] or 'unfiled'} "
|
|
276
|
+
f"x{c['occurrence_count']} last seen {c['last_seen']} {c['title']}"
|
|
277
|
+
+ (" [RESOLVED — a recurrence is a regression]" if c["resolved"] else "")
|
|
278
|
+
for c in candidates
|
|
279
|
+
]
|
|
280
|
+
return ok(
|
|
281
|
+
f"{len(candidates)} prior defect(s) resemble this one:\n" + "\n".join(lines),
|
|
282
|
+
candidates=candidates,
|
|
283
|
+
searched=len(rows),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@tool(
|
|
287
|
+
"fingerprint",
|
|
288
|
+
"Return the structural fingerprint of an envelope you already emitted. "
|
|
289
|
+
"Two reports of the same defect share it regardless of wording.",
|
|
290
|
+
{
|
|
291
|
+
"type": "object",
|
|
292
|
+
"required": ["envelope_id"],
|
|
293
|
+
"properties": {"envelope_id": {"type": "string"}},
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
async def fingerprint(args: dict[str, Any]) -> dict[str, Any]:
|
|
297
|
+
envelope = ctx.store.get_envelope(args["envelope_id"])
|
|
298
|
+
if envelope is None:
|
|
299
|
+
return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
|
|
300
|
+
return ok(
|
|
301
|
+
f"{envelope.fingerprint()} — {envelope.title}",
|
|
302
|
+
envelope_id=envelope.id,
|
|
303
|
+
fingerprint=envelope.fingerprint(),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
@tool(
|
|
307
|
+
"record",
|
|
308
|
+
"Remember this defect so future runs can dedupe against it. A second record of a "
|
|
309
|
+
"known fingerprint increments its occurrence count instead of duplicating it.",
|
|
310
|
+
{
|
|
311
|
+
"type": "object",
|
|
312
|
+
"required": ["envelope_id"],
|
|
313
|
+
"properties": {
|
|
314
|
+
"envelope_id": {"type": "string"},
|
|
315
|
+
"ticket_key": {"type": "string", "description": "The tracker key, if one was filed."},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
async def record(args: dict[str, Any]) -> dict[str, Any]:
|
|
320
|
+
envelope = ctx.store.get_envelope(args["envelope_id"])
|
|
321
|
+
if envelope is None:
|
|
322
|
+
return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
|
|
323
|
+
|
|
324
|
+
fp = envelope.fingerprint()
|
|
325
|
+
ticket_key = args.get("ticket_key") or None
|
|
326
|
+
now = _utcnow_iso()
|
|
327
|
+
|
|
328
|
+
conn = connect(root)
|
|
329
|
+
try:
|
|
330
|
+
row = conn.execute("SELECT * FROM defects WHERE fingerprint = ?", (fp,)).fetchone()
|
|
331
|
+
if row is None:
|
|
332
|
+
conn.execute(
|
|
333
|
+
"INSERT INTO defects (fingerprint, title, summary, domain, defect_class, "
|
|
334
|
+
"service, endpoint, ui_route, paths, ticket_key, occurrence_count, "
|
|
335
|
+
"first_seen, last_seen, last_run_id) "
|
|
336
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,1,?,?,?)",
|
|
337
|
+
(
|
|
338
|
+
fp,
|
|
339
|
+
envelope.title,
|
|
340
|
+
envelope.summary,
|
|
341
|
+
envelope.domain.value,
|
|
342
|
+
envelope.defect_class.value,
|
|
343
|
+
envelope.location.service,
|
|
344
|
+
envelope.location.endpoint,
|
|
345
|
+
envelope.location.ui_route,
|
|
346
|
+
json.dumps(sorted(envelope.location.paths)),
|
|
347
|
+
ticket_key,
|
|
348
|
+
now,
|
|
349
|
+
now,
|
|
350
|
+
ctx.store.run_id,
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
conn.commit()
|
|
354
|
+
ctx.store.log(
|
|
355
|
+
"defect_memory", agent=ctx.agent.name, action="new",
|
|
356
|
+
fingerprint=fp, envelope_id=envelope.id, ticket_key=ticket_key,
|
|
357
|
+
)
|
|
358
|
+
return ok(
|
|
359
|
+
f"New defect recorded. Fingerprint {fp}, occurrence 1"
|
|
360
|
+
+ (f", ticket {ticket_key}." if ticket_key else ", no ticket yet."),
|
|
361
|
+
fingerprint=fp,
|
|
362
|
+
occurrence_count=1,
|
|
363
|
+
ticket_key=ticket_key,
|
|
364
|
+
regression=False,
|
|
365
|
+
first_time=True,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
was_resolved = row["resolved_at"] is not None
|
|
369
|
+
count = row["occurrence_count"] + 1
|
|
370
|
+
# resolved_at is cleared unconditionally: a defect that is back is
|
|
371
|
+
# open again, whatever the tracker still says.
|
|
372
|
+
conn.execute(
|
|
373
|
+
"UPDATE defects SET occurrence_count = ?, last_seen = ?, last_run_id = ?, "
|
|
374
|
+
"ticket_key = COALESCE(?, ticket_key), resolved_at = NULL "
|
|
375
|
+
"WHERE fingerprint = ?",
|
|
376
|
+
(count, now, ctx.store.run_id, ticket_key, fp),
|
|
377
|
+
)
|
|
378
|
+
conn.commit()
|
|
379
|
+
known_ticket = ticket_key or row["ticket_key"]
|
|
380
|
+
finally:
|
|
381
|
+
conn.close()
|
|
382
|
+
|
|
383
|
+
if was_resolved:
|
|
384
|
+
closed_under = row["resolved_ticket_key"] or row["ticket_key"] or "an earlier ticket"
|
|
385
|
+
ctx.store.log(
|
|
386
|
+
"regression", agent=ctx.agent.name, fingerprint=fp,
|
|
387
|
+
envelope_id=envelope.id, resolved_ticket_key=closed_under,
|
|
388
|
+
)
|
|
389
|
+
return ok(
|
|
390
|
+
f"REGRESSION: this is a regression of {closed_under}. The defect with "
|
|
391
|
+
f"fingerprint {fp} was marked resolved on {row['resolved_at']} and has come "
|
|
392
|
+
f"back (occurrence {count}). File it as a regression and link it to "
|
|
393
|
+
f"{closed_under} — do not close it as a duplicate.",
|
|
394
|
+
fingerprint=fp,
|
|
395
|
+
occurrence_count=count,
|
|
396
|
+
ticket_key=known_ticket,
|
|
397
|
+
regression=True,
|
|
398
|
+
regression_of=closed_under,
|
|
399
|
+
previously_resolved_at=row["resolved_at"],
|
|
400
|
+
first_time=False,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
ctx.store.log(
|
|
404
|
+
"defect_memory", agent=ctx.agent.name, action="occurrence",
|
|
405
|
+
fingerprint=fp, envelope_id=envelope.id, occurrence_count=count,
|
|
406
|
+
)
|
|
407
|
+
return ok(
|
|
408
|
+
f"Known defect: occurrence {count} of fingerprint {fp}"
|
|
409
|
+
+ (f", already tracked as {known_ticket}. Add evidence there; do not file again."
|
|
410
|
+
if known_ticket else ". Still unfiled."),
|
|
411
|
+
fingerprint=fp,
|
|
412
|
+
occurrence_count=count,
|
|
413
|
+
ticket_key=known_ticket,
|
|
414
|
+
regression=False,
|
|
415
|
+
first_time=False,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
@tool(
|
|
419
|
+
"get_occurrences",
|
|
420
|
+
"How often this fingerprint has been seen, when, and under which ticket.",
|
|
421
|
+
{
|
|
422
|
+
"type": "object",
|
|
423
|
+
"required": ["fingerprint"],
|
|
424
|
+
"properties": {"fingerprint": {"type": "string"}},
|
|
425
|
+
},
|
|
426
|
+
)
|
|
427
|
+
async def get_occurrences(args: dict[str, Any]) -> dict[str, Any]:
|
|
428
|
+
conn = connect(root)
|
|
429
|
+
try:
|
|
430
|
+
row = conn.execute(
|
|
431
|
+
"SELECT * FROM defects WHERE fingerprint = ?", (args["fingerprint"],)
|
|
432
|
+
).fetchone()
|
|
433
|
+
finally:
|
|
434
|
+
conn.close()
|
|
435
|
+
if row is None:
|
|
436
|
+
return err(
|
|
437
|
+
f"Fingerprint {args['fingerprint']} is not in defect memory. "
|
|
438
|
+
"Either it is genuinely new, or you have the wrong fingerprint."
|
|
439
|
+
)
|
|
440
|
+
state = "resolved" if row["resolved_at"] else "open"
|
|
441
|
+
return ok(
|
|
442
|
+
f"{row['occurrence_count']} occurrence(s), first {row['first_seen']}, "
|
|
443
|
+
f"last {row['last_seen']}, ticket {row['ticket_key'] or 'none'} ({state}).",
|
|
444
|
+
**_row_view(row),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
@tool(
|
|
448
|
+
"mark_resolved",
|
|
449
|
+
"Mark this defect resolved. A later recurrence is then reported as a regression "
|
|
450
|
+
"rather than a duplicate — which is the difference between reopening and ignoring it.",
|
|
451
|
+
{
|
|
452
|
+
"type": "object",
|
|
453
|
+
"required": ["fingerprint"],
|
|
454
|
+
"properties": {
|
|
455
|
+
"fingerprint": {"type": "string"},
|
|
456
|
+
"ticket_key": {"type": "string", "description": "The ticket it was resolved under."},
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
)
|
|
460
|
+
async def mark_resolved(args: dict[str, Any]) -> dict[str, Any]:
|
|
461
|
+
fp = args["fingerprint"]
|
|
462
|
+
ticket_key = args.get("ticket_key") or None
|
|
463
|
+
now = _utcnow_iso()
|
|
464
|
+
conn = connect(root)
|
|
465
|
+
try:
|
|
466
|
+
row = conn.execute("SELECT * FROM defects WHERE fingerprint = ?", (fp,)).fetchone()
|
|
467
|
+
if row is None:
|
|
468
|
+
return err(f"Fingerprint {fp} is not in defect memory; nothing to resolve.")
|
|
469
|
+
resolved_under = ticket_key or row["ticket_key"]
|
|
470
|
+
conn.execute(
|
|
471
|
+
"UPDATE defects SET resolved_at = ?, resolved_ticket_key = ?, "
|
|
472
|
+
"ticket_key = COALESCE(?, ticket_key) WHERE fingerprint = ?",
|
|
473
|
+
(now, resolved_under, ticket_key, fp),
|
|
474
|
+
)
|
|
475
|
+
conn.commit()
|
|
476
|
+
finally:
|
|
477
|
+
conn.close()
|
|
478
|
+
ctx.store.log(
|
|
479
|
+
"defect_memory", agent=ctx.agent.name, action="resolved",
|
|
480
|
+
fingerprint=fp, ticket_key=resolved_under,
|
|
481
|
+
)
|
|
482
|
+
return ok(
|
|
483
|
+
f"Marked {fp} resolved under {resolved_under or 'no ticket'}. "
|
|
484
|
+
"If it comes back, it will be reported as a regression.",
|
|
485
|
+
fingerprint=fp,
|
|
486
|
+
resolved_at=now,
|
|
487
|
+
resolved_ticket_key=resolved_under,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return [search_similar, fingerprint, record, get_occurrences, mark_resolved]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def build(ctx: ToolContext):
|
|
494
|
+
"""Construct the defect memory MCP server bound to one agent's run context."""
|
|
495
|
+
return create_sdk_mcp_server(name="defect_memory", version="1.0.0", tools=build_tools(ctx))
|