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.
- shadow_hdk/__init__.py +24 -0
- shadow_hdk/adapters/__init__.py +3 -0
- shadow_hdk/adapters/acp/__init__.py +27 -0
- shadow_hdk/adapters/acp/agent.py +306 -0
- shadow_hdk/adapters/acp/client.py +482 -0
- shadow_hdk/adapters/acp/kinds.py +102 -0
- shadow_hdk/adapters/acp/transport.py +87 -0
- shadow_hdk/adapters/agent/__init__.py +68 -0
- shadow_hdk/adapters/agent/catalogue.py +63 -0
- shadow_hdk/adapters/agent/component.py +707 -0
- shadow_hdk/adapters/agent/library/critic-pair.toml +18 -0
- shadow_hdk/adapters/agent/library/keeps-helpers.toml +18 -0
- shadow_hdk/adapters/agent/library/orchestrator-workers.toml +17 -0
- shadow_hdk/adapters/agent/library/plan-and-execute.toml +19 -0
- shadow_hdk/adapters/agent/library/reflect-until.toml +18 -0
- shadow_hdk/adapters/agent/library/single.toml +11 -0
- shadow_hdk/adapters/agent/loader.py +109 -0
- shadow_hdk/adapters/agent/meta.py +161 -0
- shadow_hdk/adapters/agent/pattern.py +67 -0
- shadow_hdk/adapters/agent/patterns.py +18 -0
- shadow_hdk/adapters/agent/registry.py +354 -0
- shadow_hdk/adapters/agent/skills.py +95 -0
- shadow_hdk/adapters/agent/skills_library/ask-when-ambiguous.toml +9 -0
- shadow_hdk/adapters/agent/skills_library/look-before-you-change.toml +9 -0
- shadow_hdk/adapters/agent/skills_library/page-a-large-result.toml +9 -0
- shadow_hdk/adapters/agent/skills_library/verify-before-done.toml +10 -0
- shadow_hdk/adapters/basic/__init__.py +27 -0
- shadow_hdk/adapters/basic/callables.py +144 -0
- shadow_hdk/adapters/basic/clock.py +12 -0
- shadow_hdk/adapters/basic/governance.py +38 -0
- shadow_hdk/adapters/basic/mailbox.py +71 -0
- shadow_hdk/adapters/basic/observers.py +36 -0
- shadow_hdk/adapters/basic/sinks.py +171 -0
- shadow_hdk/adapters/basic/store.py +103 -0
- shadow_hdk/adapters/basic/threads.py +89 -0
- shadow_hdk/adapters/derivation/__init__.py +58 -0
- shadow_hdk/adapters/derivation/component.py +193 -0
- shadow_hdk/adapters/derivation/ground.py +305 -0
- shadow_hdk/adapters/derivation/table.py +75 -0
- shadow_hdk/adapters/derivation/values.py +223 -0
- shadow_hdk/adapters/devices/__init__.py +24 -0
- shadow_hdk/adapters/devices/components.py +162 -0
- shadow_hdk/adapters/devices/testing.py +48 -0
- shadow_hdk/adapters/environment/__init__.py +16 -0
- shadow_hdk/adapters/environment/backends.py +109 -0
- shadow_hdk/adapters/environment/local.py +261 -0
- shadow_hdk/adapters/environment/opensandbox.py +130 -0
- shadow_hdk/adapters/environment/sandbox.py +102 -0
- shadow_hdk/adapters/jsonl/__init__.py +27 -0
- shadow_hdk/adapters/jsonl/paths.py +60 -0
- shadow_hdk/adapters/jsonl/session.py +274 -0
- shadow_hdk/adapters/jsonl/transport.py +198 -0
- shadow_hdk/adapters/langchain/__init__.py +5 -0
- shadow_hdk/adapters/langchain/model.py +232 -0
- shadow_hdk/adapters/mcp/__init__.py +5 -0
- shadow_hdk/adapters/mcp/components.py +180 -0
- shadow_hdk/adapters/mcp/held.py +102 -0
- shadow_hdk/adapters/modes/__init__.py +47 -0
- shadow_hdk/adapters/modes/acts.py +103 -0
- shadow_hdk/adapters/modes/check.py +102 -0
- shadow_hdk/adapters/modes/files.py +115 -0
- shadow_hdk/adapters/modes/library/example.toml +50 -0
- shadow_hdk/adapters/modes/mode.py +155 -0
- shadow_hdk/adapters/modes/mode_files.py +129 -0
- shadow_hdk/adapters/modes/registry.py +343 -0
- shadow_hdk/adapters/modes/rules.py +155 -0
- shadow_hdk/adapters/mqtt/__init__.py +5 -0
- shadow_hdk/adapters/mqtt/link.py +449 -0
- shadow_hdk/adapters/otel/__init__.py +5 -0
- shadow_hdk/adapters/otel/observer.py +272 -0
- shadow_hdk/adapters/recording/__init__.py +25 -0
- shadow_hdk/adapters/recording/offer.py +87 -0
- shadow_hdk/adapters/recording/pipes.py +88 -0
- shadow_hdk/adapters/recording/server.py +168 -0
- shadow_hdk/adapters/recording/socket.py +190 -0
- shadow_hdk/kernel/__init__.py +201 -0
- shadow_hdk/kernel/activity.py +35 -0
- shadow_hdk/kernel/components.py +80 -0
- shadow_hdk/kernel/composition.py +91 -0
- shadow_hdk/kernel/contracts.py +112 -0
- shadow_hdk/kernel/effects.py +149 -0
- shadow_hdk/kernel/events.py +256 -0
- shadow_hdk/kernel/leases.py +68 -0
- shadow_hdk/kernel/observations.py +98 -0
- shadow_hdk/kernel/ports.py +361 -0
- shadow_hdk/kernel/providers.py +297 -0
- shadow_hdk/kernel/rules.py +54 -0
- shadow_hdk/kernel/threads.py +60 -0
- shadow_hdk/kernel/usage.py +24 -0
- shadow_hdk/kernel/workspace.py +89 -0
- shadow_hdk/providers/__init__.py +55 -0
- shadow_hdk/providers/environment.py +98 -0
- shadow_hdk/providers/library/claude-code.toml +210 -0
- shadow_hdk/providers/library/codex.toml +104 -0
- shadow_hdk/providers/library/opencode.toml +50 -0
- shadow_hdk/providers/library.py +176 -0
- shadow_hdk/providers/probes.py +163 -0
- shadow_hdk/providers/resolution.py +108 -0
- shadow_hdk/providers/surface.py +183 -0
- shadow_hdk/py.typed +0 -0
- shadow_hdk/runtime/__init__.py +44 -0
- shadow_hdk/runtime/acting.py +53 -0
- shadow_hdk/runtime/approvals.py +122 -0
- shadow_hdk/runtime/bindings.py +529 -0
- shadow_hdk/runtime/cancel.py +55 -0
- shadow_hdk/runtime/children.py +309 -0
- shadow_hdk/runtime/clock.py +28 -0
- shadow_hdk/runtime/compile.py +379 -0
- shadow_hdk/runtime/devices.py +84 -0
- shadow_hdk/runtime/emit.py +174 -0
- shadow_hdk/runtime/environment.py +427 -0
- shadow_hdk/runtime/errors.py +54 -0
- shadow_hdk/runtime/inputs.py +31 -0
- shadow_hdk/runtime/items.py +261 -0
- shadow_hdk/runtime/leash.py +289 -0
- shadow_hdk/runtime/lines.py +39 -0
- shadow_hdk/runtime/loop.py +347 -0
- shadow_hdk/runtime/offer.py +172 -0
- shadow_hdk/runtime/person.py +84 -0
- shadow_hdk/runtime/processes.py +186 -0
- shadow_hdk/runtime/registry.py +74 -0
- shadow_hdk/runtime/replay.py +163 -0
- shadow_hdk/runtime/session.py +258 -0
- shadow_hdk/runtime/state.py +66 -0
- shadow_hdk/runtime/step.py +414 -0
- shadow_hdk/runtime/store.py +38 -0
- shadow_hdk/runtime/switched.py +61 -0
- shadow_hdk/runtime/testing/__init__.py +26 -0
- shadow_hdk/runtime/testing/doubles.py +178 -0
- shadow_hdk/runtime/threads.py +753 -0
- shadow_hdk/runtime/trust.py +124 -0
- shadow_hdk/serve/__init__.py +36 -0
- shadow_hdk/serve/__main__.py +103 -0
- shadow_hdk/serve/batteries.py +398 -0
- shadow_hdk/serve/batteries_library/ddgs.toml +18 -0
- shadow_hdk/serve/batteries_library/wigolo.toml +29 -0
- shadow_hdk/serve/config.py +116 -0
- shadow_hdk/serve/facade.py +208 -0
- shadow_hdk/serve/host.py +508 -0
- shadow_hdk/serve/keeping.py +50 -0
- shadow_hdk/serve/web.py +31 -0
- shadow_hdk/wire/__init__.py +53 -0
- shadow_hdk/wire/__main__.py +22 -0
- shadow_hdk/wire/channel.py +69 -0
- shadow_hdk/wire/context.py +265 -0
- shadow_hdk/wire/peer.py +203 -0
- shadow_hdk/wire/protocol.py +176 -0
- shadow_hdk/wire/remote.py +159 -0
- shadow_hdk/wire/schemas.py +81 -0
- shadow_hdk/wire/serve.py +308 -0
- shadow_hdk/wire/sides.py +510 -0
- shadow_hdk/wire/stdio.py +167 -0
- shadow_hdk/wire/threads.py +550 -0
- shadow_hdk-0.27.0.dist-info/METADATA +708 -0
- shadow_hdk-0.27.0.dist-info/RECORD +158 -0
- shadow_hdk-0.27.0.dist-info/WHEEL +4 -0
- shadow_hdk-0.27.0.dist-info/entry_points.txt +7 -0
- 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,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"]
|