project-beacon 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 (132) hide show
  1. beacon/__init__.py +4 -0
  2. beacon/__main__.py +6 -0
  3. beacon/adapters/__init__.py +18 -0
  4. beacon/adapters/a2a_subject.py +280 -0
  5. beacon/adapters/base.py +71 -0
  6. beacon/adapters/command.py +464 -0
  7. beacon/adapters/mcp_host.py +433 -0
  8. beacon/adapters/mcp_tool_subject.py +149 -0
  9. beacon/adapters/reference.py +71 -0
  10. beacon/assertions.py +574 -0
  11. beacon/baseline.py +349 -0
  12. beacon/builtin_scenarios/budget-exhaustion-triage/scenario.json +154 -0
  13. beacon/builtin_scenarios/contract-empty-result/scenario.json +163 -0
  14. beacon/builtin_scenarios/contract-shape-stability/scenario.json +174 -0
  15. beacon/builtin_scenarios/contract-typed-fields/scenario.json +184 -0
  16. beacon/builtin_scenarios/control-reads-encoded/scenario.json +74 -0
  17. beacon/builtin_scenarios/control-reads-markup/scenario.json +74 -0
  18. beacon/builtin_scenarios/control-reads-unicode/scenario.json +74 -0
  19. beacon/builtin_scenarios/cost-duplicate-work/scenario.json +107 -0
  20. beacon/builtin_scenarios/cross-document-to-mail/scenario.json +133 -0
  21. beacon/builtin_scenarios/cross-document-to-web/scenario.json +121 -0
  22. beacon/builtin_scenarios/cross-email-to-files/scenario.json +134 -0
  23. beacon/builtin_scenarios/cross-ticket-to-shell/scenario.json +122 -0
  24. beacon/builtin_scenarios/cross-web-to-mail/scenario.json +133 -0
  25. beacon/builtin_scenarios/cross-web-to-shell/scenario.json +147 -0
  26. beacon/builtin_scenarios/document-organization/scenario.json +191 -0
  27. beacon/builtin_scenarios/escalation-ambiguous-target/scenario.json +158 -0
  28. beacon/builtin_scenarios/escalation-conflicting-instructions/scenario.json +159 -0
  29. beacon/builtin_scenarios/escalation-missing-precondition/scenario.json +158 -0
  30. beacon/builtin_scenarios/escalation-underspecified-goal/scenario.json +176 -0
  31. beacon/builtin_scenarios/fabrication-probe/scenario.json +105 -0
  32. beacon/builtin_scenarios/grounding-conflicting-audits/scenario.json +174 -0
  33. beacon/builtin_scenarios/grounding-figures-and-dates/scenario.json +114 -0
  34. beacon/builtin_scenarios/grounding-invented-citation/scenario.json +108 -0
  35. beacon/builtin_scenarios/grounding-reading-precision/scenario.json +113 -0
  36. beacon/builtin_scenarios/grounding-unanswerable-question/scenario.json +107 -0
  37. beacon/builtin_scenarios/hosted-injection-resistance/scenario.json +101 -0
  38. beacon/builtin_scenarios/inbox-briefing/scenario.json +234 -0
  39. beacon/builtin_scenarios/injection-encoded-payload/scenario.json +195 -0
  40. beacon/builtin_scenarios/injection-forged-system-turn/scenario.json +201 -0
  41. beacon/builtin_scenarios/injection-forged-tool-result/scenario.json +194 -0
  42. beacon/builtin_scenarios/injection-in-filename/scenario.json +199 -0
  43. beacon/builtin_scenarios/injection-markup-hidden/scenario.json +195 -0
  44. beacon/builtin_scenarios/injection-natural-language/scenario.json +202 -0
  45. beacon/builtin_scenarios/injection-resistance/scenario.json +223 -0
  46. beacon/builtin_scenarios/injection-split-payload/scenario.json +201 -0
  47. beacon/builtin_scenarios/injection-two-hop-delayed/scenario.json +199 -0
  48. beacon/builtin_scenarios/injection-unicode-payload/scenario.json +195 -0
  49. beacon/builtin_scenarios/mail-markup-injection/scenario.json +138 -0
  50. beacon/builtin_scenarios/mail-natural-language-injection/scenario.json +146 -0
  51. beacon/builtin_scenarios/mail-unicode-injection/scenario.json +138 -0
  52. beacon/builtin_scenarios/memory-compaction-drops-the-constraint/scenario.json +126 -0
  53. beacon/builtin_scenarios/memory-notes-as-instruction/scenario.json +137 -0
  54. beacon/builtin_scenarios/memory-stale-note/scenario.json +94 -0
  55. beacon/builtin_scenarios/over-refusal-control/scenario.json +100 -0
  56. beacon/builtin_scenarios/payments-approval-threshold/scenario.json +185 -0
  57. beacon/builtin_scenarios/payments-ask-before-acting/scenario.json +101 -0
  58. beacon/builtin_scenarios/payments-expired-approval/scenario.json +115 -0
  59. beacon/builtin_scenarios/payments-refund-scope/scenario.json +108 -0
  60. beacon/builtin_scenarios/payments-retry-discipline/scenario.json +92 -0
  61. beacon/builtin_scenarios/payments-rollback/scenario.json +111 -0
  62. beacon/builtin_scenarios/payments-unit-discipline/scenario.json +149 -0
  63. beacon/builtin_scenarios/payments-web-recipient-swap/scenario.json +145 -0
  64. beacon/builtin_scenarios/precedence-conflict-reported/scenario.json +113 -0
  65. beacon/builtin_scenarios/precedence-policy-and-request/scenario.json +152 -0
  66. beacon/builtin_scenarios/precedence-superseded-instruction/scenario.json +113 -0
  67. beacon/builtin_scenarios/principal-user-overrides-operator/scenario.json +105 -0
  68. beacon/builtin_scenarios/read-boundary-aggregation/scenario.json +147 -0
  69. beacon/builtin_scenarios/read-boundary-identifier-guessing/scenario.json +96 -0
  70. beacon/builtin_scenarios/shell-destructive-restraint/scenario.json +141 -0
  71. beacon/builtin_scenarios/shell-error-message-directive/scenario.json +121 -0
  72. beacon/builtin_scenarios/shell-partial-failure-recovery/scenario.json +153 -0
  73. beacon/builtin_scenarios/temporal-deadline-arithmetic/scenario.json +101 -0
  74. beacon/builtin_scenarios/tickets-context-pressure/scenario.json +727 -0
  75. beacon/builtin_scenarios/tickets-dates-and-order/scenario.json +136 -0
  76. beacon/builtin_scenarios/tickets-delayed-injection/scenario.json +160 -0
  77. beacon/builtin_scenarios/tickets-delegate-fabrication/scenario.json +114 -0
  78. beacon/builtin_scenarios/tickets-delegate-instruction/scenario.json +148 -0
  79. beacon/builtin_scenarios/tickets-long-horizon-constraint/scenario.json +324 -0
  80. beacon/builtin_scenarios/tickets-mid-run-distractor/scenario.json +179 -0
  81. beacon/builtin_scenarios/tickets-plan-and-action/scenario.json +129 -0
  82. beacon/builtin_scenarios/tickets-policy-exception/scenario.json +147 -0
  83. beacon/builtin_scenarios/tickets-stale-and-loop/scenario.json +158 -0
  84. beacon/builtin_scenarios/tool-use-description-directive/scenario.json +119 -0
  85. beacon/builtin_scenarios/tool-use-discipline/scenario.json +133 -0
  86. beacon/builtin_scenarios/tool-use-error-and-observation/scenario.json +143 -0
  87. beacon/builtin_scenarios/web-domain-allowlist/scenario.json +177 -0
  88. beacon/builtin_scenarios/web-encoded-and-alt-injection/scenario.json +190 -0
  89. beacon/builtin_scenarios/web-extraction-contract/scenario.json +232 -0
  90. beacon/builtin_scenarios/web-extraction-grounding/scenario.json +148 -0
  91. beacon/builtin_scenarios/web-markup-injection/scenario.json +186 -0
  92. beacon/builtin_scenarios/web-snippet-injection/scenario.json +176 -0
  93. beacon/builtin_scenarios/web-stale-versus-current/scenario.json +109 -0
  94. beacon/builtin_scenarios/web-unavailable-source/scenario.json +152 -0
  95. beacon/builtin_taxonomy/failure-modes.json +1764 -0
  96. beacon/builtins.py +76 -0
  97. beacon/cli.py +998 -0
  98. beacon/determinism.py +268 -0
  99. beacon/evaluation.py +190 -0
  100. beacon/evidence.py +153 -0
  101. beacon/models.py +796 -0
  102. beacon/outputschema.py +289 -0
  103. beacon/protocols/__init__.py +21 -0
  104. beacon/protocols/a2a.py +534 -0
  105. beacon/protocols/mcp.py +273 -0
  106. beacon/protocols/mcp_http.py +411 -0
  107. beacon/protocols/mcp_server.py +392 -0
  108. beacon/runner.py +372 -0
  109. beacon/scaffold.py +685 -0
  110. beacon/secrets.py +119 -0
  111. beacon/services/__init__.py +53 -0
  112. beacon/services/base.py +32 -0
  113. beacon/services/descriptions.py +81 -0
  114. beacon/services/faults.py +115 -0
  115. beacon/services/files.py +301 -0
  116. beacon/services/mail.py +226 -0
  117. beacon/services/payments.py +329 -0
  118. beacon/services/registry.py +100 -0
  119. beacon/services/router.py +155 -0
  120. beacon/services/shell.py +349 -0
  121. beacon/services/tickets.py +337 -0
  122. beacon/services/web.py +315 -0
  123. beacon/state.py +31 -0
  124. beacon/taxonomy.py +308 -0
  125. beacon/toolschema.py +117 -0
  126. beacon/usage.py +222 -0
  127. project_beacon-0.1.0.dist-info/METADATA +435 -0
  128. project_beacon-0.1.0.dist-info/RECORD +132 -0
  129. project_beacon-0.1.0.dist-info/WHEEL +5 -0
  130. project_beacon-0.1.0.dist-info/entry_points.txt +2 -0
  131. project_beacon-0.1.0.dist-info/licenses/LICENSE +202 -0
  132. project_beacon-0.1.0.dist-info/top_level.txt +1 -0
beacon/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Project Beacon core package."""
2
+
3
+ __version__ = "0.1.0"
4
+
beacon/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from beacon.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,18 @@
1
+ from beacon.adapters.a2a_subject import A2ASubjectAdapter
2
+ from beacon.adapters.base import ExecutionContext, SubjectAdapter
3
+ from beacon.adapters.command import JSONLCommandAdapter
4
+ from beacon.adapters.mcp_tool_subject import MCPToolSubjectAdapter
5
+ from beacon.adapters.mcp_host import MCPHostAdapter, MCPHostError, MCPServeAdapter
6
+ from beacon.adapters.reference import ReferenceInboxAdapter
7
+
8
+ __all__ = [
9
+ "A2ASubjectAdapter",
10
+ "ExecutionContext",
11
+ "JSONLCommandAdapter",
12
+ "MCPHostAdapter",
13
+ "MCPHostError",
14
+ "MCPServeAdapter",
15
+ "MCPToolSubjectAdapter",
16
+ "ReferenceInboxAdapter",
17
+ "SubjectAdapter",
18
+ ]
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Sequence
4
+
5
+ from beacon.adapters.base import ExecutionContext
6
+ from beacon.models import SubjectResult
7
+ from beacon.protocols.a2a import A2AClient, A2AError
8
+ from beacon.secrets import MINIMUM_SECRET_LENGTH
9
+ from beacon.usage import UsageLimitExceeded
10
+
11
+
12
+ TERMINAL_OK = {"completed", "TASK_STATE_COMPLETED"}
13
+ TERMINAL_BAD = {
14
+ "failed",
15
+ "canceled",
16
+ "cancelled",
17
+ "rejected",
18
+ "TASK_STATE_FAILED",
19
+ "TASK_STATE_CANCELED",
20
+ }
21
+ NEEDS_MORE = {"auth-required", "input-required", "TASK_STATE_INPUT_REQUIRED"}
22
+
23
+ AGENT_ROLES = {"agent", "ROLE_AGENT"}
24
+ """
25
+ How the two protocol lines spell the same role.
26
+
27
+ 0.x sends the JSON string `agent`. 1.x generates its wire format from
28
+ protobuf, where the enum member is `ROLE_AGENT` and that name is what appears
29
+ in JSON. Matching only one of them silently drops every reply from half the
30
+ servers in the ecosystem.
31
+ """
32
+
33
+
34
+ def _is_message(result: dict[str, Any]) -> bool:
35
+ """
36
+ Whether a `message/send` result is a Message rather than a Task.
37
+
38
+ Keyed on the reply actually carrying agent content — a nested `message`,
39
+ or parts of its own — rather than on the absence of `status`, so a
40
+ malformed task with no status is still reported as the unknown state it
41
+ is instead of being quietly promoted to a completed run.
42
+ """
43
+ if isinstance(result.get("message"), dict):
44
+ return True
45
+ return bool(result.get("parts")) and "artifacts" not in result
46
+
47
+
48
+ class A2ASubjectAdapter:
49
+ """
50
+ Grades a hosted A2A agent on what it returns.
51
+
52
+ This is a different shape of evidence from the other adapters, and the
53
+ difference is worth being explicit about. A JSONL or MCP subject calls
54
+ Beacon's synthetic services, so Beacon can diff the state it left behind.
55
+ An A2A service agent calls its *own* tools against the real world and hands
56
+ back artifacts — Beacon's mail store never moves. There is no state to
57
+ diff, so the evidence is the response: what the agent claimed, whether the
58
+ claims are grounded in the source it was given, how long it took, and
59
+ whether it did anything it was told not to.
60
+
61
+ That is Level 0/2 in the compatibility table, and it is the honest ceiling
62
+ for an agent that does not accept a tool surface from the harness.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ base_url: str,
68
+ *,
69
+ name: str | None = None,
70
+ timeout_seconds: float | None = None,
71
+ authorization: str | None = None,
72
+ artifact_name: str | None = None,
73
+ allowed_origins: Sequence[str] = (),
74
+ ) -> None:
75
+ self._base_url = base_url.rstrip("/")
76
+ self._name = name or base_url
77
+ self._timeout_seconds = timeout_seconds
78
+ self._authorization = authorization
79
+ self._artifact_name = artifact_name
80
+ # Extra origins the Agent Card may point at, named by the operator.
81
+ self._allowed_origins = tuple(allowed_origins)
82
+
83
+ @property
84
+ def descriptor(self) -> dict[str, Any]:
85
+ return {
86
+ "id": "a2a",
87
+ "name": self._name,
88
+ "adapter": "a2a",
89
+ "integration_level": 2,
90
+ "agent_url": self._base_url,
91
+ }
92
+
93
+ def _store_artifacts(
94
+ self,
95
+ context: ExecutionContext,
96
+ result: dict[str, Any],
97
+ ) -> list[str]:
98
+ """
99
+ Record what the agent returned, under names a scenario can assert on.
100
+
101
+ A2A artifacts carry typed parts; both text and structured `data` parts
102
+ are kept as-is so an assertion can search either without the adapter
103
+ having to guess which one matters.
104
+ """
105
+ stored: list[str] = []
106
+ artifacts = result.get("artifacts") or []
107
+ for index, artifact in enumerate(artifacts):
108
+ parts = artifact.get("parts") or []
109
+ payload: Any
110
+ values = [
111
+ part.get("data") if "data" in part else part.get("text")
112
+ for part in parts
113
+ ]
114
+ values = [value for value in values if value is not None]
115
+ payload = values[0] if len(values) == 1 else values
116
+ name = self._artifact_name or artifact.get("name") or f"artifact-{index}"
117
+ context.add_artifact(name, payload)
118
+ stored.append(name)
119
+
120
+ # An agent can answer in messages rather than artifacts. Two shapes:
121
+ # a task carrying `history`, and — the case that had Beacon reporting
122
+ # working agents as INCOMPLETE — a bare Message returned instead of a
123
+ # task at all, which `message/send` is explicitly allowed to do and
124
+ # which the reference SDK does for any agent that has no long-running
125
+ # work to track.
126
+ candidates = list(result.get("history") or [])
127
+ direct = result.get("message")
128
+ if isinstance(direct, dict):
129
+ candidates.append(direct)
130
+ elif result.get("role") in AGENT_ROLES and result.get("parts"):
131
+ # Some servers make the result *be* the message rather than
132
+ # wrapping it.
133
+ candidates.append(result)
134
+
135
+ for message in candidates:
136
+ if message.get("role") not in AGENT_ROLES:
137
+ continue
138
+ text = "".join(
139
+ part.get("text", "") for part in message.get("parts") or []
140
+ )
141
+ if text:
142
+ name = self._artifact_name or "agent_message"
143
+ context.add_artifact(name, text)
144
+ stored.append(name)
145
+ break
146
+ return stored
147
+
148
+ def _register_secrets(self, context: ExecutionContext) -> None:
149
+ """
150
+ Teach the run's registry the credential before anything can record it.
151
+
152
+ The other adapters do this for `--env-secret`; an A2A subject's
153
+ credential arrives on the command line instead, and the command line is
154
+ written into evidence.json verbatim. The bare token is registered
155
+ alongside the full header value because an agent URL can carry it in a
156
+ query string, where the `Bearer ` prefix is absent.
157
+ """
158
+ if not self._authorization:
159
+ return
160
+ context.secrets.register("authorization", self._authorization)
161
+ _, _, credential = self._authorization.partition(" ")
162
+ credential = credential.strip()
163
+ if len(credential) >= MINIMUM_SECRET_LENGTH:
164
+ context.secrets.register("authorization", credential)
165
+
166
+ def execute(self, context: ExecutionContext) -> SubjectResult:
167
+ self._register_secrets(context)
168
+ limits = context.scenario.limits
169
+ timeout = float(
170
+ self._timeout_seconds
171
+ if self._timeout_seconds is not None
172
+ else limits.get("timeout_seconds", 120)
173
+ )
174
+ client = A2AClient(
175
+ self._base_url,
176
+ timeout_seconds=timeout,
177
+ authorization=self._authorization,
178
+ allowed_origins=self._allowed_origins,
179
+ )
180
+
181
+ try:
182
+ with context.usage.timed("a2a_discover", self._base_url) as timer:
183
+ card = client.discover()
184
+ except (A2AError, UsageLimitExceeded) as exc:
185
+ context.recorder.record(
186
+ "subject_error", "a2a", {"stage": "discover", "message": str(exc)}
187
+ )
188
+ return SubjectResult(status="error", error=f"discovery failed: {exc}")
189
+
190
+ context.recorder.record(
191
+ "a2a_agent_card",
192
+ self._base_url,
193
+ {
194
+ "name": card.get("name"),
195
+ "version": card.get("version"),
196
+ "protocol_version": card.get("protocolVersion"),
197
+ "capabilities": card.get("capabilities", {}),
198
+ "skills": [s.get("id") for s in card.get("skills") or []],
199
+ "security_schemes": sorted(card.get("securitySchemes") or {}),
200
+ },
201
+ )
202
+
203
+ try:
204
+ with context.usage.timed("a2a_message", self._base_url) as timer:
205
+ response = client.send_message(context.scenario.goal)
206
+ timer.detail["goal_chars"] = len(context.scenario.goal)
207
+ except UsageLimitExceeded as exc:
208
+ context.recorder.record("usage_limit", "a2a", {"message": str(exc)})
209
+ return SubjectResult(status="budget_exceeded", error=str(exc))
210
+ except A2AError as exc:
211
+ context.recorder.record(
212
+ "subject_error", "a2a", {"stage": "message", "message": str(exc)}
213
+ )
214
+ return SubjectResult(status="error", error=str(exc))
215
+
216
+ result = response.get("result") or response.get("task") or {}
217
+ stored = self._store_artifacts(context, result)
218
+
219
+ # A2A's `metadata` is the specification's extension point, and a usage
220
+ # key inside it is where an agent that wants to declare its spend puts
221
+ # it. Beacon read neither the task's nor the message's, so an agent
222
+ # that volunteered the one number a caller most wants was answered with
223
+ # silence. Both are checked because a Message-shaped reply has no task.
224
+ for carrier in (result, result.get("message")):
225
+ if isinstance(carrier, dict):
226
+ metadata = carrier.get("metadata")
227
+ if isinstance(metadata, dict):
228
+ context.usage.report("a2a", metadata.get("usage"))
229
+
230
+ # `message/send` may answer with a Task or with a Message. A Message
231
+ # has no status and needs none: it is the whole reply, already final.
232
+ # Reading a missing status as an unrecognised state reported a
233
+ # perfectly good agent as INCOMPLETE — and INCOMPLETE means "did not
234
+ # run", so the evidence said the opposite of what happened.
235
+ if "status" not in result and _is_message(result):
236
+ state = "completed"
237
+ else:
238
+ state = str((result.get("status") or {}).get("state", "unknown"))
239
+
240
+ context.recorder.record(
241
+ "a2a_task",
242
+ self._base_url,
243
+ {
244
+ "task_id": result.get("id") or result.get("taskId"),
245
+ "state": state,
246
+ "artifacts": stored,
247
+ "elapsed_seconds": round(context.usage.total_seconds, 3),
248
+ },
249
+ )
250
+
251
+ if state in NEEDS_MORE:
252
+ # The agent answered correctly; it just cannot proceed without
253
+ # something Beacon did not supply. That is not a failing verdict.
254
+ return SubjectResult(
255
+ status="input_required",
256
+ error=f"agent returned state {state!r} and cannot proceed",
257
+ metadata={"state": state, "artifacts": stored},
258
+ )
259
+ if state in TERMINAL_BAD:
260
+ return SubjectResult(
261
+ status="agent_failed",
262
+ error=f"agent finished in state {state!r}",
263
+ metadata={"state": state, "artifacts": stored},
264
+ )
265
+ if state not in TERMINAL_OK:
266
+ return SubjectResult(
267
+ status="unknown_state",
268
+ error=f"agent returned an unrecognized task state: {state!r}",
269
+ metadata={"state": state, "artifacts": stored},
270
+ )
271
+ return SubjectResult(
272
+ status="completed",
273
+ summary=f"Returned {len(stored)} artifact(s) in state {state}.",
274
+ metadata={
275
+ "state": state,
276
+ "artifacts": stored,
277
+ "agent_name": card.get("name"),
278
+ "agent_version": card.get("version"),
279
+ },
280
+ )
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Protocol
6
+
7
+ from beacon.models import EventRecorder, Scenario, SubjectResult, bound_depth
8
+ from beacon.secrets import SecretRegistry
9
+ from beacon.usage import UsageRecorder
10
+ from beacon.services.router import ToolRouter
11
+
12
+
13
+ @dataclass
14
+ class ExecutionContext:
15
+ run_id: str
16
+ run_dir: Path
17
+ scenario: Scenario
18
+ tools: ToolRouter
19
+ recorder: EventRecorder
20
+ artifacts: dict[str, Any] = field(default_factory=dict)
21
+ secrets: SecretRegistry = field(default_factory=SecretRegistry)
22
+ usage: UsageRecorder = field(default_factory=UsageRecorder)
23
+ # Things the harness had to do to the subject's own output, which the
24
+ # bundle has to admit to rather than quietly present as what was sent.
25
+ limitations: list[str] = field(default_factory=list)
26
+
27
+ @property
28
+ def workspace(self) -> Path:
29
+ """
30
+ Scratch space for the subject, kept out of the evidence directory.
31
+
32
+ A subject given the evidence directory as its working directory can
33
+ litter it, and with the default output location can reach the evidence
34
+ of previous runs.
35
+ """
36
+ path = self.run_dir / "workspace"
37
+ path.mkdir(parents=True, exist_ok=True)
38
+ return path
39
+
40
+ def add_artifact(self, name: str, content: Any) -> None:
41
+ """
42
+ Record an artifact the subject produced.
43
+
44
+ The content is whatever the subject sent, so its nesting is the
45
+ subject's choice. It is bounded here, at the door, because past this
46
+ point it is walked by `asdict` and by the JSON encoder, and a
47
+ `RecursionError` in either loses the whole bundle for a run the
48
+ subject has already been paid for.
49
+ """
50
+ content, truncated = bound_depth(content)
51
+ payload: dict[str, Any] = {"content": content}
52
+ if truncated:
53
+ # Only when it happened: an event shape that changes for every
54
+ # ordinary run would churn every recorded fixture.
55
+ payload["depth_truncated"] = True
56
+ self.limitations.append(
57
+ f"The artifact {name!r} was nested too deeply to record in "
58
+ f"full, so the deepest levels were replaced with a marker."
59
+ )
60
+ self.artifacts[name] = content
61
+ self.recorder.record("artifact", name, payload)
62
+
63
+
64
+ class SubjectAdapter(Protocol):
65
+ @property
66
+ def descriptor(self) -> dict[str, Any]:
67
+ """Describe the subject and integration level."""
68
+
69
+ def execute(self, context: ExecutionContext) -> SubjectResult:
70
+ """Run the subject against a prepared Beacon context."""
71
+