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.
- agent_handshake/__init__.py +74 -0
- agent_handshake/_internal/__init__.py +1 -0
- agent_handshake/_internal/hashing.py +23 -0
- agent_handshake/_internal/safe_eval.py +119 -0
- agent_handshake/_internal/types.py +14 -0
- agent_handshake/adapters/__init__.py +1 -0
- agent_handshake/adapters/a2a_adapter.py +256 -0
- agent_handshake/adapters/base.py +68 -0
- agent_handshake/adapters/function_adapter.py +83 -0
- agent_handshake/adapters/http_adapter.py +105 -0
- agent_handshake/adapters/langchain_adapter.py +94 -0
- agent_handshake/cli/__init__.py +1 -0
- agent_handshake/cli/diff_cmd.py +132 -0
- agent_handshake/cli/infer_cmd.py +140 -0
- agent_handshake/cli/init_cmd.py +179 -0
- agent_handshake/cli/main.py +100 -0
- agent_handshake/cli/pipeline_cmd.py +175 -0
- agent_handshake/cli/record_cmd.py +123 -0
- agent_handshake/cli/skill_cmd.py +46 -0
- agent_handshake/cli/test_cmd.py +279 -0
- agent_handshake/config/__init__.py +1 -0
- agent_handshake/config/loader.py +63 -0
- agent_handshake/config/settings.py +41 -0
- agent_handshake/diff/__init__.py +1 -0
- agent_handshake/diff/engine.py +129 -0
- agent_handshake/diff/report.py +53 -0
- agent_handshake/diff/semantic.py +98 -0
- agent_handshake/diff/structural.py +96 -0
- agent_handshake/exceptions.py +87 -0
- agent_handshake/infer/__init__.py +1 -0
- agent_handshake/infer/builder.py +155 -0
- agent_handshake/infer/check.py +97 -0
- agent_handshake/infer/consumer.py +276 -0
- agent_handshake/pipeline/__init__.py +1 -0
- agent_handshake/pipeline/dag.py +165 -0
- agent_handshake/pipeline/executor.py +166 -0
- agent_handshake/pipeline/trace.py +68 -0
- agent_handshake/policy/__init__.py +1 -0
- agent_handshake/policy/builtin.py +59 -0
- agent_handshake/policy/custom.py +57 -0
- agent_handshake/policy/engine.py +153 -0
- agent_handshake/project.py +118 -0
- agent_handshake/py.typed +1 -0
- agent_handshake/pytest_plugin.py +219 -0
- agent_handshake/reporting/__init__.py +1 -0
- agent_handshake/reporting/console.py +143 -0
- agent_handshake/reporting/github.py +110 -0
- agent_handshake/reporting/junit.py +57 -0
- agent_handshake/reporting/markdown.py +55 -0
- agent_handshake/semantic/__init__.py +1 -0
- agent_handshake/semantic/embeddings.py +50 -0
- agent_handshake/semantic/engine.py +126 -0
- agent_handshake/semantic/similarity.py +31 -0
- agent_handshake/skill/SKILL.md +109 -0
- agent_handshake/snapshot/__init__.py +1 -0
- agent_handshake/snapshot/baseline.py +77 -0
- agent_handshake/snapshot/fingerprint.py +56 -0
- agent_handshake/snapshot/recorder.py +69 -0
- agent_handshake/snapshot/replay.py +50 -0
- agent_handshake/snapshot/store.py +199 -0
- agent_handshake/spec/__init__.py +1 -0
- agent_handshake/spec/checker.py +101 -0
- agent_handshake/spec/loader.py +90 -0
- agent_handshake/spec/registry.py +50 -0
- agent_handshake/spec/schema.py +89 -0
- agent_handshake/spec/validator.py +83 -0
- agent_handshake-0.7.0.dist-info/METADATA +493 -0
- agent_handshake-0.7.0.dist-info/RECORD +71 -0
- agent_handshake-0.7.0.dist-info/WHEEL +4 -0
- agent_handshake-0.7.0.dist-info/entry_points.txt +5 -0
- 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)
|