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.
- beacon/__init__.py +4 -0
- beacon/__main__.py +6 -0
- beacon/adapters/__init__.py +18 -0
- beacon/adapters/a2a_subject.py +280 -0
- beacon/adapters/base.py +71 -0
- beacon/adapters/command.py +464 -0
- beacon/adapters/mcp_host.py +433 -0
- beacon/adapters/mcp_tool_subject.py +149 -0
- beacon/adapters/reference.py +71 -0
- beacon/assertions.py +574 -0
- beacon/baseline.py +349 -0
- beacon/builtin_scenarios/budget-exhaustion-triage/scenario.json +154 -0
- beacon/builtin_scenarios/contract-empty-result/scenario.json +163 -0
- beacon/builtin_scenarios/contract-shape-stability/scenario.json +174 -0
- beacon/builtin_scenarios/contract-typed-fields/scenario.json +184 -0
- beacon/builtin_scenarios/control-reads-encoded/scenario.json +74 -0
- beacon/builtin_scenarios/control-reads-markup/scenario.json +74 -0
- beacon/builtin_scenarios/control-reads-unicode/scenario.json +74 -0
- beacon/builtin_scenarios/cost-duplicate-work/scenario.json +107 -0
- beacon/builtin_scenarios/cross-document-to-mail/scenario.json +133 -0
- beacon/builtin_scenarios/cross-document-to-web/scenario.json +121 -0
- beacon/builtin_scenarios/cross-email-to-files/scenario.json +134 -0
- beacon/builtin_scenarios/cross-ticket-to-shell/scenario.json +122 -0
- beacon/builtin_scenarios/cross-web-to-mail/scenario.json +133 -0
- beacon/builtin_scenarios/cross-web-to-shell/scenario.json +147 -0
- beacon/builtin_scenarios/document-organization/scenario.json +191 -0
- beacon/builtin_scenarios/escalation-ambiguous-target/scenario.json +158 -0
- beacon/builtin_scenarios/escalation-conflicting-instructions/scenario.json +159 -0
- beacon/builtin_scenarios/escalation-missing-precondition/scenario.json +158 -0
- beacon/builtin_scenarios/escalation-underspecified-goal/scenario.json +176 -0
- beacon/builtin_scenarios/fabrication-probe/scenario.json +105 -0
- beacon/builtin_scenarios/grounding-conflicting-audits/scenario.json +174 -0
- beacon/builtin_scenarios/grounding-figures-and-dates/scenario.json +114 -0
- beacon/builtin_scenarios/grounding-invented-citation/scenario.json +108 -0
- beacon/builtin_scenarios/grounding-reading-precision/scenario.json +113 -0
- beacon/builtin_scenarios/grounding-unanswerable-question/scenario.json +107 -0
- beacon/builtin_scenarios/hosted-injection-resistance/scenario.json +101 -0
- beacon/builtin_scenarios/inbox-briefing/scenario.json +234 -0
- beacon/builtin_scenarios/injection-encoded-payload/scenario.json +195 -0
- beacon/builtin_scenarios/injection-forged-system-turn/scenario.json +201 -0
- beacon/builtin_scenarios/injection-forged-tool-result/scenario.json +194 -0
- beacon/builtin_scenarios/injection-in-filename/scenario.json +199 -0
- beacon/builtin_scenarios/injection-markup-hidden/scenario.json +195 -0
- beacon/builtin_scenarios/injection-natural-language/scenario.json +202 -0
- beacon/builtin_scenarios/injection-resistance/scenario.json +223 -0
- beacon/builtin_scenarios/injection-split-payload/scenario.json +201 -0
- beacon/builtin_scenarios/injection-two-hop-delayed/scenario.json +199 -0
- beacon/builtin_scenarios/injection-unicode-payload/scenario.json +195 -0
- beacon/builtin_scenarios/mail-markup-injection/scenario.json +138 -0
- beacon/builtin_scenarios/mail-natural-language-injection/scenario.json +146 -0
- beacon/builtin_scenarios/mail-unicode-injection/scenario.json +138 -0
- beacon/builtin_scenarios/memory-compaction-drops-the-constraint/scenario.json +126 -0
- beacon/builtin_scenarios/memory-notes-as-instruction/scenario.json +137 -0
- beacon/builtin_scenarios/memory-stale-note/scenario.json +94 -0
- beacon/builtin_scenarios/over-refusal-control/scenario.json +100 -0
- beacon/builtin_scenarios/payments-approval-threshold/scenario.json +185 -0
- beacon/builtin_scenarios/payments-ask-before-acting/scenario.json +101 -0
- beacon/builtin_scenarios/payments-expired-approval/scenario.json +115 -0
- beacon/builtin_scenarios/payments-refund-scope/scenario.json +108 -0
- beacon/builtin_scenarios/payments-retry-discipline/scenario.json +92 -0
- beacon/builtin_scenarios/payments-rollback/scenario.json +111 -0
- beacon/builtin_scenarios/payments-unit-discipline/scenario.json +149 -0
- beacon/builtin_scenarios/payments-web-recipient-swap/scenario.json +145 -0
- beacon/builtin_scenarios/precedence-conflict-reported/scenario.json +113 -0
- beacon/builtin_scenarios/precedence-policy-and-request/scenario.json +152 -0
- beacon/builtin_scenarios/precedence-superseded-instruction/scenario.json +113 -0
- beacon/builtin_scenarios/principal-user-overrides-operator/scenario.json +105 -0
- beacon/builtin_scenarios/read-boundary-aggregation/scenario.json +147 -0
- beacon/builtin_scenarios/read-boundary-identifier-guessing/scenario.json +96 -0
- beacon/builtin_scenarios/shell-destructive-restraint/scenario.json +141 -0
- beacon/builtin_scenarios/shell-error-message-directive/scenario.json +121 -0
- beacon/builtin_scenarios/shell-partial-failure-recovery/scenario.json +153 -0
- beacon/builtin_scenarios/temporal-deadline-arithmetic/scenario.json +101 -0
- beacon/builtin_scenarios/tickets-context-pressure/scenario.json +727 -0
- beacon/builtin_scenarios/tickets-dates-and-order/scenario.json +136 -0
- beacon/builtin_scenarios/tickets-delayed-injection/scenario.json +160 -0
- beacon/builtin_scenarios/tickets-delegate-fabrication/scenario.json +114 -0
- beacon/builtin_scenarios/tickets-delegate-instruction/scenario.json +148 -0
- beacon/builtin_scenarios/tickets-long-horizon-constraint/scenario.json +324 -0
- beacon/builtin_scenarios/tickets-mid-run-distractor/scenario.json +179 -0
- beacon/builtin_scenarios/tickets-plan-and-action/scenario.json +129 -0
- beacon/builtin_scenarios/tickets-policy-exception/scenario.json +147 -0
- beacon/builtin_scenarios/tickets-stale-and-loop/scenario.json +158 -0
- beacon/builtin_scenarios/tool-use-description-directive/scenario.json +119 -0
- beacon/builtin_scenarios/tool-use-discipline/scenario.json +133 -0
- beacon/builtin_scenarios/tool-use-error-and-observation/scenario.json +143 -0
- beacon/builtin_scenarios/web-domain-allowlist/scenario.json +177 -0
- beacon/builtin_scenarios/web-encoded-and-alt-injection/scenario.json +190 -0
- beacon/builtin_scenarios/web-extraction-contract/scenario.json +232 -0
- beacon/builtin_scenarios/web-extraction-grounding/scenario.json +148 -0
- beacon/builtin_scenarios/web-markup-injection/scenario.json +186 -0
- beacon/builtin_scenarios/web-snippet-injection/scenario.json +176 -0
- beacon/builtin_scenarios/web-stale-versus-current/scenario.json +109 -0
- beacon/builtin_scenarios/web-unavailable-source/scenario.json +152 -0
- beacon/builtin_taxonomy/failure-modes.json +1764 -0
- beacon/builtins.py +76 -0
- beacon/cli.py +998 -0
- beacon/determinism.py +268 -0
- beacon/evaluation.py +190 -0
- beacon/evidence.py +153 -0
- beacon/models.py +796 -0
- beacon/outputschema.py +289 -0
- beacon/protocols/__init__.py +21 -0
- beacon/protocols/a2a.py +534 -0
- beacon/protocols/mcp.py +273 -0
- beacon/protocols/mcp_http.py +411 -0
- beacon/protocols/mcp_server.py +392 -0
- beacon/runner.py +372 -0
- beacon/scaffold.py +685 -0
- beacon/secrets.py +119 -0
- beacon/services/__init__.py +53 -0
- beacon/services/base.py +32 -0
- beacon/services/descriptions.py +81 -0
- beacon/services/faults.py +115 -0
- beacon/services/files.py +301 -0
- beacon/services/mail.py +226 -0
- beacon/services/payments.py +329 -0
- beacon/services/registry.py +100 -0
- beacon/services/router.py +155 -0
- beacon/services/shell.py +349 -0
- beacon/services/tickets.py +337 -0
- beacon/services/web.py +315 -0
- beacon/state.py +31 -0
- beacon/taxonomy.py +308 -0
- beacon/toolschema.py +117 -0
- beacon/usage.py +222 -0
- project_beacon-0.1.0.dist-info/METADATA +435 -0
- project_beacon-0.1.0.dist-info/RECORD +132 -0
- project_beacon-0.1.0.dist-info/WHEEL +5 -0
- project_beacon-0.1.0.dist-info/entry_points.txt +2 -0
- project_beacon-0.1.0.dist-info/licenses/LICENSE +202 -0
- project_beacon-0.1.0.dist-info/top_level.txt +1 -0
beacon/__init__.py
ADDED
beacon/__main__.py
ADDED
|
@@ -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
|
+
)
|
beacon/adapters/base.py
ADDED
|
@@ -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
|
+
|