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.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. 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}"