agent-handshake 0.7.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 (71) hide show
  1. agent_handshake/__init__.py +74 -0
  2. agent_handshake/_internal/__init__.py +1 -0
  3. agent_handshake/_internal/hashing.py +23 -0
  4. agent_handshake/_internal/safe_eval.py +119 -0
  5. agent_handshake/_internal/types.py +14 -0
  6. agent_handshake/adapters/__init__.py +1 -0
  7. agent_handshake/adapters/a2a_adapter.py +256 -0
  8. agent_handshake/adapters/base.py +68 -0
  9. agent_handshake/adapters/function_adapter.py +83 -0
  10. agent_handshake/adapters/http_adapter.py +105 -0
  11. agent_handshake/adapters/langchain_adapter.py +94 -0
  12. agent_handshake/cli/__init__.py +1 -0
  13. agent_handshake/cli/diff_cmd.py +132 -0
  14. agent_handshake/cli/infer_cmd.py +140 -0
  15. agent_handshake/cli/init_cmd.py +179 -0
  16. agent_handshake/cli/main.py +100 -0
  17. agent_handshake/cli/pipeline_cmd.py +175 -0
  18. agent_handshake/cli/record_cmd.py +123 -0
  19. agent_handshake/cli/skill_cmd.py +46 -0
  20. agent_handshake/cli/test_cmd.py +279 -0
  21. agent_handshake/config/__init__.py +1 -0
  22. agent_handshake/config/loader.py +63 -0
  23. agent_handshake/config/settings.py +41 -0
  24. agent_handshake/diff/__init__.py +1 -0
  25. agent_handshake/diff/engine.py +129 -0
  26. agent_handshake/diff/report.py +53 -0
  27. agent_handshake/diff/semantic.py +98 -0
  28. agent_handshake/diff/structural.py +96 -0
  29. agent_handshake/exceptions.py +87 -0
  30. agent_handshake/infer/__init__.py +1 -0
  31. agent_handshake/infer/builder.py +155 -0
  32. agent_handshake/infer/check.py +97 -0
  33. agent_handshake/infer/consumer.py +276 -0
  34. agent_handshake/pipeline/__init__.py +1 -0
  35. agent_handshake/pipeline/dag.py +165 -0
  36. agent_handshake/pipeline/executor.py +166 -0
  37. agent_handshake/pipeline/trace.py +68 -0
  38. agent_handshake/policy/__init__.py +1 -0
  39. agent_handshake/policy/builtin.py +59 -0
  40. agent_handshake/policy/custom.py +57 -0
  41. agent_handshake/policy/engine.py +153 -0
  42. agent_handshake/project.py +118 -0
  43. agent_handshake/py.typed +1 -0
  44. agent_handshake/pytest_plugin.py +219 -0
  45. agent_handshake/reporting/__init__.py +1 -0
  46. agent_handshake/reporting/console.py +143 -0
  47. agent_handshake/reporting/github.py +110 -0
  48. agent_handshake/reporting/junit.py +57 -0
  49. agent_handshake/reporting/markdown.py +55 -0
  50. agent_handshake/semantic/__init__.py +1 -0
  51. agent_handshake/semantic/embeddings.py +50 -0
  52. agent_handshake/semantic/engine.py +126 -0
  53. agent_handshake/semantic/similarity.py +31 -0
  54. agent_handshake/skill/SKILL.md +109 -0
  55. agent_handshake/snapshot/__init__.py +1 -0
  56. agent_handshake/snapshot/baseline.py +77 -0
  57. agent_handshake/snapshot/fingerprint.py +56 -0
  58. agent_handshake/snapshot/recorder.py +69 -0
  59. agent_handshake/snapshot/replay.py +50 -0
  60. agent_handshake/snapshot/store.py +199 -0
  61. agent_handshake/spec/__init__.py +1 -0
  62. agent_handshake/spec/checker.py +101 -0
  63. agent_handshake/spec/loader.py +90 -0
  64. agent_handshake/spec/registry.py +50 -0
  65. agent_handshake/spec/schema.py +89 -0
  66. agent_handshake/spec/validator.py +83 -0
  67. agent_handshake-0.7.0.dist-info/METADATA +493 -0
  68. agent_handshake-0.7.0.dist-info/RECORD +71 -0
  69. agent_handshake-0.7.0.dist-info/WHEEL +4 -0
  70. agent_handshake-0.7.0.dist-info/entry_points.txt +5 -0
  71. agent_handshake-0.7.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,74 @@
1
+ """agent-handshake: The open specification for agent-to-agent interactions."""
2
+
3
+ __version__ = "0.7.0"
4
+
5
+ from agent_handshake.adapters.a2a_adapter import A2AAdapter
6
+ from agent_handshake.adapters.base import AgentAdapter, AgentMetadata, AgentResponse
7
+ from agent_handshake.adapters.function_adapter import FunctionAdapter
8
+ from agent_handshake.adapters.http_adapter import HTTPAdapter
9
+ from agent_handshake.adapters.langchain_adapter import LangChainAdapter
10
+ from agent_handshake.config.loader import load_config
11
+ from agent_handshake.config.settings import HandshakeConfig
12
+ from agent_handshake.diff.engine import DiffEngine, DiffResult, DriftSeverity
13
+ from agent_handshake.exceptions import (
14
+ AdapterError,
15
+ ConfigError,
16
+ HandshakeError,
17
+ PipelineExecutionError,
18
+ PolicyViolationError,
19
+ SemanticDriftError,
20
+ SnapshotMismatchError,
21
+ SnapshotNotFoundError,
22
+ SpecValidationError,
23
+ )
24
+ from agent_handshake.pipeline.executor import PipelineExecutor
25
+ from agent_handshake.pipeline.trace import PipelineTrace
26
+ from agent_handshake.snapshot.recorder import Recorder
27
+ from agent_handshake.snapshot.replay import ReplayEngine
28
+ from agent_handshake.snapshot.store import SnapshotStore
29
+ from agent_handshake.spec.loader import load_spec
30
+ from agent_handshake.spec.schema import PolicyRule, SemanticRule, Spec, StructuralSpec
31
+ from agent_handshake.spec.validator import validate_output
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ # Spec
36
+ "Spec",
37
+ "StructuralSpec",
38
+ "SemanticRule",
39
+ "PolicyRule",
40
+ "load_spec",
41
+ "validate_output",
42
+ # Snapshots
43
+ "SnapshotStore",
44
+ "Recorder",
45
+ "ReplayEngine",
46
+ # Diff
47
+ "DiffEngine",
48
+ "DiffResult",
49
+ "DriftSeverity",
50
+ # Pipeline
51
+ "PipelineExecutor",
52
+ "PipelineTrace",
53
+ # Adapters
54
+ "A2AAdapter",
55
+ "AgentAdapter",
56
+ "AgentResponse",
57
+ "AgentMetadata",
58
+ "FunctionAdapter",
59
+ "HTTPAdapter",
60
+ "LangChainAdapter",
61
+ # Config
62
+ "HandshakeConfig",
63
+ "load_config",
64
+ # Exceptions
65
+ "HandshakeError",
66
+ "SpecValidationError",
67
+ "SnapshotNotFoundError",
68
+ "SnapshotMismatchError",
69
+ "SemanticDriftError",
70
+ "PolicyViolationError",
71
+ "PipelineExecutionError",
72
+ "AdapterError",
73
+ "ConfigError",
74
+ ]
@@ -0,0 +1 @@
1
+ """Internal utilities for agent-handshake. Not part of the public API."""
@@ -0,0 +1,23 @@
1
+ """Hashing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+
10
+ def hash_dict(data: dict[str, Any]) -> str:
11
+ """Deterministic SHA256 hash of a dict."""
12
+ content = json.dumps(data, sort_keys=True, ensure_ascii=False)
13
+ return hashlib.sha256(content.encode()).hexdigest()
14
+
15
+
16
+ def short_hash(data: dict[str, Any], length: int = 12) -> str:
17
+ """Short deterministic hash of a dict."""
18
+ return hash_dict(data)[:length]
19
+
20
+
21
+ def hash_string(text: str) -> str:
22
+ """SHA256 hash of a string."""
23
+ return hashlib.sha256(text.encode()).hexdigest()
@@ -0,0 +1,119 @@
1
+ """Sandboxed expression evaluation for pipeline conditions.
2
+
3
+ SECURITY: This uses a restricted AST evaluator, NOT raw eval().
4
+ Only comparison operators, attribute access, and literals are allowed.
5
+ No function calls, imports, or assignments.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import operator
12
+ from typing import Any
13
+
14
+ # Allowed binary comparison operators
15
+ _OPERATORS: dict[type, Any] = {
16
+ ast.Eq: operator.eq,
17
+ ast.NotEq: operator.ne,
18
+ ast.Lt: operator.lt,
19
+ ast.LtE: operator.le,
20
+ ast.Gt: operator.gt,
21
+ ast.GtE: operator.ge,
22
+ }
23
+
24
+ # Allowed boolean operators
25
+ _BOOL_OPS: dict[type, Any] = {
26
+ ast.And: all,
27
+ ast.Or: any,
28
+ }
29
+
30
+
31
+ class _SafeEvaluator(ast.NodeVisitor):
32
+ """Restricted AST evaluator."""
33
+
34
+ def __init__(self, context: dict[str, Any]) -> None:
35
+ self.context = context
36
+
37
+ def evaluate(self, expr: str) -> Any:
38
+ """Evaluate an expression string against the context."""
39
+ tree = ast.parse(expr, mode="eval")
40
+ return self.visit(tree.body)
41
+
42
+ def visit_Compare(self, node: ast.Compare) -> bool:
43
+ """Handle comparison operations (==, !=, <, >, etc.)."""
44
+ left = self.visit(node.left)
45
+ for op_node, comparator in zip(node.ops, node.comparators):
46
+ op_type = type(op_node)
47
+ if op_type not in _OPERATORS:
48
+ raise ValueError(f"Unsupported operator: {op_type.__name__}")
49
+ right = self.visit(comparator)
50
+ if not _OPERATORS[op_type](left, right):
51
+ return False
52
+ left = right
53
+ return True
54
+
55
+ def visit_BoolOp(self, node: ast.BoolOp) -> bool:
56
+ """Handle boolean operations (and, or)."""
57
+ op_type = type(node.op)
58
+ if op_type not in _BOOL_OPS:
59
+ raise ValueError(f"Unsupported bool operator: {op_type.__name__}")
60
+ values = [self.visit(v) for v in node.values]
61
+ return bool(_BOOL_OPS[op_type](values))
62
+
63
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
64
+ """Handle attribute access (e.g., output.category)."""
65
+ obj = self.visit(node.value)
66
+ if isinstance(obj, dict):
67
+ return obj.get(node.attr)
68
+ return getattr(obj, node.attr, None)
69
+
70
+ def visit_Subscript(self, node: ast.Subscript) -> Any:
71
+ """Handle subscript access (e.g., output['key'])."""
72
+ obj = self.visit(node.value)
73
+ key = self.visit(node.slice)
74
+ return obj[key]
75
+
76
+ def visit_Name(self, node: ast.Name) -> Any:
77
+ """Handle variable name resolution."""
78
+ if node.id not in self.context:
79
+ raise ValueError(f"Unknown variable: '{node.id}'")
80
+ return self.context[node.id]
81
+
82
+ def visit_Constant(self, node: ast.Constant) -> Any:
83
+ """Handle literal values (strings, numbers, booleans)."""
84
+ return node.value
85
+
86
+ def visit_List(self, node: ast.List) -> list[Any]:
87
+ """Handle list literals."""
88
+ return [self.visit(el) for el in node.elts]
89
+
90
+ def visit_Tuple(self, node: ast.Tuple) -> tuple[Any, ...]:
91
+ """Handle tuple literals."""
92
+ return tuple(self.visit(el) for el in node.elts)
93
+
94
+ def generic_visit(self, node: ast.AST) -> None:
95
+ """Reject any unsupported AST nodes."""
96
+ raise ValueError(
97
+ f"Unsupported expression element: {type(node).__name__}. "
98
+ f"Only comparisons, attribute access, and literals are allowed."
99
+ )
100
+
101
+
102
+ def safe_evaluate(expression: str, context: dict[str, Any]) -> Any:
103
+ """Safely evaluate an expression against a context dict.
104
+
105
+ Only supports: comparisons, boolean ops, attribute access,
106
+ subscript access, and literals. No function calls or imports.
107
+
108
+ Args:
109
+ expression: The expression string (e.g., "output.category == 'billing'").
110
+ context: Variables available in the expression.
111
+
112
+ Returns:
113
+ The evaluation result.
114
+
115
+ Raises:
116
+ ValueError: If the expression contains unsupported operations.
117
+ """
118
+ evaluator = _SafeEvaluator(context)
119
+ return evaluator.evaluate(expression)
@@ -0,0 +1,14 @@
1
+ """Shared type aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol
6
+
7
+ # Common type aliases used across the codebase
8
+ JsonDict = dict[str, Any]
9
+
10
+
11
+ class ValidatorFn(Protocol):
12
+ """Protocol for custom policy validator functions."""
13
+
14
+ def __call__(self, output: dict[str, Any], input_data: dict[str, Any]) -> tuple[bool, str]: ...
@@ -0,0 +1 @@
1
+ """Agent adapters for wrapping different agent types."""
@@ -0,0 +1,256 @@
1
+ """Adapter for agents that speak the A2A protocol (version 1.0, JSON-RPC)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import hashlib
8
+ import json
9
+ import time
10
+ import uuid
11
+ from typing import Any
12
+ from urllib.parse import urljoin
13
+
14
+ import httpx
15
+
16
+ from agent_handshake.adapters.base import AgentAdapter, AgentMetadata, AgentResponse
17
+ from agent_handshake.exceptions import AdapterError
18
+
19
+ AGENT_CARD_PATH = "/.well-known/agent-card.json"
20
+ PROTOCOL_VERSION = "1.0"
21
+
22
+
23
+ class A2AAdapter(AgentAdapter):
24
+ """Calls an A2A agent through its Agent Card and the JSON-RPC binding.
25
+
26
+ The input dict is sent as one structured ``data`` part, or, with
27
+ ``text_field``, that field's value is sent as a ``text`` part. The reply
28
+ (a Message, or the artifacts of a completed Task) becomes the output
29
+ dict: ``data`` parts holding objects are merged, ``text`` parts are joined
30
+ under ``"text"``, and file parts (``raw`` or ``url``) are listed under
31
+ ``"files"``. A ``url`` file keeps its URL; a ``raw`` file is described by
32
+ its ``size`` and ``sha256`` instead of its bytes, so snapshots stay small.
33
+ With ``json_text=True`` a text-only reply that is a JSON object is used as
34
+ the output directly.
35
+
36
+ Usage:
37
+ adapter = A2AAdapter(
38
+ url="https://triage.example.com", # or the agent card URL
39
+ agent_id="triage-agent",
40
+ )
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ agent_id: str,
47
+ version: str | None = None,
48
+ text_field: str | None = None,
49
+ json_text: bool = False,
50
+ headers: dict[str, str] | None = None,
51
+ timeout: float = 60.0,
52
+ transport: httpx.AsyncBaseTransport | None = None,
53
+ ) -> None:
54
+ self._card_url = url if url.endswith(".json") else url.rstrip("/") + AGENT_CARD_PATH
55
+ self._agent_id = agent_id
56
+ self._version = version
57
+ self._text_field = text_field
58
+ self._json_text = json_text
59
+ self._headers = headers or {}
60
+ self._timeout = timeout
61
+ self._transport = transport
62
+ self._card: dict[str, Any] | None = None
63
+ self._endpoint: tuple[str, str | None] | None = None
64
+
65
+ def get_metadata(self) -> AgentMetadata:
66
+ """Return metadata; the version comes from the Agent Card once fetched."""
67
+ version = self._version or (self._card or {}).get("version") or "unknown"
68
+ return AgentMetadata(agent_id=self._agent_id, version=str(version))
69
+
70
+ async def health_check(self) -> bool:
71
+ """Return True if the Agent Card can be fetched and offers JSON-RPC 1.0."""
72
+ try:
73
+ async with self._client() as client:
74
+ await self._resolve(client)
75
+ except AdapterError:
76
+ return False
77
+ return True
78
+
79
+ async def call(self, input_data: dict[str, Any]) -> AgentResponse:
80
+ """Send the input as an A2A message and return the agent's reply.
81
+
82
+ Raises:
83
+ AdapterError: If the card or endpoint is unusable, the agent returns
84
+ a JSON-RPC error, or the task does not complete.
85
+ """
86
+ start = time.monotonic()
87
+ async with self._client() as client:
88
+ rpc_url, tenant = await self._resolve(client)
89
+ params: dict[str, Any] = {"message": self._message(input_data)}
90
+ if tenant:
91
+ params["tenant"] = tenant
92
+ body = {
93
+ "jsonrpc": "2.0",
94
+ "id": str(uuid.uuid4()),
95
+ "method": "SendMessage",
96
+ "params": params,
97
+ }
98
+ reply = await self._post(client, rpc_url, body)
99
+ latency = (time.monotonic() - start) * 1000
100
+
101
+ if "error" in reply:
102
+ err = reply["error"] or {}
103
+ raise AdapterError(
104
+ self._agent_id, f"A2A error {err.get('code')}: {err.get('message', '')}"
105
+ )
106
+ result = reply.get("result") or {}
107
+ meta: dict[str, Any] = {}
108
+ if "task" in result:
109
+ task = result["task"]
110
+ parts = self._task_parts(task)
111
+ meta = {"task_id": task.get("id"), "context_id": task.get("contextId")}
112
+ elif "message" in result:
113
+ parts = result["message"].get("parts") or []
114
+ meta = {"context_id": result["message"].get("contextId")}
115
+ else:
116
+ raise AdapterError(self._agent_id, "A2A reply has neither 'task' nor 'message'")
117
+
118
+ output, text = self._to_output(parts)
119
+ return AgentResponse(output=output, raw_text=text, latency_ms=latency, metadata=meta)
120
+
121
+ def _client(self) -> httpx.AsyncClient:
122
+ return httpx.AsyncClient(
123
+ timeout=self._timeout,
124
+ headers={**self._headers, "A2A-Version": PROTOCOL_VERSION},
125
+ transport=self._transport,
126
+ )
127
+
128
+ async def _resolve(self, client: httpx.AsyncClient) -> tuple[str, str | None]:
129
+ if self._endpoint is not None:
130
+ return self._endpoint
131
+ try:
132
+ resp = await client.get(self._card_url)
133
+ resp.raise_for_status()
134
+ card = resp.json()
135
+ except (httpx.HTTPError, ValueError) as e:
136
+ raise AdapterError(
137
+ self._agent_id, f"Cannot load agent card {self._card_url}: {e}"
138
+ ) from e
139
+ if not isinstance(card, dict):
140
+ raise AdapterError(self._agent_id, f"Agent card {self._card_url} is not an object")
141
+
142
+ interfaces = card.get("supportedInterfaces") or []
143
+ for iface in interfaces:
144
+ if iface.get("protocolBinding") == "JSONRPC" and str(
145
+ iface.get("protocolVersion", "")
146
+ ).startswith("1."):
147
+ self._card = card
148
+ self._endpoint = (urljoin(self._card_url, iface["url"]), iface.get("tenant"))
149
+ return self._endpoint
150
+
151
+ offered = [
152
+ f"{i.get('protocolBinding')} {i.get('protocolVersion')}" for i in interfaces
153
+ ] or [f"A2A {card.get('protocolVersion', 'unknown')}"]
154
+ raise AdapterError(
155
+ self._agent_id,
156
+ f"Agent card {self._card_url} offers {', '.join(offered)}; "
157
+ f"A2AAdapter needs a JSONRPC interface for A2A {PROTOCOL_VERSION}",
158
+ )
159
+
160
+ async def _post(
161
+ self, client: httpx.AsyncClient, url: str, body: dict[str, Any]
162
+ ) -> dict[str, Any]:
163
+ try:
164
+ resp = await client.post(url, json=body)
165
+ resp.raise_for_status()
166
+ reply = resp.json()
167
+ except (httpx.HTTPError, ValueError) as e:
168
+ raise AdapterError(self._agent_id, f"A2A request to {url} failed: {e}") from e
169
+ if not isinstance(reply, dict):
170
+ raise AdapterError(self._agent_id, "A2A reply is not a JSON-RPC object")
171
+ return reply
172
+
173
+ def _message(self, input_data: dict[str, Any]) -> dict[str, Any]:
174
+ if self._text_field is None:
175
+ part: dict[str, Any] = {"data": input_data, "mediaType": "application/json"}
176
+ else:
177
+ value = input_data.get(self._text_field)
178
+ if not isinstance(value, str):
179
+ raise AdapterError(
180
+ self._agent_id, f"text_field '{self._text_field}' is missing or not a string"
181
+ )
182
+ part = {"text": value}
183
+ return {"messageId": str(uuid.uuid4()), "role": "ROLE_USER", "parts": [part]}
184
+
185
+ def _task_parts(self, task: dict[str, Any]) -> list[dict[str, Any]]:
186
+ status = task.get("status") or {}
187
+ state = status.get("state")
188
+ status_parts = (status.get("message") or {}).get("parts") or []
189
+ if state != "TASK_STATE_COMPLETED":
190
+ detail = " ".join(p["text"] for p in status_parts if "text" in p)
191
+ raise AdapterError(self._agent_id, f"task ended in {state}: {detail}".rstrip(": "))
192
+ parts = [p for a in task.get("artifacts") or [] for p in a.get("parts") or []]
193
+ return parts or status_parts
194
+
195
+ def _to_output(self, parts: list[dict[str, Any]]) -> tuple[dict[str, Any], str | None]:
196
+ if not parts:
197
+ raise AdapterError(self._agent_id, "A2A reply contains no parts")
198
+ output: dict[str, Any] = {}
199
+ texts: list[str] = []
200
+ loose: list[Any] = []
201
+ files: list[dict[str, Any]] = []
202
+ for part in parts:
203
+ if "text" in part:
204
+ texts.append(part["text"])
205
+ elif "data" in part:
206
+ data = part["data"]
207
+ if not isinstance(data, dict):
208
+ loose.append(data)
209
+ continue
210
+ clash = output.keys() & data.keys()
211
+ if clash:
212
+ raise AdapterError(
213
+ self._agent_id, f"data parts disagree on key(s) {sorted(clash)}"
214
+ )
215
+ output.update(data)
216
+ elif "raw" in part or "url" in part:
217
+ files.append(self._file(part))
218
+ else:
219
+ raise AdapterError(self._agent_id, f"unknown part with keys {sorted(part)}")
220
+
221
+ text = "\n".join(texts) if texts else None
222
+ if text is not None and not output and not loose and not files and self._json_text:
223
+ try:
224
+ parsed = json.loads(text)
225
+ except ValueError:
226
+ parsed = None
227
+ if isinstance(parsed, dict):
228
+ return parsed, text
229
+ if loose:
230
+ self._put(output, "data", loose[0] if len(loose) == 1 else loose)
231
+ if text is not None:
232
+ self._put(output, "text", text)
233
+ if files:
234
+ self._put(output, "files", files)
235
+ return output, text
236
+
237
+ def _file(self, part: dict[str, Any]) -> dict[str, Any]:
238
+ entry: dict[str, Any] = {}
239
+ for key in ("filename", "mediaType"):
240
+ if key in part:
241
+ entry[key] = part[key]
242
+ if "url" in part:
243
+ entry["url"] = part["url"]
244
+ return entry
245
+ try:
246
+ content = base64.b64decode(part["raw"], validate=True)
247
+ except (binascii.Error, TypeError, ValueError) as e:
248
+ raise AdapterError(self._agent_id, f"file part has invalid base64 'raw': {e}") from e
249
+ entry["size"] = len(content)
250
+ entry["sha256"] = hashlib.sha256(content).hexdigest()
251
+ return entry
252
+
253
+ def _put(self, output: dict[str, Any], key: str, value: Any) -> None:
254
+ if key in output:
255
+ raise AdapterError(self._agent_id, f"reply already has a '{key}' field")
256
+ output[key] = value
@@ -0,0 +1,68 @@
1
+ """Base adapter interface for wrapping any agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class AgentMetadata:
12
+ """Identification and configuration of an agent."""
13
+
14
+ agent_id: str
15
+ version: str
16
+ model: str | None = None
17
+ prompt_hash: str | None = None
18
+ tools: list[str] = field(default_factory=list)
19
+
20
+
21
+ @dataclass
22
+ class AgentResponse:
23
+ """Structured response from an agent call."""
24
+
25
+ output: dict[str, Any]
26
+ raw_text: str | None = None
27
+ tool_calls: list[dict[str, Any]] | None = None
28
+ token_usage: dict[str, int] | None = None
29
+ latency_ms: float | None = None
30
+ metadata: dict[str, Any] | None = None
31
+
32
+
33
+ class AgentAdapter(ABC):
34
+ """Abstract base class for agent adapters.
35
+
36
+ Implement this to wrap your agent (LangChain, CrewAI, plain function,
37
+ HTTP service, etc.) so agent-handshake can call it during recording and testing.
38
+ """
39
+
40
+ @abstractmethod
41
+ def get_metadata(self) -> AgentMetadata:
42
+ """Return agent identification and configuration.
43
+
44
+ This is called to identify the agent in snapshots and reports.
45
+ The prompt_hash field is particularly important — it allows
46
+ agent-handshake to detect when a prompt has changed and invalidate
47
+ stale snapshots.
48
+ """
49
+ ...
50
+
51
+ @abstractmethod
52
+ async def call(self, input_data: dict[str, Any]) -> AgentResponse:
53
+ """Execute the agent with the given input.
54
+
55
+ Args:
56
+ input_data: Arbitrary dict passed to the agent.
57
+
58
+ Returns:
59
+ AgentResponse with at minimum the output dict populated.
60
+
61
+ Raises:
62
+ AdapterError: If the agent call fails.
63
+ """
64
+ ...
65
+
66
+ async def health_check(self) -> bool:
67
+ """Optional health check. Returns True if agent is reachable."""
68
+ return True
@@ -0,0 +1,83 @@
1
+ """Adapter for plain Python async functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any
9
+
10
+ from agent_handshake.adapters.base import AgentAdapter, AgentMetadata, AgentResponse
11
+ from agent_handshake.exceptions import AdapterError
12
+
13
+
14
+ class FunctionAdapter(AgentAdapter):
15
+ """Wraps an async Python function as an agent adapter.
16
+
17
+ Usage:
18
+ async def my_agent(input_data: dict) -> dict:
19
+ # your agent logic
20
+ return {"category": "billing"}
21
+
22
+ adapter = FunctionAdapter(
23
+ fn=my_agent,
24
+ agent_id="triage-agent",
25
+ version="1.0.0",
26
+ )
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ fn: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
32
+ agent_id: str,
33
+ version: str,
34
+ model: str | None = None,
35
+ prompt_hash: str | None = None,
36
+ ) -> None:
37
+ if not callable(fn):
38
+ raise AdapterError(agent_id, f"Expected callable, got {type(fn).__name__}")
39
+ if not inspect.iscoroutinefunction(fn):
40
+ raise AdapterError(agent_id, "Function must be async (use 'async def')")
41
+
42
+ self._fn = fn
43
+ self._agent_id = agent_id
44
+ self._version = version
45
+ self._model = model
46
+ self._prompt_hash = prompt_hash
47
+
48
+ def get_metadata(self) -> AgentMetadata:
49
+ """Return metadata for this function adapter."""
50
+ return AgentMetadata(
51
+ agent_id=self._agent_id,
52
+ version=self._version,
53
+ model=self._model,
54
+ prompt_hash=self._prompt_hash,
55
+ )
56
+
57
+ async def call(self, input_data: dict[str, Any]) -> AgentResponse:
58
+ """Call the wrapped async function.
59
+
60
+ Args:
61
+ input_data: Input dict to pass to the function.
62
+
63
+ Returns:
64
+ AgentResponse with the function's return value.
65
+
66
+ Raises:
67
+ AdapterError: If the function call fails or returns wrong type.
68
+ """
69
+ start = time.monotonic()
70
+ try:
71
+ result = await self._fn(input_data)
72
+ except Exception as e:
73
+ raise AdapterError(self._agent_id, f"Agent call failed: {e}") from e
74
+
75
+ latency = (time.monotonic() - start) * 1000
76
+
77
+ if not isinstance(result, dict):
78
+ raise AdapterError(
79
+ self._agent_id,
80
+ f"Agent must return dict, got {type(result).__name__}",
81
+ )
82
+
83
+ return AgentResponse(output=result, latency_ms=latency)