AgentAssure-ai 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 (86) hide show
  1. agentassure/__init__.py +68 -0
  2. agentassure/adapters/__init__.py +5 -0
  3. agentassure/adapters/base.py +210 -0
  4. agentassure/adapters/http.py +326 -0
  5. agentassure/adapters/llm.py +325 -0
  6. agentassure/adapters/mapping.py +162 -0
  7. agentassure/adapters/python_callable.py +228 -0
  8. agentassure/adapters/registry.py +52 -0
  9. agentassure/adapters/streaming.py +67 -0
  10. agentassure/attacks/__init__.py +62 -0
  11. agentassure/authoring/__init__.py +0 -0
  12. agentassure/authoring/cases.py +535 -0
  13. agentassure/cli/__init__.py +0 -0
  14. agentassure/cli/main.py +199 -0
  15. agentassure/cli/templates.py +147 -0
  16. agentassure/config/__init__.py +26 -0
  17. agentassure/config/loader.py +147 -0
  18. agentassure/config/schema.py +379 -0
  19. agentassure/conversation/__init__.py +5 -0
  20. agentassure/conversation/session.py +268 -0
  21. agentassure/core/__init__.py +0 -0
  22. agentassure/core/aggregation.py +140 -0
  23. agentassure/core/checks.py +86 -0
  24. agentassure/core/evidence.py +303 -0
  25. agentassure/core/gates.py +192 -0
  26. agentassure/core/redaction.py +104 -0
  27. agentassure/core/results.py +137 -0
  28. agentassure/core/retry.py +57 -0
  29. agentassure/dsl/__init__.py +0 -0
  30. agentassure/dsl/decorators.py +99 -0
  31. agentassure/dsl/expect.py +1852 -0
  32. agentassure/errors.py +41 -0
  33. agentassure/llm/__init__.py +5 -0
  34. agentassure/llm/clients.py +236 -0
  35. agentassure/metrics/__init__.py +40 -0
  36. agentassure/metrics/base.py +118 -0
  37. agentassure/metrics/builtin.py +316 -0
  38. agentassure/metrics/rag_metrics.py +423 -0
  39. agentassure/perf/__init__.py +4 -0
  40. agentassure/perf/aggregator.py +412 -0
  41. agentassure/perf/cli.py +604 -0
  42. agentassure/perf/compare.py +449 -0
  43. agentassure/perf/config.py +636 -0
  44. agentassure/perf/describe.py +128 -0
  45. agentassure/perf/driver.py +349 -0
  46. agentassure/perf/engine.py +926 -0
  47. agentassure/perf/health.py +87 -0
  48. agentassure/perf/histogram.py +204 -0
  49. agentassure/perf/otel.py +260 -0
  50. agentassure/perf/outcomes.py +199 -0
  51. agentassure/perf/pricing.py +33 -0
  52. agentassure/perf/qualification.py +64 -0
  53. agentassure/perf/quality.py +166 -0
  54. agentassure/perf/report.py +486 -0
  55. agentassure/perf/samples.py +210 -0
  56. agentassure/perf/scenarios.py +142 -0
  57. agentassure/perf/shaper.py +135 -0
  58. agentassure/perf/stats.py +149 -0
  59. agentassure/perf/templates/perf_report.html.j2 +314 -0
  60. agentassure/perf/testing.py +374 -0
  61. agentassure/perf/thinktime.py +41 -0
  62. agentassure/perf/verdicts.py +316 -0
  63. agentassure/py.typed +1 -0
  64. agentassure/pytest_plugin/__init__.py +0 -0
  65. agentassure/pytest_plugin/metric_names.py +56 -0
  66. agentassure/pytest_plugin/plugin.py +580 -0
  67. agentassure/pytest_plugin/state.py +56 -0
  68. agentassure/reports/__init__.py +0 -0
  69. agentassure/reports/console.py +89 -0
  70. agentassure/reports/html.py +74 -0
  71. agentassure/reports/otel.py +92 -0
  72. agentassure/reports/templates/report.html.j2 +256 -0
  73. agentassure/reports/writer.py +44 -0
  74. agentassure/runtime/__init__.py +0 -0
  75. agentassure/runtime/context.py +432 -0
  76. agentassure/runtime/runner.py +194 -0
  77. agentassure/runtime/session.py +150 -0
  78. agentassure/runtime/steps.py +54 -0
  79. agentassure/state/__init__.py +16 -0
  80. agentassure/state/diff.py +92 -0
  81. agentassure/state/memory.py +110 -0
  82. agentassure/state/provider.py +229 -0
  83. agentassure_ai-0.1.0.dist-info/METADATA +152 -0
  84. agentassure_ai-0.1.0.dist-info/RECORD +86 -0
  85. agentassure_ai-0.1.0.dist-info/WHEEL +4 -0
  86. agentassure_ai-0.1.0.dist-info/entry_points.txt +33 -0
@@ -0,0 +1,68 @@
1
+ """AgentAssure: pytest-based QA for enterprise AI agents.
2
+
3
+ Write tests that talk to an agent, then check what it said, which tools it called, what it changed in the
4
+ system of record and what it was allowed to do. Run them with `pytest` or `agentassure test`.
5
+
6
+ Quick start (`agentassure init` scaffolds the config)::
7
+
8
+ import agentassure as aa
9
+
10
+ @aa.test(agent="service_agent", persona="service_rep")
11
+ def test_escalation(agent: aa.Conversation, sf: aa.StateProvider, expect: aa.Expect):
12
+ case = sf.create("Case", Subject="Delayed order", Priority="Medium")
13
+ with sf.watch() as diff:
14
+ reply = agent.ask("Escalate case {n}", n=case.CaseNumber)
15
+ expect(reply).tool_called("Escalate_Case").response_contains("High priority")
16
+ expect(diff).record(case).changed_only(Priority="High")
17
+
18
+ The type hints are optional; add them to get autocomplete and hover docs on the fixtures.
19
+
20
+ What you import from here:
21
+
22
+ - Markers: `test`, `cases` (CSV-driven tests), `agent`, `tags`, `meta`.
23
+ - Structure: `step` (named test steps), `Responder` / `when` (scripted replies to the agent's questions).
24
+ - Fixture types: `Conversation` (`agent`), `StateProvider` (`state` / `sf`), `Expect` (`expect`),
25
+ `Row` (`row`), plus `Reply` and `StateDiff` for the values they return.
26
+ - Constants: `metrics` (names for `expect(...).metric(...)`), `attacks` (prompt-injection payloads),
27
+ `Severity`.
28
+ """
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ from agentassure import attacks, metrics
33
+ from agentassure.authoring.cases import Row
34
+ from agentassure.conversation.session import Conversation, Responder, when
35
+ from agentassure.core.checks import Severity
36
+ from agentassure.core.evidence import Reply
37
+ from agentassure.dsl.decorators import agent, cases, meta, tags, test
38
+ from agentassure.dsl.expect import Expect
39
+ from agentassure.perf.engine import PerfExpect, PerfReply, PerfSession
40
+ from agentassure.perf.scenarios import perf_scenario
41
+ from agentassure.runtime.steps import step
42
+ from agentassure.state.diff import StateDiff
43
+ from agentassure.state.provider import StateProvider
44
+
45
+ __all__ = [
46
+ "Conversation",
47
+ "Expect",
48
+ "PerfExpect",
49
+ "PerfReply",
50
+ "PerfSession",
51
+ "Reply",
52
+ "Responder",
53
+ "Row",
54
+ "Severity",
55
+ "StateDiff",
56
+ "StateProvider",
57
+ "__version__",
58
+ "agent",
59
+ "attacks",
60
+ "cases",
61
+ "meta",
62
+ "metrics",
63
+ "perf_scenario",
64
+ "step",
65
+ "tags",
66
+ "test",
67
+ "when",
68
+ ]
@@ -0,0 +1,5 @@
1
+ """Adapters connect tests to agents: generic HTTP, in-process Python callables, and a lookup registry."""
2
+
3
+ from agentassure.adapters.base import AdapterContext, AgentAdapter, AgentSession, PersonaContext, Resources
4
+
5
+ __all__ = ["AdapterContext", "AgentAdapter", "AgentSession", "PersonaContext", "Resources"]
@@ -0,0 +1,210 @@
1
+ """Adapter extension point: `AgentAdapter` / `AgentSession` base classes and shared adapter context types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Protocol, TypeVar
9
+
10
+ from agentassure.config.schema import AgentConfig, ResolvedConfig
11
+ from agentassure.core.evidence import Capabilities, Exchange, Reply
12
+ from agentassure.state.provider import StateProvider
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class Resources:
18
+ """Session-wide shared objects (connections, token providers) keyed by name."""
19
+
20
+ def __init__(self) -> None:
21
+ self._items: dict[str, Any] = {}
22
+
23
+ def get_or_create(self, key: str, factory: Callable[[], T]) -> T:
24
+ """Return the object stored under `key`, creating and storing it with `factory` on first use."""
25
+ if key not in self._items:
26
+ self._items[key] = factory()
27
+ return self._items[key]
28
+
29
+ def get(self, key: str) -> Any:
30
+ """Return the object stored under `key`, or None if absent."""
31
+ return self._items.get(key)
32
+
33
+ def set(self, key: str, value: Any) -> None:
34
+ """Store `value` under `key`, replacing any existing entry."""
35
+ self._items[key] = value
36
+
37
+ def close(self) -> None:
38
+ """Close stored objects that have a `close()` method, in reverse insertion order."""
39
+ for item in reversed(list(self._items.values())):
40
+ close = getattr(item, "close", None)
41
+ if callable(close):
42
+ close()
43
+ self._items.clear()
44
+
45
+
46
+ @dataclass
47
+ class AdapterContext:
48
+ """Wiring handed to every adapter: the resolved run config and shared session resources."""
49
+
50
+ config: ResolvedConfig
51
+ resources: Resources
52
+
53
+
54
+ @dataclass
55
+ class PersonaContext:
56
+ """Runtime persona for a session: test-user identity, context variables, and adapter options."""
57
+
58
+ name: str
59
+ username: str | None = None
60
+ description: str | None = None
61
+ context_variables: dict[str, Any] = field(default_factory=dict)
62
+ options: dict[str, Any] = field(default_factory=dict)
63
+
64
+
65
+ class PermissionChecker(Protocol):
66
+ """Platform access checker: which records a persona's test user may read or edit.
67
+
68
+ Return an implementation from `AgentAdapter.permission_checker()`.
69
+ """
70
+
71
+ def extract_record_ids(self, text: str) -> list[str]:
72
+ """Return the record IDs mentioned in `text` that permission assertions should check."""
73
+ ...
74
+
75
+ def access(self, persona: PersonaContext, record_ids: list[str]) -> dict[str, dict[str, bool]]:
76
+ """Return {record_id: {"read": bool, "edit": bool}} evaluated as the persona user. Leave out IDs
77
+ that can't be evaluated (for example records the checking user can't see): they are reported
78
+ as unknown, not as violations."""
79
+ ...
80
+
81
+
82
+ class AgentSession(ABC):
83
+ """One live conversation with the agent under test.
84
+
85
+ Subclass this in your adapter and implement `send`. Override `set_variable`, `close`, and
86
+ `finalize` only if your platform needs them. Set `capabilities` to declare what evidence the
87
+ agent reports (tool calls, citations, ...).
88
+ """
89
+
90
+ capabilities: Capabilities
91
+
92
+ @abstractmethod
93
+ def send(self, text: str) -> Exchange:
94
+ """Send one user message and return the recorded exchange."""
95
+ ...
96
+
97
+ def set_variable(self, name: str, value: Any) -> None:
98
+ """Set a session variable visible to the agent; unsupported adapters raise NotImplementedError."""
99
+ raise NotImplementedError(f"{type(self).__name__} does not support session variables")
100
+
101
+ def close(self) -> None:
102
+ """End the session and release any live resources."""
103
+ return None
104
+
105
+ def finalize(self, replies: list[Reply]) -> None:
106
+ """Called after `close()`: fill evidence that only exists once the session has ended (into the
107
+ replies' exchanges), then clear `capabilities.deferred`."""
108
+ return None
109
+
110
+
111
+ class StreamObserver(Protocol):
112
+ """Receives timing signals from an `AsyncAgentSession.send` (used by performance testing).
113
+
114
+ Call `dispatched()` once the request has been handed to the network, and `content(text)` for every
115
+ chunk that carries reply text (empty strings are ignored). Both are cheap and must not block.
116
+ """
117
+
118
+ def dispatched(self) -> None: ...
119
+
120
+ def content(self, text: str) -> None: ...
121
+
122
+
123
+ class AsyncAgentSession(ABC):
124
+ """Optional async, stream-capable conversation (see `AgentAdapter.open_session_async`).
125
+
126
+ `send` must call `observer.dispatched()` when the request leaves the process and `observer.content()`
127
+ for each text chunk as it arrives; that is how first-content latency and stream gaps are measured.
128
+ """
129
+
130
+ capabilities: Capabilities
131
+ streams: bool = False
132
+ """True when replies arrive in chunks. Only streaming sessions get first-content and stream-silence
133
+ timeouts; non-streaming replies are bounded by the turn timeout alone."""
134
+
135
+ @abstractmethod
136
+ async def send(self, text: str, observer: StreamObserver | None = None) -> Exchange:
137
+ """Send one user message, report chunks to `observer`, and return the complete exchange."""
138
+ ...
139
+
140
+ async def aclose(self) -> None:
141
+ """End the session and release live resources."""
142
+ return None
143
+
144
+
145
+ class AgentAdapter(ABC):
146
+ """Connects AgentAssure to one agent. Instantiated once per agent per test session.
147
+
148
+ To support a new agent platform, subclass this, implement `open_session` (returning an
149
+ `AgentSession`), and register the class under a name so `adapter: <name>` works in agentassure.yaml
150
+ (see `agentassure.adapters.registry.register`, or the `agentassure.adapters` entry-point group).
151
+ Optionally override `preflight`, `state_provider`, `permission_checker`, `instructions`, `describe`,
152
+ `doctor`, and `close`. Set `name` and, if the agent reports tool calls etc., `capabilities`.
153
+ """
154
+
155
+ name: str = "adapter"
156
+ capabilities: Capabilities = Capabilities()
157
+ supports_async: bool = False
158
+ """True when `open_session_async` is implemented (perf runs then stream instead of using threads)."""
159
+ usage_model: str | None = None
160
+ """`provider/model` key used to price reported token usage (perf `pricing`), if known."""
161
+
162
+ def __init__(self, agent_name: str, agent_config: AgentConfig, context: AdapterContext):
163
+ self.agent_name = agent_name
164
+ self.agent_config = agent_config
165
+ self.context = context
166
+
167
+ @property
168
+ def options(self) -> dict[str, Any]:
169
+ """Adapter-specific options: the extra keys of this agent's config entry."""
170
+ return self.agent_config.options
171
+
172
+ def preflight(self) -> None:
173
+ """Validate connectivity and safety before any test runs. Raise to abort the session."""
174
+
175
+ @abstractmethod
176
+ def open_session(self, persona: PersonaContext | None) -> AgentSession:
177
+ """Start a new conversation, optionally running as `persona`'s test user."""
178
+ ...
179
+
180
+ async def open_session_async(self, persona: PersonaContext | None) -> AsyncAgentSession:
181
+ """Start an async, stream-capable conversation. Only called when `supports_async` is True."""
182
+ raise NotImplementedError(f"{type(self).__name__} has no async sessions")
183
+
184
+ async def aclose(self) -> None:
185
+ """Release async resources (e.g. an `httpx.AsyncClient`) created by `open_session_async`."""
186
+ return None
187
+
188
+ def state_provider(self) -> StateProvider | None:
189
+ """Default state provider for this agent, if the platform has one."""
190
+ return None
191
+
192
+ def permission_checker(self) -> PermissionChecker | None:
193
+ """Platform access checks used by persona permission assertions."""
194
+ return None
195
+
196
+ def instructions(self) -> list[str]:
197
+ """The agent's own instructions, for leak checks. Defaults to `instructions:` in the agent config."""
198
+ return list(self.agent_config.instructions)
199
+
200
+ def describe(self) -> dict[str, Any]:
201
+ """Return a short dict identifying this adapter for reports and diagnostics."""
202
+ return {"adapter": self.name}
203
+
204
+ def doctor(self) -> list[tuple[str, bool, str]]:
205
+ """Diagnostics for `agentassure doctor`: (check, ok, detail)."""
206
+ return []
207
+
208
+ def close(self) -> None:
209
+ """Release adapter-level resources such as open connections."""
210
+ return None
@@ -0,0 +1,326 @@
1
+ """Generic HTTP adapter (`adapter: http`): test any agent that exposes a JSON chat endpoint.
2
+
3
+ Sync sessions (functional tests) POST and map the JSON reply. Async sessions (performance tests) can also
4
+ consume a streamed reply: `stream: sse` (Server-Sent Events) or `stream: ndjson`, reporting each text
5
+ chunk so first-content latency and stream gaps are measured. See the GitBook "Agents and adapters" page.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import time
12
+ import uuid
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from agentassure.adapters.base import (
18
+ AgentAdapter,
19
+ AgentSession,
20
+ AsyncAgentSession,
21
+ PersonaContext,
22
+ StreamObserver,
23
+ )
24
+ from agentassure.adapters.mapping import dig, exchange_from_payload, usage_from_payload
25
+ from agentassure.adapters.streaming import StreamProtocolError, iter_ndjson, iter_sse
26
+ from agentassure.config.loader import require
27
+ from agentassure.core.evidence import AgentMessage, Capabilities, Exchange
28
+ from agentassure.errors import AdapterError, ConfigError
29
+
30
+ STREAM_MODES = ("none", "sse", "ndjson")
31
+
32
+
33
+ class HttpSession(AgentSession):
34
+ """One conversation with an HTTP agent: tracks the server-side session ID and mutable variables."""
35
+
36
+ def __init__(self, adapter: HttpAdapter, persona: PersonaContext | None):
37
+ self.adapter = adapter
38
+ self.persona = persona
39
+ self.capabilities = adapter.capabilities
40
+ self.session_id = str(uuid.uuid4())
41
+ self.variables: dict[str, Any] = dict(persona.context_variables) if persona else {}
42
+ start_path = adapter.options.get("session_start_path")
43
+ if start_path:
44
+ data = adapter.post(
45
+ start_path, {"persona": persona.name if persona else None, "variables": self.variables}
46
+ )
47
+ self.session_id = str(
48
+ dig(data, adapter.options.get("session_id_field", "session_id"), self.session_id)
49
+ )
50
+
51
+ def set_variable(self, name: str, value: Any) -> None:
52
+ """Set a session variable sent with subsequent requests."""
53
+ self.variables[name] = value
54
+
55
+ def send(self, text: str) -> Exchange:
56
+ """POST the user message to the chat endpoint and map the JSON response to an Exchange."""
57
+ req = self.adapter.options.get("request", {})
58
+ body = {
59
+ req.get("message_field", "message"): text,
60
+ req.get("session_field", "session_id"): self.session_id,
61
+ req.get("persona_field", "persona"): self.persona.name if self.persona else None,
62
+ req.get("variables_field", "variables"): self.variables,
63
+ }
64
+ started = time.perf_counter()
65
+ data = self.adapter.post(self.adapter.options.get("path", "/chat"), body)
66
+ exchange = self.adapter.to_exchange(text, data)
67
+ exchange.latency_ms = (time.perf_counter() - started) * 1000
68
+ return exchange
69
+
70
+ def close(self) -> None:
71
+ """Call the optional `session_end_path` endpoint, ignoring request failures."""
72
+ end_path = self.adapter.options.get("session_end_path")
73
+ if end_path:
74
+ with contextlib.suppress(AdapterError):
75
+ self.adapter.post(end_path, {"session_id": self.session_id})
76
+
77
+
78
+ def _body(session: HttpSession | HttpAsyncSession, text: str) -> dict[str, Any]:
79
+ req = session.adapter.options.get("request", {})
80
+ return {
81
+ req.get("message_field", "message"): text,
82
+ req.get("session_field", "session_id"): session.session_id,
83
+ req.get("persona_field", "persona"): session.persona.name if session.persona else None,
84
+ req.get("variables_field", "variables"): session.variables,
85
+ }
86
+
87
+
88
+ _SEND_STARTED = ("http11.send_request_headers.started", "http2.send_request_headers.started")
89
+
90
+
91
+ def trace_extensions(observer: StreamObserver | None) -> dict[str, Any]:
92
+ """httpx `extensions` that call `observer.dispatched()` when the request headers start going out
93
+ (after connection setup), so connect/TLS time counts as client wait, not agent latency."""
94
+ if observer is None:
95
+ return {}
96
+
97
+ async def trace(event: str, info: dict[str, Any]) -> None:
98
+ if event in _SEND_STARTED:
99
+ observer.dispatched()
100
+
101
+ return {"trace": trace}
102
+
103
+
104
+ class HttpAsyncSession(AsyncAgentSession):
105
+ """Async conversation with an HTTP agent; streams the reply when `stream` is `sse` or `ndjson`."""
106
+
107
+ def __init__(self, adapter: HttpAdapter, persona: PersonaContext | None):
108
+ self.adapter = adapter
109
+ self.persona = persona
110
+ self.capabilities = adapter.capabilities
111
+ self.session_id = str(uuid.uuid4())
112
+ self.variables: dict[str, Any] = dict(persona.context_variables) if persona else {}
113
+ self.streams = adapter.stream_mode != "none"
114
+
115
+ async def start(self) -> None:
116
+ start_path = self.adapter.options.get("session_start_path")
117
+ if start_path:
118
+ data = await self.adapter.apost(
119
+ start_path,
120
+ {"persona": self.persona.name if self.persona else None, "variables": self.variables},
121
+ )
122
+ self.session_id = str(
123
+ dig(data, self.adapter.options.get("session_id_field", "session_id"), self.session_id)
124
+ )
125
+
126
+ async def send(self, text: str, observer: StreamObserver | None = None) -> Exchange:
127
+ """Send one message; with streaming, report each text chunk to `observer` as it arrives."""
128
+ opts = self.adapter.options
129
+ mode = self.adapter.stream_mode
130
+ started = time.perf_counter()
131
+ if mode == "none":
132
+ data = await self.adapter.apost(opts.get("path", "/chat"), _body(self, text), observer=observer)
133
+ exchange = self.adapter.to_exchange(text, data)
134
+ else:
135
+ exchange = await self._stream(text, observer)
136
+ exchange.latency_ms = (time.perf_counter() - started) * 1000
137
+ return exchange
138
+
139
+ async def _stream(self, text: str, observer: StreamObserver | None) -> Exchange:
140
+ opts = self.adapter.options
141
+ path = opts.get("stream_path") or opts.get("path", "/chat")
142
+ text_path = opts.get("stream_text_path", "text")
143
+ done_marker = opts.get("stream_done", "[DONE]")
144
+ final_event = opts.get("stream_final_event", "final")
145
+ usage_path = opts.get("usage_path", "usage")
146
+ parts: list[str] = []
147
+ final_payload: dict[str, Any] | None = None
148
+ usage_raw: Any = None
149
+ done = False
150
+ try:
151
+ async with self.adapter.async_client().stream(
152
+ "POST", path, json=_body(self, text), extensions=trace_extensions(observer)
153
+ ) as response:
154
+ response.raise_for_status()
155
+ lines = response.aiter_lines()
156
+ if self.adapter.stream_mode == "sse":
157
+ async for event in iter_sse(lines):
158
+ if event.data.strip() == done_marker or event.event == done_marker:
159
+ done = True
160
+ break
161
+ try:
162
+ payload = event.json()
163
+ except ValueError as exc:
164
+ raise StreamProtocolError(
165
+ f"{self.adapter.agent_name}: SSE data isn't JSON: {event.data[:200]!r}"
166
+ ) from exc
167
+ if event.event == final_event:
168
+ final_payload = payload if isinstance(payload, dict) else None
169
+ done = True
170
+ break
171
+ chunk = dig(payload, text_path)
172
+ if isinstance(chunk, str) and chunk:
173
+ parts.append(chunk)
174
+ if observer:
175
+ observer.content(chunk)
176
+ usage_raw = dig(payload, usage_path, usage_raw)
177
+ else:
178
+ async for payload in iter_ndjson(lines):
179
+ if isinstance(payload, dict) and payload.get("done") is True:
180
+ final_payload = payload
181
+ done = True
182
+ break
183
+ chunk = dig(payload, text_path)
184
+ if isinstance(chunk, str) and chunk:
185
+ parts.append(chunk)
186
+ if observer:
187
+ observer.content(chunk)
188
+ usage_raw = dig(payload, usage_path, usage_raw)
189
+ except ValueError as exc:
190
+ if isinstance(exc, StreamProtocolError):
191
+ raise
192
+ raise StreamProtocolError(f"{self.adapter.agent_name}: malformed stream: {exc}") from exc
193
+ except httpx.HTTPError as exc:
194
+ raise AdapterError(f"{self.adapter.agent_name}: HTTP stream to {path} failed: {exc}") from exc
195
+ if not done and opts.get("stream_require_done", True):
196
+ raise StreamProtocolError(f"{self.adapter.agent_name}: stream ended without a completion marker")
197
+ streamed = "".join(parts)
198
+ if final_payload is not None:
199
+ exchange = self.adapter.to_exchange(text, final_payload)
200
+ if not exchange.response and streamed:
201
+ exchange.messages = [AgentMessage(text=streamed, agent=self.adapter.agent_name)]
202
+ if exchange.usage is None:
203
+ exchange.usage = usage_from_payload(usage_raw, opts.get("usage_fields"))
204
+ return exchange
205
+ exchange = self.adapter.to_exchange(text, {"text": streamed})
206
+ exchange.usage = usage_from_payload(usage_raw, opts.get("usage_fields"))
207
+ return exchange
208
+
209
+ async def aclose(self) -> None:
210
+ end_path = self.adapter.options.get("session_end_path")
211
+ if end_path:
212
+ with contextlib.suppress(AdapterError):
213
+ await self.adapter.apost(end_path, {"session_id": self.session_id})
214
+
215
+
216
+ class HttpAdapter(AgentAdapter):
217
+ """Generic JSON-over-HTTP agent.
218
+
219
+ Options: base_url, path, headers, timeout_s, request{...field names}, response{...dotted paths},
220
+ capabilities{...}, session_start_path, session_end_path, session_id_field, usage_path, usage_fields;
221
+ streaming (async/perf only): stream (none|sse|ndjson), stream_path, stream_text_path, stream_done,
222
+ stream_final_event, stream_require_done.
223
+ """
224
+
225
+ name = "http"
226
+ supports_async = True
227
+
228
+ def __init__(self, *args: Any, **kwargs: Any):
229
+ super().__init__(*args, **kwargs)
230
+ base_url = require(self.options.get("base_url"), f"agents.{self.agent_name}.base_url")
231
+ self.client = httpx.Client(
232
+ base_url=base_url,
233
+ headers=self.options.get("headers") or {},
234
+ timeout=float(self.options.get("timeout_s", self.context.config.defaults.timeout_s)),
235
+ )
236
+ declared = self.options.get("capabilities") or {}
237
+ response_map = self.options.get("response") or {}
238
+ inferred = {
239
+ "tool_calls": "tool_calls" in response_map,
240
+ "tool_arguments": "tool_calls" in response_map,
241
+ "tool_outputs": "tool_calls" in response_map,
242
+ "routing": "routing" in response_map,
243
+ "handoffs": "handoffs" in response_map,
244
+ "escalation": "escalation" in response_map,
245
+ "retrieved_contexts": "contexts" in response_map,
246
+ }
247
+ self.capabilities = Capabilities(**(inferred | declared))
248
+ self.stream_mode = str(self.options.get("stream", "none"))
249
+ if self.stream_mode not in STREAM_MODES:
250
+ raise ConfigError(
251
+ f"agents.{self.agent_name}.stream must be one of {', '.join(STREAM_MODES)}, got {self.stream_mode!r}"
252
+ )
253
+ self.usage_model = self.options.get("usage_model")
254
+ self._aclient: httpx.AsyncClient | None = None
255
+
256
+ def to_exchange(self, text: str, data: Any) -> Exchange:
257
+ """Map a JSON reply to an Exchange, including token usage at `usage_path` (default `usage`)."""
258
+ payload = data if isinstance(data, dict) else {"text": data}
259
+ exchange = exchange_from_payload(text, payload, self.options.get("response", {}), self.agent_name)
260
+ exchange.usage = usage_from_payload(
261
+ dig(payload, self.options.get("usage_path", "usage")), self.options.get("usage_fields")
262
+ )
263
+ return exchange
264
+
265
+ def async_client(self) -> httpx.AsyncClient:
266
+ """The adapter's async client, created on first use inside the running event loop."""
267
+ if self._aclient is None:
268
+ self._aclient = httpx.AsyncClient(
269
+ base_url=str(self.client.base_url),
270
+ headers=self.options.get("headers") or {},
271
+ timeout=self.client.timeout,
272
+ limits=httpx.Limits(max_connections=None, max_keepalive_connections=None),
273
+ )
274
+ return self._aclient
275
+
276
+ async def apost(self, path: str, body: dict[str, Any], observer: StreamObserver | None = None) -> Any:
277
+ """Async POST returning the decoded JSON body.
278
+
279
+ Raises:
280
+ AdapterError: the request failed or returned a non-2xx status (the httpx error is the cause).
281
+ """
282
+ try:
283
+ response = await self.async_client().post(path, json=body, extensions=trace_extensions(observer))
284
+ response.raise_for_status()
285
+ return response.json()
286
+ except httpx.HTTPError as exc:
287
+ raise AdapterError(f"{self.agent_name}: HTTP call to {path} failed: {exc}") from exc
288
+ except ValueError as exc:
289
+ raise StreamProtocolError(f"{self.agent_name}: response from {path} isn't JSON") from exc
290
+
291
+ async def open_session_async(self, persona: PersonaContext | None) -> AsyncAgentSession:
292
+ """Start an async session (calls `session_start_path` when configured)."""
293
+ session = HttpAsyncSession(self, persona)
294
+ await session.start()
295
+ return session
296
+
297
+ async def aclose(self) -> None:
298
+ """Close the async client."""
299
+ if self._aclient is not None:
300
+ await self._aclient.aclose()
301
+ self._aclient = None
302
+
303
+ def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
304
+ """POST JSON to `path` and return the decoded response body.
305
+
306
+ Raises:
307
+ AdapterError: If the request fails or returns a non-2xx status.
308
+ """
309
+ try:
310
+ response = self.client.post(path, json=body)
311
+ response.raise_for_status()
312
+ return response.json()
313
+ except httpx.HTTPError as exc:
314
+ raise AdapterError(f"{self.agent_name}: HTTP call to {path} failed: {exc}") from exc
315
+
316
+ def open_session(self, persona: PersonaContext | None) -> AgentSession:
317
+ """Start a new HTTP session, optionally as `persona`'s user."""
318
+ return HttpSession(self, persona)
319
+
320
+ def describe(self) -> dict[str, Any]:
321
+ """Return adapter name and base URL for reports and diagnostics."""
322
+ return {"adapter": self.name, "base_url": str(self.client.base_url)}
323
+
324
+ def close(self) -> None:
325
+ """Close the underlying httpx client."""
326
+ self.client.close()