shadow-hdk 0.27.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 (158) hide show
  1. shadow_hdk/__init__.py +24 -0
  2. shadow_hdk/adapters/__init__.py +3 -0
  3. shadow_hdk/adapters/acp/__init__.py +27 -0
  4. shadow_hdk/adapters/acp/agent.py +306 -0
  5. shadow_hdk/adapters/acp/client.py +482 -0
  6. shadow_hdk/adapters/acp/kinds.py +102 -0
  7. shadow_hdk/adapters/acp/transport.py +87 -0
  8. shadow_hdk/adapters/agent/__init__.py +68 -0
  9. shadow_hdk/adapters/agent/catalogue.py +63 -0
  10. shadow_hdk/adapters/agent/component.py +707 -0
  11. shadow_hdk/adapters/agent/library/critic-pair.toml +18 -0
  12. shadow_hdk/adapters/agent/library/keeps-helpers.toml +18 -0
  13. shadow_hdk/adapters/agent/library/orchestrator-workers.toml +17 -0
  14. shadow_hdk/adapters/agent/library/plan-and-execute.toml +19 -0
  15. shadow_hdk/adapters/agent/library/reflect-until.toml +18 -0
  16. shadow_hdk/adapters/agent/library/single.toml +11 -0
  17. shadow_hdk/adapters/agent/loader.py +109 -0
  18. shadow_hdk/adapters/agent/meta.py +161 -0
  19. shadow_hdk/adapters/agent/pattern.py +67 -0
  20. shadow_hdk/adapters/agent/patterns.py +18 -0
  21. shadow_hdk/adapters/agent/registry.py +354 -0
  22. shadow_hdk/adapters/agent/skills.py +95 -0
  23. shadow_hdk/adapters/agent/skills_library/ask-when-ambiguous.toml +9 -0
  24. shadow_hdk/adapters/agent/skills_library/look-before-you-change.toml +9 -0
  25. shadow_hdk/adapters/agent/skills_library/page-a-large-result.toml +9 -0
  26. shadow_hdk/adapters/agent/skills_library/verify-before-done.toml +10 -0
  27. shadow_hdk/adapters/basic/__init__.py +27 -0
  28. shadow_hdk/adapters/basic/callables.py +144 -0
  29. shadow_hdk/adapters/basic/clock.py +12 -0
  30. shadow_hdk/adapters/basic/governance.py +38 -0
  31. shadow_hdk/adapters/basic/mailbox.py +71 -0
  32. shadow_hdk/adapters/basic/observers.py +36 -0
  33. shadow_hdk/adapters/basic/sinks.py +171 -0
  34. shadow_hdk/adapters/basic/store.py +103 -0
  35. shadow_hdk/adapters/basic/threads.py +89 -0
  36. shadow_hdk/adapters/derivation/__init__.py +58 -0
  37. shadow_hdk/adapters/derivation/component.py +193 -0
  38. shadow_hdk/adapters/derivation/ground.py +305 -0
  39. shadow_hdk/adapters/derivation/table.py +75 -0
  40. shadow_hdk/adapters/derivation/values.py +223 -0
  41. shadow_hdk/adapters/devices/__init__.py +24 -0
  42. shadow_hdk/adapters/devices/components.py +162 -0
  43. shadow_hdk/adapters/devices/testing.py +48 -0
  44. shadow_hdk/adapters/environment/__init__.py +16 -0
  45. shadow_hdk/adapters/environment/backends.py +109 -0
  46. shadow_hdk/adapters/environment/local.py +261 -0
  47. shadow_hdk/adapters/environment/opensandbox.py +130 -0
  48. shadow_hdk/adapters/environment/sandbox.py +102 -0
  49. shadow_hdk/adapters/jsonl/__init__.py +27 -0
  50. shadow_hdk/adapters/jsonl/paths.py +60 -0
  51. shadow_hdk/adapters/jsonl/session.py +274 -0
  52. shadow_hdk/adapters/jsonl/transport.py +198 -0
  53. shadow_hdk/adapters/langchain/__init__.py +5 -0
  54. shadow_hdk/adapters/langchain/model.py +232 -0
  55. shadow_hdk/adapters/mcp/__init__.py +5 -0
  56. shadow_hdk/adapters/mcp/components.py +180 -0
  57. shadow_hdk/adapters/mcp/held.py +102 -0
  58. shadow_hdk/adapters/modes/__init__.py +47 -0
  59. shadow_hdk/adapters/modes/acts.py +103 -0
  60. shadow_hdk/adapters/modes/check.py +102 -0
  61. shadow_hdk/adapters/modes/files.py +115 -0
  62. shadow_hdk/adapters/modes/library/example.toml +50 -0
  63. shadow_hdk/adapters/modes/mode.py +155 -0
  64. shadow_hdk/adapters/modes/mode_files.py +129 -0
  65. shadow_hdk/adapters/modes/registry.py +343 -0
  66. shadow_hdk/adapters/modes/rules.py +155 -0
  67. shadow_hdk/adapters/mqtt/__init__.py +5 -0
  68. shadow_hdk/adapters/mqtt/link.py +449 -0
  69. shadow_hdk/adapters/otel/__init__.py +5 -0
  70. shadow_hdk/adapters/otel/observer.py +272 -0
  71. shadow_hdk/adapters/recording/__init__.py +25 -0
  72. shadow_hdk/adapters/recording/offer.py +87 -0
  73. shadow_hdk/adapters/recording/pipes.py +88 -0
  74. shadow_hdk/adapters/recording/server.py +168 -0
  75. shadow_hdk/adapters/recording/socket.py +190 -0
  76. shadow_hdk/kernel/__init__.py +201 -0
  77. shadow_hdk/kernel/activity.py +35 -0
  78. shadow_hdk/kernel/components.py +80 -0
  79. shadow_hdk/kernel/composition.py +91 -0
  80. shadow_hdk/kernel/contracts.py +112 -0
  81. shadow_hdk/kernel/effects.py +149 -0
  82. shadow_hdk/kernel/events.py +256 -0
  83. shadow_hdk/kernel/leases.py +68 -0
  84. shadow_hdk/kernel/observations.py +98 -0
  85. shadow_hdk/kernel/ports.py +361 -0
  86. shadow_hdk/kernel/providers.py +297 -0
  87. shadow_hdk/kernel/rules.py +54 -0
  88. shadow_hdk/kernel/threads.py +60 -0
  89. shadow_hdk/kernel/usage.py +24 -0
  90. shadow_hdk/kernel/workspace.py +89 -0
  91. shadow_hdk/providers/__init__.py +55 -0
  92. shadow_hdk/providers/environment.py +98 -0
  93. shadow_hdk/providers/library/claude-code.toml +210 -0
  94. shadow_hdk/providers/library/codex.toml +104 -0
  95. shadow_hdk/providers/library/opencode.toml +50 -0
  96. shadow_hdk/providers/library.py +176 -0
  97. shadow_hdk/providers/probes.py +163 -0
  98. shadow_hdk/providers/resolution.py +108 -0
  99. shadow_hdk/providers/surface.py +183 -0
  100. shadow_hdk/py.typed +0 -0
  101. shadow_hdk/runtime/__init__.py +44 -0
  102. shadow_hdk/runtime/acting.py +53 -0
  103. shadow_hdk/runtime/approvals.py +122 -0
  104. shadow_hdk/runtime/bindings.py +529 -0
  105. shadow_hdk/runtime/cancel.py +55 -0
  106. shadow_hdk/runtime/children.py +309 -0
  107. shadow_hdk/runtime/clock.py +28 -0
  108. shadow_hdk/runtime/compile.py +379 -0
  109. shadow_hdk/runtime/devices.py +84 -0
  110. shadow_hdk/runtime/emit.py +174 -0
  111. shadow_hdk/runtime/environment.py +427 -0
  112. shadow_hdk/runtime/errors.py +54 -0
  113. shadow_hdk/runtime/inputs.py +31 -0
  114. shadow_hdk/runtime/items.py +261 -0
  115. shadow_hdk/runtime/leash.py +289 -0
  116. shadow_hdk/runtime/lines.py +39 -0
  117. shadow_hdk/runtime/loop.py +347 -0
  118. shadow_hdk/runtime/offer.py +172 -0
  119. shadow_hdk/runtime/person.py +84 -0
  120. shadow_hdk/runtime/processes.py +186 -0
  121. shadow_hdk/runtime/registry.py +74 -0
  122. shadow_hdk/runtime/replay.py +163 -0
  123. shadow_hdk/runtime/session.py +258 -0
  124. shadow_hdk/runtime/state.py +66 -0
  125. shadow_hdk/runtime/step.py +414 -0
  126. shadow_hdk/runtime/store.py +38 -0
  127. shadow_hdk/runtime/switched.py +61 -0
  128. shadow_hdk/runtime/testing/__init__.py +26 -0
  129. shadow_hdk/runtime/testing/doubles.py +178 -0
  130. shadow_hdk/runtime/threads.py +753 -0
  131. shadow_hdk/runtime/trust.py +124 -0
  132. shadow_hdk/serve/__init__.py +36 -0
  133. shadow_hdk/serve/__main__.py +103 -0
  134. shadow_hdk/serve/batteries.py +398 -0
  135. shadow_hdk/serve/batteries_library/ddgs.toml +18 -0
  136. shadow_hdk/serve/batteries_library/wigolo.toml +29 -0
  137. shadow_hdk/serve/config.py +116 -0
  138. shadow_hdk/serve/facade.py +208 -0
  139. shadow_hdk/serve/host.py +508 -0
  140. shadow_hdk/serve/keeping.py +50 -0
  141. shadow_hdk/serve/web.py +31 -0
  142. shadow_hdk/wire/__init__.py +53 -0
  143. shadow_hdk/wire/__main__.py +22 -0
  144. shadow_hdk/wire/channel.py +69 -0
  145. shadow_hdk/wire/context.py +265 -0
  146. shadow_hdk/wire/peer.py +203 -0
  147. shadow_hdk/wire/protocol.py +176 -0
  148. shadow_hdk/wire/remote.py +159 -0
  149. shadow_hdk/wire/schemas.py +81 -0
  150. shadow_hdk/wire/serve.py +308 -0
  151. shadow_hdk/wire/sides.py +510 -0
  152. shadow_hdk/wire/stdio.py +167 -0
  153. shadow_hdk/wire/threads.py +550 -0
  154. shadow_hdk-0.27.0.dist-info/METADATA +708 -0
  155. shadow_hdk-0.27.0.dist-info/RECORD +158 -0
  156. shadow_hdk-0.27.0.dist-info/WHEEL +4 -0
  157. shadow_hdk-0.27.0.dist-info/entry_points.txt +7 -0
  158. shadow_hdk-0.27.0.dist-info/licenses/LICENSE +21 -0
shadow_hdk/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Shadow HDK — a Harness Development Kit.
2
+
3
+ A runtime that runs an agent over an open set of components under a governance policy and hands
4
+ what the agent produces to whoever is listening. One distribution, one import name:
5
+
6
+ shadow_hdk.kernel pure types and the ports — no I/O, no clock, no framework
7
+ shadow_hdk.runtime the governed loop, on LangGraph; threads, the environment base
8
+ shadow_hdk.wire the runtime behind JSON-RPC — stdio, HTTP/SSE — for any language
9
+ shadow_hdk.providers your key, or your subscription — a provider is a file
10
+ shadow_hdk.serve the front door: `Harness`, `harness.toml`, `shadow-hdk serve`
11
+ shadow_hdk.adapters the ports implemented — governance, sinks, components, models, agents
12
+
13
+ The layering is a test, not a convention: the kernel imports nothing, the runtime imports no
14
+ adapter, no adapter imports another, and the wire and the providers import no adapter.
15
+ """
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ try:
20
+ __version__ = version("shadow-hdk")
21
+ except PackageNotFoundError: # pragma: no cover — a source checkout that is not installed
22
+ __version__ = "0"
23
+
24
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ """The ports, implemented — each adapter a package of its own, none importing another (a test
2
+ holds it). Optional SDKs are extras of the one distribution: `shadow-hdk[langchain]`, `[mqtt]`,
3
+ `[otel]`, `[sandbox]`, `[search]`, or `[all]`."""
@@ -0,0 +1,27 @@
1
+ """Another agent, driven as a governed component."""
2
+
3
+ from shadow_hdk.adapters.acp.agent import AcpAgent, mcp_servers_from
4
+ from shadow_hdk.adapters.acp.client import REFUSED, BridgeClient, Spend
5
+ from shadow_hdk.adapters.acp.kinds import (
6
+ KNOWN_KINDS,
7
+ effects_for,
8
+ opening_a_terminal,
9
+ reading_a_file,
10
+ writing_a_file,
11
+ )
12
+ from shadow_hdk.adapters.acp.transport import AcpProvider, open_agent
13
+
14
+ __all__ = [
15
+ "AcpProvider",
16
+ "open_agent",
17
+ "mcp_servers_from",
18
+ "KNOWN_KINDS",
19
+ "REFUSED",
20
+ "AcpAgent",
21
+ "BridgeClient",
22
+ "Spend",
23
+ "effects_for",
24
+ "opening_a_terminal",
25
+ "reading_a_file",
26
+ "writing_a_file",
27
+ ]
@@ -0,0 +1,306 @@
1
+ """Another agent, driven as a component.
2
+
3
+ `09` §8: *driving a locally installed agent over ACP is the harness's business; whether this
4
+ deployment allows it, and whose plan covers it, is the product's.* This is the harness's half.
5
+
6
+ Three things it owns, each because Phase 2 measured that somebody had to:
7
+
8
+ * **Residency.** One process and one handshake for the session, not one per step. A child agent is
9
+ expensive to start, and a five-step composition should not be five cold starts.
10
+ * **A clock.** Nothing in ACP stops an agent looping on a denial, so the bridge bounds every turn by
11
+ `min(its own timeout, what the lease has left)` — an adapter cannot be constructed generously
12
+ enough to outlive the run that invoked it.
13
+ * **A purse.** What the child spent comes back in the shape `_usage_of` reads, so the parent's meter
14
+ charges it without knowing ACP exists.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import shlex
21
+ from collections.abc import Sequence
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import acp
26
+ from acp import schema
27
+ from pydantic import JsonValue
28
+
29
+ from shadow_hdk.adapters.acp.client import BridgeClient
30
+ from shadow_hdk.kernel.components import (
31
+ Component,
32
+ Interface,
33
+ Provenance,
34
+ Registration,
35
+ RegistrationId,
36
+ )
37
+ from shadow_hdk.kernel.effects import EffectProfile
38
+ from shadow_hdk.kernel.observations import Completed, Failed, Observation
39
+ from shadow_hdk.kernel.ports import AgentSession, ComponentPort, ToolSource, Turn, Usage
40
+ from shadow_hdk.runtime import current_run
41
+ from shadow_hdk.runtime.processes import start_held, stop_or_kill
42
+
43
+ BRIEF_SCHEMA: dict[str, JsonValue] = {
44
+ "type": "object",
45
+ "properties": {"brief": {"type": "string"}},
46
+ "required": ["brief"],
47
+ }
48
+
49
+
50
+ def mcp_servers_from(tools: Sequence[ToolSource]) -> list[Any]:
51
+ """Turn the run's tool sources into what ACP's `session/new` takes (D42).
52
+
53
+ **This adapter never learns whose registry it is.** `ToolSource` is a kernel type; whoever opens
54
+ the session builds it. That is what lets the registry's address travel in here without this
55
+ package importing the one that serves it, which rule 4 of the stands-alone invariant would fail
56
+ the build for.
57
+
58
+ A kind nothing here can serve is **refused, naming it**. Dropping it silently would launch the
59
+ child with no tools at all, which looks exactly like a model that chose not to use any — the
60
+ most expensive possible way to fail, because it fails after the turn is paid for.
61
+ """
62
+ made: list[Any] = []
63
+ for source in tools:
64
+ match source.kind:
65
+ case "mcp":
66
+ command, *arguments = shlex.split(source.address)
67
+ made.append(
68
+ schema.McpServerStdio(
69
+ name="shadow-hdk", command=command, args=list(arguments), env=[]
70
+ )
71
+ )
72
+ case "mcp-http":
73
+ made.append(
74
+ schema.HttpMcpServer(
75
+ type="http", name="shadow-hdk", url=source.address, headers=[]
76
+ )
77
+ )
78
+ case other:
79
+ raise ValueError(
80
+ f"nothing here serves a tool source of kind {other!r}; the child would be "
81
+ "launched with no tools and look like a model that chose not to use any"
82
+ )
83
+ return made
84
+
85
+
86
+ class AcpAgent(ComponentPort, AgentSession):
87
+ def __init__(
88
+ self,
89
+ command: str,
90
+ args: Sequence[str] = (),
91
+ *,
92
+ name: str = "acp_agent",
93
+ effects: EffectProfile,
94
+ description: str = "Hand a brief to another agent and get back what it did.",
95
+ workspace: Path | None = None,
96
+ tools: Sequence[ToolSource] = (),
97
+ contained: bool = False,
98
+ network: bool = False,
99
+ currency: str = "USD",
100
+ timeout_s: float = 300.0,
101
+ grace_s: float = 5.0,
102
+ cwd: Path | None = None,
103
+ at: str = "",
104
+ source: str = "acp",
105
+ ) -> None:
106
+ self._command = command
107
+ self._args = list(args)
108
+ self._cwd = cwd
109
+ self._timeout_s = timeout_s
110
+ self._workspace = workspace
111
+ self._tools = tuple(tools)
112
+ self.client = BridgeClient(
113
+ workspace=workspace, contained=contained, network=network, currency=currency
114
+ )
115
+ self._grace_s = grace_s
116
+ self.had_to_be_killed = False
117
+ """Whether the last `stop()` had to go past `SIGTERM`. A child that never goes quietly
118
+ is worth a host's attention, and reading that off a log line is not recording it."""
119
+ self._process: asyncio.subprocess.Process | None = None
120
+ self._agent: Any = None
121
+ self._session: str | None = None
122
+ self.sessions_opened = 0
123
+ self._registration = Registration(
124
+ id=name,
125
+ component=Component(
126
+ interface=Interface(
127
+ name=name,
128
+ description=description,
129
+ input_schema=BRIEF_SCHEMA,
130
+ output_schema={"type": "object"},
131
+ ),
132
+ effects=effects,
133
+ provenance=Provenance(registered_by=source, adapter="acp", at=at),
134
+ labels=frozenset({"agent"}),
135
+ ),
136
+ )
137
+
138
+ # ------------------------------------------------------------------ residency
139
+
140
+ async def start(self) -> None:
141
+ # A session leader the runtime holds (D35, D53): `stop()` ends the whole tree — a coding
142
+ # agent spawns compilers, test runners and language servers — and it dies with this
143
+ # process, whatever ends it (BUG-019).
144
+ self._process = await start_held(
145
+ self._command,
146
+ *self._args,
147
+ cwd=self._cwd,
148
+ stdin=asyncio.subprocess.PIPE,
149
+ stdout=asyncio.subprocess.PIPE,
150
+ stderr=None,
151
+ )
152
+ # Named from the agent's side: `input_stream` is what goes *into* it — the writer.
153
+ self._agent = acp.connect_to_agent(self.client, self._process.stdin, self._process.stdout)
154
+ await asyncio.wait_for(self._agent.initialize(protocol_version=1), self._timeout_s)
155
+ opened = await asyncio.wait_for(
156
+ self._agent.new_session(
157
+ cwd=str(self._workspace or Path.cwd()),
158
+ mcp_servers=mcp_servers_from(self._tools),
159
+ ),
160
+ self._timeout_s,
161
+ )
162
+ self._session = opened.session_id
163
+ self.sessions_opened += 1
164
+
165
+ async def stop(self) -> None:
166
+ """Ask the child to go, and make it go if it will not (BUG-011, D35).
167
+
168
+ It used to `terminate()` and then `wait()` with no deadline, so a child that ignores
169
+ `SIGTERM` — which a busy agent mid-tool-call may well do — wedged the caller forever. The
170
+ grace period is real: an agent asked politely gets to flush its transcript and close what
171
+ it had open. After it, the **group** goes, because by then the child has proven it is not
172
+ cooperating and everything it started is still ours to account for.
173
+ """
174
+ if self._process is not None:
175
+ self.had_to_be_killed = await stop_or_kill(self._process, grace_s=self._grace_s)
176
+ self._process, self._agent, self._session = None, None, None
177
+
178
+ async def __aenter__(self) -> AcpAgent:
179
+ await self.start()
180
+ return self
181
+
182
+ async def __aexit__(self, *exc: object) -> None:
183
+ await self.stop()
184
+
185
+ @property
186
+ def process_is_running(self) -> bool:
187
+ """Whether the child is still alive. A timed-out child that is merely *ignored* is a leaked
188
+ process holding a subscription seat, so this is asserted rather than assumed."""
189
+ return self._process is not None and self._process.returncode is None
190
+
191
+ # ------------------------------------------------------------------ the agent seam (D39)
192
+
193
+ async def turn(self, prompt: str) -> Turn:
194
+ """One turn of the provider's own loop.
195
+
196
+ **No tool calls come back.** They left through the injected registry and landed on the run's
197
+ graph, judged and charged and recorded (D42). What is here is what only the provider knows:
198
+ what it said, what it spent, and why it stopped.
199
+ """
200
+ if self._agent is None or self._session is None:
201
+ await self.start()
202
+ assert self._agent is not None and self._session is not None
203
+
204
+ said_before = len(self.client.said)
205
+ with self.client.governed_by(current_run()):
206
+ answered = await asyncio.wait_for(
207
+ self._agent.prompt(
208
+ session_id=self._session,
209
+ prompt=[schema.TextContentBlock(type="text", text=prompt)],
210
+ ),
211
+ (await self._clock()),
212
+ )
213
+ self.client.spend.add_tokens(getattr(answered, "usage", None))
214
+ charge = self.client.spend.take()
215
+ return Turn(
216
+ text="".join(self.client.said[said_before:]),
217
+ reasoning=self.client.take_thought(),
218
+ usage=Usage(
219
+ input_tokens=charge.input_tokens or None,
220
+ output_tokens=charge.output_tokens or None,
221
+ cost_cents=charge.cents or None,
222
+ ),
223
+ stop_reason=str(getattr(answered, "stop_reason", "") or ""),
224
+ )
225
+
226
+ async def close(self) -> None:
227
+ """Ending the session ends everything it opened — terminals included (D35)."""
228
+ await self.client.end_every_terminal()
229
+ await self.stop()
230
+
231
+ # ------------------------------------------------------------------ the port
232
+
233
+ async def registrations(self) -> Sequence[Registration]:
234
+ return [self._registration]
235
+
236
+ async def invoke(self, registration: RegistrationId, inputs: JsonValue) -> Observation:
237
+ if registration != self._registration.id:
238
+ return Failed(f"no component registered as {registration!r}")
239
+ if self._agent is None or self._session is None:
240
+ return Failed(f"{self._registration.id!r} is not started")
241
+ brief = inputs.get("brief") if isinstance(inputs, dict) else None
242
+ if not isinstance(brief, str):
243
+ return Failed("a child agent needs a brief: a string saying what to work on")
244
+
245
+ said_before = len(self.client.said)
246
+ try:
247
+ # The client's callbacks run on the SDK's own reader task, whose context was captured
248
+ # when the session opened — so it is told which run governs it rather than looking.
249
+ with self.client.governed_by(current_run()):
250
+ reply = await asyncio.wait_for(
251
+ self._agent.prompt(
252
+ session_id=self._session,
253
+ prompt=[schema.TextContentBlock(type="text", text=brief)],
254
+ ),
255
+ (await self._clock()),
256
+ )
257
+ except TimeoutError:
258
+ # A child that will not stop is stopped. Killing it is the point: leaving a wedged
259
+ # process behind would make the next step inherit somebody else's problem.
260
+ await self.stop()
261
+ return Failed(f"the child agent did not finish within {(await self._clock()):g}s")
262
+ except Exception as broken: # noqa: BLE001 — a child is untrusted like any component (D7)
263
+ return Failed(f"{type(broken).__name__}: {broken}")
264
+
265
+ return Completed(
266
+ {
267
+ "text": "".join(self.client.said[said_before:]),
268
+ "stop_reason": reply.stop_reason,
269
+ "usage": self._usage(reply.usage),
270
+ "refusals": list(self.client.refusals),
271
+ }
272
+ )
273
+
274
+ async def _clock(self) -> float:
275
+ """`min(what this adapter was told, what the lease has left)`.
276
+
277
+ An adapter constructed with a generous timeout cannot outlive the run that invoked it.
278
+ """
279
+ context = current_run()
280
+ if context is None:
281
+ return self._timeout_s
282
+ return min(self._timeout_s, float((await context.remaining_now()).ceiling.max_wall_seconds))
283
+
284
+ def _usage(self, reported: Any) -> dict[str, JsonValue]:
285
+ """In the shape `_usage_of` reads, so the parent's meter charges without knowing ACP.
286
+
287
+ **What this turn spent, never what the session holds** (BUG-011). The purse is cumulative
288
+ because a session is, and `step.py` adds what it is handed once per step — so reporting the
289
+ running total charged the first turn again on the second and twice more on the third.
290
+ """
291
+ self.client.spend.add_tokens(reported)
292
+ charge = self.client.spend.take()
293
+ usage: dict[str, JsonValue] = {
294
+ "input_tokens": charge.input_tokens,
295
+ "output_tokens": charge.output_tokens,
296
+ "cost_cents": charge.cents,
297
+ }
298
+ if charge.foreign:
299
+ # Not converted, and not hidden: a host with a rate can do the sum itself.
300
+ usage["uncounted_currencies"] = {
301
+ currency: str(amount) for currency, amount in charge.foreign.items()
302
+ }
303
+ return usage
304
+
305
+
306
+ __all__ = ["AcpAgent"]