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.
- agentassure/__init__.py +68 -0
- agentassure/adapters/__init__.py +5 -0
- agentassure/adapters/base.py +210 -0
- agentassure/adapters/http.py +326 -0
- agentassure/adapters/llm.py +325 -0
- agentassure/adapters/mapping.py +162 -0
- agentassure/adapters/python_callable.py +228 -0
- agentassure/adapters/registry.py +52 -0
- agentassure/adapters/streaming.py +67 -0
- agentassure/attacks/__init__.py +62 -0
- agentassure/authoring/__init__.py +0 -0
- agentassure/authoring/cases.py +535 -0
- agentassure/cli/__init__.py +0 -0
- agentassure/cli/main.py +199 -0
- agentassure/cli/templates.py +147 -0
- agentassure/config/__init__.py +26 -0
- agentassure/config/loader.py +147 -0
- agentassure/config/schema.py +379 -0
- agentassure/conversation/__init__.py +5 -0
- agentassure/conversation/session.py +268 -0
- agentassure/core/__init__.py +0 -0
- agentassure/core/aggregation.py +140 -0
- agentassure/core/checks.py +86 -0
- agentassure/core/evidence.py +303 -0
- agentassure/core/gates.py +192 -0
- agentassure/core/redaction.py +104 -0
- agentassure/core/results.py +137 -0
- agentassure/core/retry.py +57 -0
- agentassure/dsl/__init__.py +0 -0
- agentassure/dsl/decorators.py +99 -0
- agentassure/dsl/expect.py +1852 -0
- agentassure/errors.py +41 -0
- agentassure/llm/__init__.py +5 -0
- agentassure/llm/clients.py +236 -0
- agentassure/metrics/__init__.py +40 -0
- agentassure/metrics/base.py +118 -0
- agentassure/metrics/builtin.py +316 -0
- agentassure/metrics/rag_metrics.py +423 -0
- agentassure/perf/__init__.py +4 -0
- agentassure/perf/aggregator.py +412 -0
- agentassure/perf/cli.py +604 -0
- agentassure/perf/compare.py +449 -0
- agentassure/perf/config.py +636 -0
- agentassure/perf/describe.py +128 -0
- agentassure/perf/driver.py +349 -0
- agentassure/perf/engine.py +926 -0
- agentassure/perf/health.py +87 -0
- agentassure/perf/histogram.py +204 -0
- agentassure/perf/otel.py +260 -0
- agentassure/perf/outcomes.py +199 -0
- agentassure/perf/pricing.py +33 -0
- agentassure/perf/qualification.py +64 -0
- agentassure/perf/quality.py +166 -0
- agentassure/perf/report.py +486 -0
- agentassure/perf/samples.py +210 -0
- agentassure/perf/scenarios.py +142 -0
- agentassure/perf/shaper.py +135 -0
- agentassure/perf/stats.py +149 -0
- agentassure/perf/templates/perf_report.html.j2 +314 -0
- agentassure/perf/testing.py +374 -0
- agentassure/perf/thinktime.py +41 -0
- agentassure/perf/verdicts.py +316 -0
- agentassure/py.typed +1 -0
- agentassure/pytest_plugin/__init__.py +0 -0
- agentassure/pytest_plugin/metric_names.py +56 -0
- agentassure/pytest_plugin/plugin.py +580 -0
- agentassure/pytest_plugin/state.py +56 -0
- agentassure/reports/__init__.py +0 -0
- agentassure/reports/console.py +89 -0
- agentassure/reports/html.py +74 -0
- agentassure/reports/otel.py +92 -0
- agentassure/reports/templates/report.html.j2 +256 -0
- agentassure/reports/writer.py +44 -0
- agentassure/runtime/__init__.py +0 -0
- agentassure/runtime/context.py +432 -0
- agentassure/runtime/runner.py +194 -0
- agentassure/runtime/session.py +150 -0
- agentassure/runtime/steps.py +54 -0
- agentassure/state/__init__.py +16 -0
- agentassure/state/diff.py +92 -0
- agentassure/state/memory.py +110 -0
- agentassure/state/provider.py +229 -0
- agentassure_ai-0.1.0.dist-info/METADATA +152 -0
- agentassure_ai-0.1.0.dist-info/RECORD +86 -0
- agentassure_ai-0.1.0.dist-info/WHEEL +4 -0
- agentassure_ai-0.1.0.dist-info/entry_points.txt +33 -0
agentassure/__init__.py
ADDED
|
@@ -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()
|