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
qaas/registry.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Turning an AgentSpec into a runnable agent.
|
|
2
|
+
|
|
3
|
+
One agent is one top-level `query()` with its own options: its own system prompt,
|
|
4
|
+
its own MCP servers, its own tool allowlist, its own budget. Not a subagent of a
|
|
5
|
+
shared parent — that would pool the cost into one number and blur the per-agent
|
|
6
|
+
allowlist that §5.3 depends on.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any, Callable, Sequence
|
|
16
|
+
|
|
17
|
+
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
|
|
18
|
+
|
|
19
|
+
from qaas.config import AgentSpec
|
|
20
|
+
from qaas.guardrails import ALWAYS_GRANTED, Guardrail
|
|
21
|
+
from qaas.mcp.context import ToolContext
|
|
22
|
+
from qaas.sdk_compat import POST_TOOL_USE, PRE_TOOL_USE, STOP, mcp_server_wildcard
|
|
23
|
+
|
|
24
|
+
PROMPTS_DIR = Path(__file__).parent / "prompts"
|
|
25
|
+
SHARED_PROMPT = "_shared.md"
|
|
26
|
+
|
|
27
|
+
#: `CONDUIT.md` -> `CONDUIT.append.md`. The suffix exists because the only other
|
|
28
|
+
#: way to add three house lines to a shipped prompt is to fork the whole file,
|
|
29
|
+
#: and a forked prompt stops receiving the next release's improvements to it --
|
|
30
|
+
#: silently, and in the one part of the system where silence is most expensive.
|
|
31
|
+
APPEND_SUFFIX = ".append.md"
|
|
32
|
+
|
|
33
|
+
# name in config/agents/*.yaml -> the module providing `build(ctx)`.
|
|
34
|
+
# Off-the-shelf servers (playwright) are stdio subprocesses, handled separately.
|
|
35
|
+
SDK_SERVER_MODULES: dict[str, str] = {
|
|
36
|
+
"envelope": "qaas.mcp.envelope_server",
|
|
37
|
+
"defect_memory": "qaas.mcp.defect_memory",
|
|
38
|
+
"tracker": "qaas.mcp.tracker",
|
|
39
|
+
"test_runner": "qaas.mcp.test_runner",
|
|
40
|
+
"env_control": "qaas.mcp.env_control",
|
|
41
|
+
"contract_diff": "qaas.mcp.contract_diff",
|
|
42
|
+
"vcs": "qaas.mcp.vcs",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
STDIO_SERVERS: dict[str, dict[str, Any]] = {
|
|
46
|
+
"playwright": {
|
|
47
|
+
"type": "stdio",
|
|
48
|
+
"command": "npx",
|
|
49
|
+
"args": ["-y", "@playwright/mcp@latest", "--isolated", "--browser", "chromium"],
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UnknownServer(KeyError):
|
|
55
|
+
"""A config names an MCP server nothing provides."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_prompt_dirs(ctx: ToolContext | None = None) -> tuple[Path, ...]:
|
|
59
|
+
"""The prompt search path: overrides first, packaged last.
|
|
60
|
+
|
|
61
|
+
Same shape as `skill_plugins` -- a ToolContext may carry a workspace, and
|
|
62
|
+
anything without one asks the resolver. Prompts used to be read from
|
|
63
|
+
`PROMPTS_DIR` unconditionally, which meant a `pip install` user could not
|
|
64
|
+
change a single line of any prompt without editing site-packages.
|
|
65
|
+
"""
|
|
66
|
+
from qaas.paths import Workspace
|
|
67
|
+
|
|
68
|
+
ws = getattr(ctx, "workspace", None) or Workspace.resolve()
|
|
69
|
+
return tuple(ws.prompt_dirs)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _first_hit(dirs: Sequence[Path], relative: str) -> Path | None:
|
|
73
|
+
for d in dirs:
|
|
74
|
+
candidate = Path(d) / relative
|
|
75
|
+
if candidate.is_file():
|
|
76
|
+
return candidate
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def append_name(prompt: str) -> str:
|
|
81
|
+
"""`CONDUIT.md` -> `CONDUIT.append.md`, keeping any subdirectory."""
|
|
82
|
+
return str(PurePosixPath(prompt).with_suffix("")) + APPEND_SUFFIX
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def append_paths(dirs: Sequence[Path], prompt: str) -> list[Path]:
|
|
86
|
+
"""Every `<AGENT>.append.md` on the search path, broadest layer first.
|
|
87
|
+
|
|
88
|
+
Not first-hit-wins: appends accumulate rather than shadow, so an
|
|
89
|
+
organisation-wide `QAAS_HOME` addendum and a project's own both apply. They
|
|
90
|
+
are ordered lowest-precedence first so the nearest layer speaks last, which
|
|
91
|
+
is both how a reader expects the specific to follow the general and how a
|
|
92
|
+
model weights the end of a block.
|
|
93
|
+
"""
|
|
94
|
+
name = append_name(prompt)
|
|
95
|
+
found: list[Path] = []
|
|
96
|
+
for d in reversed(list(dirs)):
|
|
97
|
+
candidate = Path(d) / name
|
|
98
|
+
if candidate.is_file():
|
|
99
|
+
found.append(candidate)
|
|
100
|
+
return found
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build_system_prompt(
|
|
104
|
+
spec: AgentSpec, prompt_dirs: Sequence[Path] | None = None
|
|
105
|
+
) -> str:
|
|
106
|
+
"""The agent's prompt, its local addenda, and the house rules every agent shares.
|
|
107
|
+
|
|
108
|
+
Kept as separate files so a change to the shared rules reaches every agent at
|
|
109
|
+
once, rather than being copy-pasted into six prompts that then drift.
|
|
110
|
+
|
|
111
|
+
Each file is resolved first-hit-wins **independently**: overriding
|
|
112
|
+
`CONDUIT.md` keeps the house `_shared.md`, and replacing `_shared.md` keeps
|
|
113
|
+
all eight agent prompts. Resolving the pair from one winning directory would
|
|
114
|
+
make either override drag the other along.
|
|
115
|
+
|
|
116
|
+
Order is agent, then addenda, then shared: the house rules are the last word,
|
|
117
|
+
and an addendum that could displace them would be an enforcement hole opened
|
|
118
|
+
from a text file.
|
|
119
|
+
"""
|
|
120
|
+
dirs = tuple(prompt_dirs) if prompt_dirs is not None else resolve_prompt_dirs()
|
|
121
|
+
own = _first_hit(dirs, spec.prompt)
|
|
122
|
+
if own is None:
|
|
123
|
+
where = ", ".join(str(d) for d in dirs) or "(no prompt directories)"
|
|
124
|
+
raise FileNotFoundError(f"{spec.name} has no prompt '{spec.prompt}' in: {where}")
|
|
125
|
+
shared = _first_hit(dirs, SHARED_PROMPT)
|
|
126
|
+
if shared is None:
|
|
127
|
+
where = ", ".join(str(d) for d in dirs) or "(no prompt directories)"
|
|
128
|
+
raise FileNotFoundError(f"no {SHARED_PROMPT} in: {where}")
|
|
129
|
+
|
|
130
|
+
blocks = [own.read_text().rstrip()]
|
|
131
|
+
# An empty addendum contributes nothing rather than a stray blank block --
|
|
132
|
+
# `touch CONDUIT.append.md` must not change a single byte of the prompt.
|
|
133
|
+
blocks += [t for p in append_paths(dirs, spec.prompt) if (t := p.read_text().strip())]
|
|
134
|
+
blocks.append(shared.read_text().strip())
|
|
135
|
+
return "\n\n".join(blocks) + "\n"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MissingServerEnv(RuntimeError):
|
|
139
|
+
"""A declared server references an environment variable that is not set."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def expand_env(value: str, *, where: str) -> str:
|
|
143
|
+
"""Substitute `${VAR}` from the environment, loudly.
|
|
144
|
+
|
|
145
|
+
The CLI would do this itself -- `--mcp-config` is parsed with
|
|
146
|
+
`expandVars` on -- but two things argue for doing it here. An unset
|
|
147
|
+
variable becomes an empty string down there, so a missing token surfaces
|
|
148
|
+
much later as an unexplained auth failure rather than as the missing token
|
|
149
|
+
it is. And it is an implementation detail of a vendored binary found by
|
|
150
|
+
reading it, not a documented contract; `sdk_compat.py` exists because this
|
|
151
|
+
project does not build on those.
|
|
152
|
+
|
|
153
|
+
Expanding here is idempotent with respect to the CLI: a value with no `${`
|
|
154
|
+
left in it is passed through unchanged.
|
|
155
|
+
"""
|
|
156
|
+
def replace(match: "re.Match[str]") -> str:
|
|
157
|
+
var = match.group(1)
|
|
158
|
+
got = os.environ.get(var)
|
|
159
|
+
if got is None:
|
|
160
|
+
raise MissingServerEnv(
|
|
161
|
+
f"{where} references ${{{var}}} and it is not set. "
|
|
162
|
+
"Export it, or remove the reference -- a credential belongs in "
|
|
163
|
+
"the environment, never in a config file."
|
|
164
|
+
)
|
|
165
|
+
return got
|
|
166
|
+
|
|
167
|
+
return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", replace, value)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _declared_server(name: str, spec: Any) -> dict[str, Any]:
|
|
171
|
+
"""Turn a user's YAML declaration into the dict the SDK expects."""
|
|
172
|
+
payload = spec.model_dump(exclude_none=True)
|
|
173
|
+
where = f"MCP server '{name}'"
|
|
174
|
+
for key in ("command", "url"):
|
|
175
|
+
if key in payload:
|
|
176
|
+
payload[key] = expand_env(str(payload[key]), where=where)
|
|
177
|
+
if "args" in payload:
|
|
178
|
+
payload["args"] = [expand_env(str(a), where=where) for a in payload["args"]]
|
|
179
|
+
for key in ("env", "headers"):
|
|
180
|
+
if key in payload:
|
|
181
|
+
payload[key] = {k: expand_env(str(v), where=where) for k, v in payload[key].items()}
|
|
182
|
+
return payload
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def build_mcp_servers(spec: AgentSpec, ctx: ToolContext) -> dict[str, Any]:
|
|
186
|
+
"""Instantiate exactly the servers this agent declared, and no others.
|
|
187
|
+
|
|
188
|
+
Config-declared servers resolve FIRST, so a project can override a built-in
|
|
189
|
+
-- the bundled Playwright entry is hardcoded down to `--browser chromium`,
|
|
190
|
+
and someone testing Firefox should not have to fork the package to say so.
|
|
191
|
+
"""
|
|
192
|
+
declared = dict(getattr(ctx.config, "mcp_servers", {}) or {})
|
|
193
|
+
servers: dict[str, Any] = {}
|
|
194
|
+
for name in spec.mcp_servers:
|
|
195
|
+
if name in declared:
|
|
196
|
+
servers[name] = _declared_server(name, declared[name])
|
|
197
|
+
elif name in SDK_SERVER_MODULES:
|
|
198
|
+
module = importlib.import_module(SDK_SERVER_MODULES[name])
|
|
199
|
+
servers[name] = module.build(ctx)
|
|
200
|
+
elif name in STDIO_SERVERS:
|
|
201
|
+
servers[name] = dict(STDIO_SERVERS[name])
|
|
202
|
+
else:
|
|
203
|
+
raise UnknownServer(
|
|
204
|
+
f"{spec.name} declares MCP server '{name}', which is neither an "
|
|
205
|
+
f"in-process server ({', '.join(sorted(SDK_SERVER_MODULES))}), a "
|
|
206
|
+
f"known stdio server ({', '.join(sorted(STDIO_SERVERS))}), nor "
|
|
207
|
+
f"declared under `mcp_servers:` in system.yaml "
|
|
208
|
+
f"({', '.join(sorted(declared)) or 'nothing declared'})."
|
|
209
|
+
)
|
|
210
|
+
return servers
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def build_allowed_tools(spec: AgentSpec) -> list[str]:
|
|
214
|
+
"""The allowlist handed to the SDK.
|
|
215
|
+
|
|
216
|
+
Servers are allowed wholesale — the server itself enforces which of its tools
|
|
217
|
+
this agent may use, and it has the context to explain a refusal properly.
|
|
218
|
+
"""
|
|
219
|
+
# ALWAYS_GRANTED is shared with the guardrail so the two cannot disagree.
|
|
220
|
+
# Neither ToolSearch nor Skill is a capability grant — they load schemas and
|
|
221
|
+
# instructions for things the agent already has.
|
|
222
|
+
return [
|
|
223
|
+
*spec.builtin_tools,
|
|
224
|
+
*sorted(ALWAYS_GRANTED - set(spec.builtin_tools)),
|
|
225
|
+
*(mcp_server_wildcard(s) for s in spec.mcp_servers),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class TurnRecord:
|
|
230
|
+
"""What an agent has actually done this turn.
|
|
231
|
+
|
|
232
|
+
The Stop hook needs to know which tools were called; nothing else in the SDK
|
|
233
|
+
tracks that for us, so we count them as they go past.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self) -> None:
|
|
237
|
+
self.called: set[str] = set()
|
|
238
|
+
self.held_envelopes: int = 0
|
|
239
|
+
self.stop_blocks: int = 0
|
|
240
|
+
|
|
241
|
+
def record(self, tool_name: str | None) -> None:
|
|
242
|
+
if tool_name:
|
|
243
|
+
self.called.add(tool_name)
|
|
244
|
+
|
|
245
|
+
def missing(self, required: list[str]) -> list[str]:
|
|
246
|
+
return [t for t in required if t not in self.called]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def build_hooks(
|
|
250
|
+
guard: Guardrail, ctx: ToolContext, record: TurnRecord | None = None
|
|
251
|
+
) -> dict[str, list[HookMatcher]]:
|
|
252
|
+
"""Observability, plus one thing `can_use_tool` structurally cannot do.
|
|
253
|
+
|
|
254
|
+
Permissions are enforced twice, on purpose. `can_use_tool` is primary, but
|
|
255
|
+
the SDK can shadow it (see `CanUseToolShadowedWarning`), so `Guardrail.
|
|
256
|
+
pre_tool_use` re-runs the same `check()` as a hook. Both call one decision
|
|
257
|
+
function, so they cannot disagree — the duplication is in the wiring, not
|
|
258
|
+
in the policy.
|
|
259
|
+
|
|
260
|
+
What hooks add on top is enforcement of the *output contract*. `can_use_tool`
|
|
261
|
+
only ever sees calls that happen, so it can never notice the call that did
|
|
262
|
+
not. The Stop hook can, and it fires while the agent still has a turn left to
|
|
263
|
+
fix it — unlike the conductor, which only finds out afterwards.
|
|
264
|
+
"""
|
|
265
|
+
record = record or TurnRecord()
|
|
266
|
+
|
|
267
|
+
async def on_pre_tool_record(
|
|
268
|
+
input_data: Any, tool_use_id: str | None, context: Any
|
|
269
|
+
) -> dict[str, Any]:
|
|
270
|
+
"""Count what was called. Enforcement and logging are guard.pre_tool_use."""
|
|
271
|
+
record.record(_field(input_data, "tool_name"))
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
async def on_post_tool(input_data: Any, tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
275
|
+
tool = _field(input_data, "tool_name")
|
|
276
|
+
response = _field(input_data, "tool_response")
|
|
277
|
+
|
|
278
|
+
if isinstance(response, dict) and response.get("isError"):
|
|
279
|
+
ctx.store.log("tool_error", agent=ctx.agent.name, tool=tool, tool_use_id=tool_use_id)
|
|
280
|
+
return {}
|
|
281
|
+
|
|
282
|
+
# An envelope that was accepted but held tells the agent nothing unless
|
|
283
|
+
# someone says so now. Discovering at the end that none of your findings
|
|
284
|
+
# counted is too late to attach the missing evidence.
|
|
285
|
+
if tool and tool.endswith("__emit_envelope"):
|
|
286
|
+
structured = _structured(response)
|
|
287
|
+
if structured and structured.get("fileable") is False:
|
|
288
|
+
record.held_envelopes += 1
|
|
289
|
+
return {
|
|
290
|
+
"systemMessage": (
|
|
291
|
+
f"That finding was recorded but is held from filing "
|
|
292
|
+
f"({record.held_envelopes} so far this run). It needs an artifact or a "
|
|
293
|
+
"failing test as evidence, and confidence at or above "
|
|
294
|
+
f"{ctx.config.thresholds.min_confidence_to_file}. Attach evidence with "
|
|
295
|
+
"put_artifact and emit it again, or leave it held deliberately."
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
return {}
|
|
299
|
+
|
|
300
|
+
async def on_stop(input_data: Any, tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
301
|
+
# `stop_hook_active` is true when this hook already blocked once. Without
|
|
302
|
+
# honouring it, an agent that genuinely cannot satisfy its contract loops
|
|
303
|
+
# until it burns the budget.
|
|
304
|
+
if _field(input_data, "stop_hook_active"):
|
|
305
|
+
ctx.store.log(
|
|
306
|
+
"contract_unmet",
|
|
307
|
+
agent=ctx.agent.name,
|
|
308
|
+
missing=record.missing(ctx.agent.must_call),
|
|
309
|
+
note="allowed to stop after one block",
|
|
310
|
+
)
|
|
311
|
+
return {}
|
|
312
|
+
|
|
313
|
+
missing = record.missing(ctx.agent.must_call)
|
|
314
|
+
if not missing:
|
|
315
|
+
return {}
|
|
316
|
+
|
|
317
|
+
record.stop_blocks += 1
|
|
318
|
+
ctx.store.log("stop_blocked", agent=ctx.agent.name, missing=missing)
|
|
319
|
+
names = ", ".join(t.rsplit("__", 1)[-1] for t in missing)
|
|
320
|
+
return {
|
|
321
|
+
"decision": "block",
|
|
322
|
+
"reason": (
|
|
323
|
+
f"You have not called: {names}. That is {ctx.agent.name}'s deliverable for "
|
|
324
|
+
"this task, not an optional extra — without it this invocation produced "
|
|
325
|
+
"nothing the rest of the system can use. Either call it now, or if you "
|
|
326
|
+
"genuinely cannot, call it with the outcome you did reach and say why."
|
|
327
|
+
),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
PRE_TOOL_USE: [HookMatcher(matcher=None, hooks=[guard.pre_tool_use, on_pre_tool_record])],
|
|
332
|
+
POST_TOOL_USE: [HookMatcher(matcher=None, hooks=[on_post_tool])],
|
|
333
|
+
STOP: [HookMatcher(matcher=None, hooks=[on_stop])],
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _structured(response: Any) -> dict[str, Any] | None:
|
|
338
|
+
"""The structuredContent block of an MCP tool result, whatever wraps it."""
|
|
339
|
+
if isinstance(response, dict):
|
|
340
|
+
inner = response.get("structuredContent")
|
|
341
|
+
if isinstance(inner, dict):
|
|
342
|
+
return inner
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _field(payload: Any, name: str) -> Any:
|
|
347
|
+
"""Hook inputs arrive as dicts or dataclasses depending on SDK version."""
|
|
348
|
+
if isinstance(payload, dict):
|
|
349
|
+
return payload.get(name)
|
|
350
|
+
return getattr(payload, name, None)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def skill_plugins(ctx: ToolContext) -> list[dict[str, str]]:
|
|
354
|
+
"""The plugin directories to hand the CLI, project first.
|
|
355
|
+
|
|
356
|
+
Absolute paths: `--plugin-dir` takes the value verbatim, so a relative one
|
|
357
|
+
would resolve against the agent's cwd -- the target repository -- and find
|
|
358
|
+
nothing.
|
|
359
|
+
"""
|
|
360
|
+
from qaas.paths import Workspace
|
|
361
|
+
|
|
362
|
+
ws = getattr(ctx, "workspace", None) or Workspace.resolve()
|
|
363
|
+
return [{"type": "local", "path": str(d.resolve())} for d in ws.plugin_dirs]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def qualified_skills(spec: AgentSpec, ctx: ToolContext) -> list[str]:
|
|
367
|
+
"""This agent's skills, namespaced by the plugin that provides each.
|
|
368
|
+
|
|
369
|
+
The qualification is load-bearing. A skill name travels to the CLI down two
|
|
370
|
+
channels that match differently: the SDK turns each into a `Skill(<name>)`
|
|
371
|
+
entry on `--allowedTools`, matched **literally** against whatever the model
|
|
372
|
+
invokes, while the `initialize` request filters system-prompt content with
|
|
373
|
+
`name === entry || name.endsWith(":" + entry)`. Plugin skills register as
|
|
374
|
+
`qaas:severity-rubric`, so a bare `severity-rubric` satisfies the second
|
|
375
|
+
channel and not the first -- the skill loads, and its allow rule never
|
|
376
|
+
matches. Passing the qualified name makes both agree.
|
|
377
|
+
|
|
378
|
+
A skill no plugin provides is dropped rather than passed through. The Stop
|
|
379
|
+
hook and `qaas validate` both report the real problem; inventing a name that
|
|
380
|
+
can never resolve just moves the failure somewhere quieter.
|
|
381
|
+
"""
|
|
382
|
+
from qaas.paths import Workspace
|
|
383
|
+
|
|
384
|
+
ws = getattr(ctx, "workspace", None) or Workspace.resolve()
|
|
385
|
+
return [q for q in (ws.qualify(name) for name in spec.skills) if q]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def build_options(
|
|
389
|
+
spec: AgentSpec,
|
|
390
|
+
ctx: ToolContext,
|
|
391
|
+
*,
|
|
392
|
+
extra_env: dict[str, str] | None = None,
|
|
393
|
+
) -> ClaudeAgentOptions:
|
|
394
|
+
"""Everything one agent needs, assembled from its spec."""
|
|
395
|
+
guard = Guardrail(ctx)
|
|
396
|
+
|
|
397
|
+
env = {
|
|
398
|
+
# Opus delegates readily. An unbounded subagent tree is the fastest route
|
|
399
|
+
# to a surprise bill, so cap depth and width regardless of what it decides.
|
|
400
|
+
"CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH": "1",
|
|
401
|
+
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": "3",
|
|
402
|
+
}
|
|
403
|
+
env.update(extra_env or {})
|
|
404
|
+
|
|
405
|
+
return ClaudeAgentOptions(
|
|
406
|
+
# Through the workspace, not `PROMPTS_DIR`: a user's `.qaas/prompts/`
|
|
407
|
+
# override has to reach the agent that actually runs, not just the one
|
|
408
|
+
# `qaas prompts list` describes.
|
|
409
|
+
system_prompt=build_system_prompt(spec, resolve_prompt_dirs(ctx)),
|
|
410
|
+
model=spec.model,
|
|
411
|
+
effort=spec.effort,
|
|
412
|
+
max_turns=spec.max_turns,
|
|
413
|
+
max_budget_usd=spec.max_budget_usd,
|
|
414
|
+
mcp_servers=build_mcp_servers(spec, ctx),
|
|
415
|
+
allowed_tools=build_allowed_tools(spec),
|
|
416
|
+
can_use_tool=guard.can_use_tool,
|
|
417
|
+
hooks=build_hooks(guard, ctx),
|
|
418
|
+
# Skills arrive as a plugin, not through filesystem settings, and the
|
|
419
|
+
# names are qualified because the SDK matches them down two channels
|
|
420
|
+
# with different rules -- see `skill_plugins` and `qualified_skills`.
|
|
421
|
+
plugins=skill_plugins(ctx),
|
|
422
|
+
skills=qualified_skills(spec, ctx),
|
|
423
|
+
cwd=str(ctx.target_root),
|
|
424
|
+
env=env,
|
|
425
|
+
# Load NOTHING from the filesystem. This was `["project"]`, defended on
|
|
426
|
+
# reproducibility grounds -- project settings live in the repo, so they
|
|
427
|
+
# travel with it. That reasoning held only while `cwd` was *our* repo.
|
|
428
|
+
#
|
|
429
|
+
# `cwd` is the target now, and with `qaas run --repo <url>` it can be a
|
|
430
|
+
# repository cloned seconds earlier from a URL someone pasted. "project"
|
|
431
|
+
# means: load that repository's `.claude/settings.json`, its hooks, its
|
|
432
|
+
# permission rules and its MCP servers, into a process holding Anthropic
|
|
433
|
+
# credentials, JIRA_API_TOKEN and GitHub auth. A QA tool that executes
|
|
434
|
+
# the configuration of the code it is inspecting is a supply-chain hole.
|
|
435
|
+
#
|
|
436
|
+
# It must be an explicit `[]`, not None: `_apply_skills_defaults` in the
|
|
437
|
+
# SDK substitutes ["user", "project"] whenever setting_sources is None
|
|
438
|
+
# and skills is a list.
|
|
439
|
+
setting_sources=[],
|
|
440
|
+
permission_mode="default",
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def describe(
|
|
445
|
+
spec: AgentSpec, prompt_dirs: Sequence[Path] | None = None
|
|
446
|
+
) -> dict[str, Any]:
|
|
447
|
+
"""A dry-run view of what this agent would be given. No API call.
|
|
448
|
+
|
|
449
|
+
`prompt_dirs` so the dry run counts the prompt the real run would send. A
|
|
450
|
+
dry run that silently reports the packaged prompt while the run sends an
|
|
451
|
+
overridden one is worse than no dry run.
|
|
452
|
+
"""
|
|
453
|
+
return {
|
|
454
|
+
"agent": spec.name,
|
|
455
|
+
"model": spec.model,
|
|
456
|
+
"effort": spec.effort,
|
|
457
|
+
"max_turns": spec.max_turns,
|
|
458
|
+
"max_budget_usd": spec.max_budget_usd,
|
|
459
|
+
"mcp_servers": list(spec.mcp_servers),
|
|
460
|
+
"allowed_tools": build_allowed_tools(spec),
|
|
461
|
+
"prompt_chars": len(build_system_prompt(spec, prompt_dirs)),
|
|
462
|
+
"skills": list(spec.skills),
|
|
463
|
+
"must_call": list(spec.must_call),
|
|
464
|
+
"policy": spec.policy.model_dump(),
|
|
465
|
+
}
|
qaas/runner.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Running one agent: build its options, stream its turn, record what it cost.
|
|
2
|
+
|
|
3
|
+
Every invocation is its own `query()`. What comes back that matters is not the
|
|
4
|
+
agent's prose — that is a summary for the log — but what it wrote through its
|
|
5
|
+
tools, plus the cost and turn count the ledger needs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, AsyncIterator, Callable
|
|
13
|
+
|
|
14
|
+
from claude_agent_sdk import (
|
|
15
|
+
AssistantMessage,
|
|
16
|
+
ClaudeAgentOptions,
|
|
17
|
+
ResultMessage,
|
|
18
|
+
SystemMessage,
|
|
19
|
+
TextBlock,
|
|
20
|
+
ToolUseBlock,
|
|
21
|
+
query,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from qaas.config import AgentSpec
|
|
25
|
+
from qaas.mcp.context import ToolContext
|
|
26
|
+
from qaas.registry import build_options
|
|
27
|
+
from qaas.store import AgentResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RunOutcome:
|
|
32
|
+
"""What one agent invocation produced, beyond its side effects."""
|
|
33
|
+
|
|
34
|
+
result: AgentResult
|
|
35
|
+
final_text: str = ""
|
|
36
|
+
tool_calls: int = 0
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def ok(self) -> bool:
|
|
40
|
+
return self.result.subtype == "success" and self.result.error is None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check_skills_loaded(spec: AgentSpec, ctx: ToolContext, message: Any, emit) -> None:
|
|
44
|
+
"""Say something when the skills an agent declared did not load.
|
|
45
|
+
|
|
46
|
+
This exists because the failure has no symptom. Skills used to be found
|
|
47
|
+
through `setting_sources=["project"]`, resolved against the agent's cwd, so
|
|
48
|
+
a user whose repository had no `.claude/skills/` got none of them -- no
|
|
49
|
+
error, no warning, findings still produced, every procedure missing. It was
|
|
50
|
+
invisible for the life of the project and only surfaced when someone tried
|
|
51
|
+
to install the package.
|
|
52
|
+
|
|
53
|
+
The CLI's init message lists what it loaded. Comparing it against what was
|
|
54
|
+
asked for costs nothing and makes the next regression loud. A mismatch is
|
|
55
|
+
recorded and reported rather than raised: an agent with three of its four
|
|
56
|
+
skills is degraded, not broken, and killing the run would lose the work.
|
|
57
|
+
"""
|
|
58
|
+
declared = list(spec.skills)
|
|
59
|
+
if not declared:
|
|
60
|
+
return
|
|
61
|
+
data = getattr(message, "data", None) or {}
|
|
62
|
+
loaded = {str(n) for n in (data.get("slash_commands") or [])}
|
|
63
|
+
if not loaded:
|
|
64
|
+
return # nothing reported; do not cry wolf about a shape we do not know
|
|
65
|
+
missing = [
|
|
66
|
+
name for name in declared
|
|
67
|
+
if not any(c == name or c.endswith(f":{name}") for c in loaded)
|
|
68
|
+
]
|
|
69
|
+
if not missing:
|
|
70
|
+
return
|
|
71
|
+
ctx.store.log(
|
|
72
|
+
"skills_missing", agent=spec.name, declared=declared, missing=missing,
|
|
73
|
+
cwd=str(data.get("cwd") or ""),
|
|
74
|
+
)
|
|
75
|
+
emit("skills_missing", agent=spec.name, missing=missing)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: How much of the task goes inline in the ledger line. Enough to tell two
|
|
79
|
+
#: FORGE invocations apart at a glance; the artifact holds the rest.
|
|
80
|
+
TASK_PREVIEW_CHARS = 300
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _record_task(ctx: ToolContext, agent: str, task: str) -> dict[str, Any]:
|
|
84
|
+
"""Persist the instruction an agent was actually given, and reference it.
|
|
85
|
+
|
|
86
|
+
`agent_started` recorded `task_chars=len(task)` -- the *length* of the
|
|
87
|
+
prompt. So the one thing needed to explain why an agent did what it did, or
|
|
88
|
+
to replay it, was the one thing the ledger threw away; FORGE runs once per
|
|
89
|
+
finding and its five lines were distinguishable only by character count.
|
|
90
|
+
|
|
91
|
+
The task goes to the artifact store rather than inline because a task is
|
|
92
|
+
kilobytes and `qaas trace` has to stay readable. A preview stays on the line
|
|
93
|
+
so the common case needs no second lookup.
|
|
94
|
+
|
|
95
|
+
Never raises: an unwritable artifact store must not stop the agent from
|
|
96
|
+
running. Provenance degrades to the preview.
|
|
97
|
+
"""
|
|
98
|
+
detail: dict[str, Any] = {"task_preview": task[:TASK_PREVIEW_CHARS]}
|
|
99
|
+
try:
|
|
100
|
+
# Numbered off what is already on disk, not off a ToolContext counter:
|
|
101
|
+
# the conductor builds a fresh context per dispatch, so an in-memory
|
|
102
|
+
# counter would restart at 1 and each FORGE invocation would overwrite
|
|
103
|
+
# the previous one's task. This is the bug `put_result` already had.
|
|
104
|
+
existing = len(list((ctx.store.dir / "artifacts").glob(f"task-{agent}-*.md")))
|
|
105
|
+
detail["task_uri"] = ctx.store.put_artifact(f"task-{agent}-{existing + 1:02d}.md", task)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
return detail
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def run_agent(
|
|
112
|
+
spec: AgentSpec,
|
|
113
|
+
ctx: ToolContext,
|
|
114
|
+
task: str,
|
|
115
|
+
*,
|
|
116
|
+
options: ClaudeAgentOptions | None = None,
|
|
117
|
+
max_budget_usd: float | None = None,
|
|
118
|
+
on_event: Callable[[str, dict[str, Any]], None] | None = None,
|
|
119
|
+
) -> RunOutcome:
|
|
120
|
+
"""Invoke one agent and record the outcome.
|
|
121
|
+
|
|
122
|
+
Failures are captured, not raised. One agent falling over should cost the run
|
|
123
|
+
that agent's findings, not the whole run — the conductor decides whether to
|
|
124
|
+
retry, skip, or escalate.
|
|
125
|
+
"""
|
|
126
|
+
options = options or build_options(spec, ctx)
|
|
127
|
+
if max_budget_usd is not None:
|
|
128
|
+
options.max_budget_usd = max_budget_usd
|
|
129
|
+
started = time.monotonic()
|
|
130
|
+
ctx.store.log(
|
|
131
|
+
"agent_started", agent=spec.name, model=spec.model, task_chars=len(task),
|
|
132
|
+
**_record_task(ctx, spec.name, task),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
before = {e.id for e in ctx.store.envelopes()}
|
|
136
|
+
final_text = ""
|
|
137
|
+
tool_calls = 0
|
|
138
|
+
subtype = "success"
|
|
139
|
+
error: str | None = None
|
|
140
|
+
cost = 0.0
|
|
141
|
+
turns = 0
|
|
142
|
+
|
|
143
|
+
def emit(kind: str, **detail: Any) -> None:
|
|
144
|
+
if on_event:
|
|
145
|
+
on_event(kind, detail)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
async for message in query(prompt=task, options=options):
|
|
149
|
+
if isinstance(message, SystemMessage) and message.subtype == "init":
|
|
150
|
+
_check_skills_loaded(spec, ctx, message, emit)
|
|
151
|
+
elif isinstance(message, AssistantMessage):
|
|
152
|
+
for block in message.content:
|
|
153
|
+
if isinstance(block, TextBlock):
|
|
154
|
+
final_text = block.text
|
|
155
|
+
elif isinstance(block, ToolUseBlock):
|
|
156
|
+
tool_calls += 1
|
|
157
|
+
emit("tool", agent=spec.name, tool=block.name)
|
|
158
|
+
elif isinstance(message, ResultMessage):
|
|
159
|
+
subtype = message.subtype or "success"
|
|
160
|
+
cost = message.total_cost_usd or 0.0
|
|
161
|
+
turns = message.num_turns or 0
|
|
162
|
+
if message.is_error:
|
|
163
|
+
error = _error_text(message)
|
|
164
|
+
if isinstance(message.result, str):
|
|
165
|
+
final_text = message.result
|
|
166
|
+
except Exception as exc: # noqa: BLE001 — the conductor decides what a failure means
|
|
167
|
+
subtype = "failure"
|
|
168
|
+
error = f"{type(exc).__name__}: {exc}"
|
|
169
|
+
ctx.store.log("agent_error", agent=spec.name, error=error)
|
|
170
|
+
|
|
171
|
+
produced = [e.id for e in ctx.store.envelopes() if e.id not in before]
|
|
172
|
+
result = AgentResult(
|
|
173
|
+
agent=spec.name,
|
|
174
|
+
subtype=subtype,
|
|
175
|
+
cost_usd=cost,
|
|
176
|
+
num_turns=turns,
|
|
177
|
+
duration_s=round(time.monotonic() - started, 2),
|
|
178
|
+
envelope_ids=produced,
|
|
179
|
+
error=error,
|
|
180
|
+
)
|
|
181
|
+
ctx.store.put_result(result)
|
|
182
|
+
emit("finished", agent=spec.name, cost=cost, envelopes=len(produced), subtype=subtype)
|
|
183
|
+
return RunOutcome(result=result, final_text=final_text.strip(), tool_calls=tool_calls)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _error_text(message: ResultMessage) -> str:
|
|
187
|
+
"""A usable error string out of whichever field this SDK version populated."""
|
|
188
|
+
for attr in ("errors", "terminal_reason", "stop_reason", "api_error_status"):
|
|
189
|
+
value = getattr(message, attr, None)
|
|
190
|
+
if value:
|
|
191
|
+
return f"{attr}={value}"
|
|
192
|
+
return f"result subtype={message.subtype}"
|