yaab-sdk 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.
- yaab/__init__.py +159 -0
- yaab/_core.py +214 -0
- yaab/a2a/__init__.py +14 -0
- yaab/a2a/client.py +170 -0
- yaab/agent.py +330 -0
- yaab/agui.py +205 -0
- yaab/artifacts/__init__.py +54 -0
- yaab/artifacts/manager.py +86 -0
- yaab/auth.py +127 -0
- yaab/batch.py +115 -0
- yaab/cli.py +469 -0
- yaab/config.py +457 -0
- yaab/content.py +143 -0
- yaab/context.py +119 -0
- yaab/deploy.py +411 -0
- yaab/eval/__init__.py +128 -0
- yaab/eval/adapters/__init__.py +4 -0
- yaab/eval/adapters/deepeval.py +74 -0
- yaab/eval/adapters/ragas.py +76 -0
- yaab/exceptions.py +98 -0
- yaab/extensions.py +127 -0
- yaab/governance/__init__.py +147 -0
- yaab/governance/approval.py +89 -0
- yaab/governance/audit.py +155 -0
- yaab/governance/authorization.py +223 -0
- yaab/governance/compliance/__init__.py +59 -0
- yaab/governance/compliance/base.py +88 -0
- yaab/governance/compliance/eu_ai_act.py +83 -0
- yaab/governance/compliance/iso_42001.py +49 -0
- yaab/governance/compliance/nist_ai_rmf.py +62 -0
- yaab/governance/compliance/soc2.py +47 -0
- yaab/governance/compliance/sr_11_7.py +90 -0
- yaab/governance/eval.py +391 -0
- yaab/governance/evalset.py +159 -0
- yaab/governance/guardrails/__init__.py +48 -0
- yaab/governance/guardrails/llm_guard.py +82 -0
- yaab/governance/guardrails/nemo.py +74 -0
- yaab/governance/guardrails/presidio.py +72 -0
- yaab/governance/lifecycle.py +142 -0
- yaab/governance/monitor.py +138 -0
- yaab/governance/policy.py +211 -0
- yaab/governance/registry.py +286 -0
- yaab/governance/service.py +118 -0
- yaab/governance/simulation.py +307 -0
- yaab/graph/__init__.py +53 -0
- yaab/graph/checkpoint.py +191 -0
- yaab/graph/state.py +431 -0
- yaab/limits.py +144 -0
- yaab/memory/__init__.py +155 -0
- yaab/memory/embedders.py +67 -0
- yaab/memory/extraction.py +119 -0
- yaab/memory/manager.py +194 -0
- yaab/models/__init__.py +33 -0
- yaab/models/base.py +75 -0
- yaab/models/instrumented.py +64 -0
- yaab/models/litellm_provider.py +341 -0
- yaab/models/resilient.py +139 -0
- yaab/models/router.py +194 -0
- yaab/models/test_model.py +154 -0
- yaab/multiagent.py +291 -0
- yaab/observability/__init__.py +113 -0
- yaab/observability/sinks.py +109 -0
- yaab/optimize/__init__.py +30 -0
- yaab/optimize/module.py +132 -0
- yaab/optimize/optimizer.py +296 -0
- yaab/optimize/signature.py +70 -0
- yaab/plugins/__init__.py +71 -0
- yaab/plugins/builtins.py +102 -0
- yaab/prompts.py +98 -0
- yaab/py.typed +0 -0
- yaab/rag/__init__.py +75 -0
- yaab/rag/chunking.py +116 -0
- yaab/rag/eval.py +84 -0
- yaab/rag/knowledge.py +168 -0
- yaab/rag/loaders.py +171 -0
- yaab/rag/memory_service.py +92 -0
- yaab/rag/rerank.py +133 -0
- yaab/rag/store.py +226 -0
- yaab/rag/stores_external.py +645 -0
- yaab/rag/types.py +62 -0
- yaab/runner.py +986 -0
- yaab/serve.py +372 -0
- yaab/sessions/__init__.py +56 -0
- yaab/sessions/base.py +38 -0
- yaab/sessions/manager.py +136 -0
- yaab/sessions/memory.py +33 -0
- yaab/sessions/postgres.py +80 -0
- yaab/sessions/redis.py +79 -0
- yaab/sessions/sqlite.py +58 -0
- yaab/skills.py +84 -0
- yaab/state.py +122 -0
- yaab/streaming.py +172 -0
- yaab/testing/__init__.py +8 -0
- yaab/tools/__init__.py +25 -0
- yaab/tools/agent_tool.py +52 -0
- yaab/tools/auth.py +212 -0
- yaab/tools/base.py +186 -0
- yaab/tools/builtin/__init__.py +80 -0
- yaab/tools/builtin/calculator.py +48 -0
- yaab/tools/builtin/code.py +38 -0
- yaab/tools/builtin/datetime_tool.py +19 -0
- yaab/tools/builtin/files.py +122 -0
- yaab/tools/builtin/grounding.py +59 -0
- yaab/tools/builtin/http.py +30 -0
- yaab/tools/builtin/search.py +123 -0
- yaab/tools/builtin/url_context.py +82 -0
- yaab/tools/mcp.py +62 -0
- yaab/tools/mcp_client.py +225 -0
- yaab/tools/mcp_server.py +252 -0
- yaab/tools/openapi.py +336 -0
- yaab/tools/sandbox.py +124 -0
- yaab/types.py +167 -0
- yaab/voice.py +383 -0
- yaab/web.py +383 -0
- yaab_sdk-0.1.0.dist-info/METADATA +533 -0
- yaab_sdk-0.1.0.dist-info/RECORD +119 -0
- yaab_sdk-0.1.0.dist-info/WHEEL +4 -0
- yaab_sdk-0.1.0.dist-info/entry_points.txt +9 -0
- yaab_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
yaab/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""YAAB — Yet Another Agent Builder.
|
|
2
|
+
|
|
3
|
+
A type-safe, governance-first agent SDK with a Rust performance core. Type-safe,
|
|
4
|
+
optimizable, durable, and simple — the best ideas from across the agent
|
|
5
|
+
ecosystem on one runtime, on a universal LiteLLM model layer.
|
|
6
|
+
|
|
7
|
+
Quickstart::
|
|
8
|
+
|
|
9
|
+
from yaab import Agent
|
|
10
|
+
|
|
11
|
+
agent = Agent("assistant", model="openai/gpt-4o", instructions="Be concise.")
|
|
12
|
+
print(agent.run_sync("Say hello").output)
|
|
13
|
+
|
|
14
|
+
Offline (no API key)::
|
|
15
|
+
|
|
16
|
+
from yaab import Agent
|
|
17
|
+
from yaab.testing import TestModel
|
|
18
|
+
|
|
19
|
+
agent = Agent("assistant", model=TestModel("hi!"))
|
|
20
|
+
assert agent.run_sync("hello").output == "hi!"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from . import _core
|
|
26
|
+
from .agent import Agent
|
|
27
|
+
from .artifacts.manager import ArtifactManager
|
|
28
|
+
from .batch import batch_embed, batch_map, batch_run
|
|
29
|
+
from .config import agent_from_dict, agent_from_yaml, runner_from_dict
|
|
30
|
+
from .content import Content, Part, PartKind
|
|
31
|
+
from .context import KeepAll, SummarizeHistory, TruncateMessages
|
|
32
|
+
from .eval import available_metrics, get_metric, register_metric
|
|
33
|
+
from .exceptions import (
|
|
34
|
+
ApprovalRequired,
|
|
35
|
+
GovernanceError,
|
|
36
|
+
MaxStepsExceeded,
|
|
37
|
+
ModelError,
|
|
38
|
+
OutputValidationError,
|
|
39
|
+
PolicyViolation,
|
|
40
|
+
RunCancelled,
|
|
41
|
+
ToolError,
|
|
42
|
+
UsageLimitExceeded,
|
|
43
|
+
YaabError,
|
|
44
|
+
)
|
|
45
|
+
from .extensions import available as available_components
|
|
46
|
+
from .extensions import get as get_component
|
|
47
|
+
from .extensions import register as register_component
|
|
48
|
+
from .governance.eval import ToolTrajectoryMatch
|
|
49
|
+
from .governance.evalset import EvalCase, EvalSet
|
|
50
|
+
from .graph.state import RetryPolicy
|
|
51
|
+
from .limits import CancellationToken, UsageLimits
|
|
52
|
+
from .memory.extraction import MemoryExtractor
|
|
53
|
+
from .memory.manager import MemoryManager
|
|
54
|
+
from .models.router import ModelRouter
|
|
55
|
+
from .multiagent import LoopAgent, MapAgent, ParallelAgent, SequentialAgent, Swarm
|
|
56
|
+
from .prompts import PromptRegistry
|
|
57
|
+
from .rag import Document, KnowledgeBase
|
|
58
|
+
from .rag.memory_service import KnowledgeBaseMemory
|
|
59
|
+
from .runner import Runner
|
|
60
|
+
from .sessions.manager import SessionManager
|
|
61
|
+
from .skills import Skill
|
|
62
|
+
from .state import State
|
|
63
|
+
from .tools import AgentTool, FunctionTool, tool
|
|
64
|
+
from .tools.auth import ToolAuth, ToolAuthRequired, ToolCredential
|
|
65
|
+
from .tools.openapi import OpenAPITool, openapi_toolset
|
|
66
|
+
from .types import Event, EventType, Message, RunContext, RunResult, Usage
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
|
69
|
+
|
|
70
|
+
#: Which performance backend is active: ``"rust"`` or ``"python"``.
|
|
71
|
+
BACKEND = _core.backend()
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"__version__",
|
|
75
|
+
"BACKEND",
|
|
76
|
+
"Agent",
|
|
77
|
+
"Runner",
|
|
78
|
+
"tool",
|
|
79
|
+
"FunctionTool",
|
|
80
|
+
"AgentTool",
|
|
81
|
+
# multi-agent workflow patterns
|
|
82
|
+
"SequentialAgent",
|
|
83
|
+
"ParallelAgent",
|
|
84
|
+
"MapAgent",
|
|
85
|
+
"LoopAgent",
|
|
86
|
+
"Swarm",
|
|
87
|
+
# managers
|
|
88
|
+
"SessionManager",
|
|
89
|
+
"MemoryManager",
|
|
90
|
+
"ArtifactManager",
|
|
91
|
+
"State",
|
|
92
|
+
# extensibility
|
|
93
|
+
"register_component",
|
|
94
|
+
"get_component",
|
|
95
|
+
"available_components",
|
|
96
|
+
# eval metrics (built-in + RAGAS/DeepEval adapters)
|
|
97
|
+
"register_metric",
|
|
98
|
+
"get_metric",
|
|
99
|
+
"available_metrics",
|
|
100
|
+
# reusable building blocks
|
|
101
|
+
"Skill",
|
|
102
|
+
"PromptRegistry",
|
|
103
|
+
# RAG
|
|
104
|
+
"KnowledgeBase",
|
|
105
|
+
"Document",
|
|
106
|
+
# memory intelligence
|
|
107
|
+
"MemoryExtractor",
|
|
108
|
+
"KnowledgeBaseMemory",
|
|
109
|
+
# model intelligence
|
|
110
|
+
"ModelRouter",
|
|
111
|
+
# graph retry policies
|
|
112
|
+
"RetryPolicy",
|
|
113
|
+
# eval depth (portable evalsets + trajectory metric)
|
|
114
|
+
"EvalSet",
|
|
115
|
+
"EvalCase",
|
|
116
|
+
"ToolTrajectoryMatch",
|
|
117
|
+
# OpenAPI toolset
|
|
118
|
+
"openapi_toolset",
|
|
119
|
+
"OpenAPITool",
|
|
120
|
+
# tool-level auth (credentials + OAuth2 consent)
|
|
121
|
+
"ToolAuth",
|
|
122
|
+
"ToolCredential",
|
|
123
|
+
"ToolAuthRequired",
|
|
124
|
+
# run controls
|
|
125
|
+
"UsageLimits",
|
|
126
|
+
"CancellationToken",
|
|
127
|
+
# context-window management
|
|
128
|
+
"TruncateMessages",
|
|
129
|
+
"SummarizeHistory",
|
|
130
|
+
"KeepAll",
|
|
131
|
+
# declarative config
|
|
132
|
+
"agent_from_dict",
|
|
133
|
+
"agent_from_yaml",
|
|
134
|
+
"runner_from_dict",
|
|
135
|
+
# batch / offline inference
|
|
136
|
+
"batch_run",
|
|
137
|
+
"batch_map",
|
|
138
|
+
"batch_embed",
|
|
139
|
+
"RunContext",
|
|
140
|
+
"RunResult",
|
|
141
|
+
"Message",
|
|
142
|
+
"Content",
|
|
143
|
+
"Part",
|
|
144
|
+
"PartKind",
|
|
145
|
+
"Event",
|
|
146
|
+
"EventType",
|
|
147
|
+
"Usage",
|
|
148
|
+
# exceptions
|
|
149
|
+
"YaabError",
|
|
150
|
+
"ModelError",
|
|
151
|
+
"ToolError",
|
|
152
|
+
"OutputValidationError",
|
|
153
|
+
"MaxStepsExceeded",
|
|
154
|
+
"UsageLimitExceeded",
|
|
155
|
+
"RunCancelled",
|
|
156
|
+
"GovernanceError",
|
|
157
|
+
"PolicyViolation",
|
|
158
|
+
"ApprovalRequired",
|
|
159
|
+
]
|
yaab/_core.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Performance-core shim.
|
|
2
|
+
|
|
3
|
+
Prefers the compiled Rust extension (:mod:`yaab_core`) and transparently falls
|
|
4
|
+
back to pure-Python implementations when the wheel is unavailable. The public
|
|
5
|
+
functions here are the *only* way the rest of the SDK touches the hot paths, so
|
|
6
|
+
the Rust/Python split is invisible to callers.
|
|
7
|
+
|
|
8
|
+
Inspect :data:`RUST` to know which backend is active.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Set YAAB_NO_RUST=1 to force the pure-Python fallback (used to keep the
|
|
19
|
+
# fallback path green in CI even when the wheel is installed).
|
|
20
|
+
if os.environ.get("YAAB_NO_RUST") == "1":
|
|
21
|
+
_rust = None
|
|
22
|
+
RUST = False
|
|
23
|
+
else:
|
|
24
|
+
try: # pragma: no cover - exercised by whichever backend is installed
|
|
25
|
+
import yaab_core as _rust
|
|
26
|
+
|
|
27
|
+
RUST = True
|
|
28
|
+
except ImportError: # pragma: no cover
|
|
29
|
+
_rust = None
|
|
30
|
+
RUST = False
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"RUST",
|
|
34
|
+
"backend",
|
|
35
|
+
"cosine_similarity",
|
|
36
|
+
"top_k",
|
|
37
|
+
"encode_checkpoint",
|
|
38
|
+
"decode_checkpoint",
|
|
39
|
+
"reduce_channel",
|
|
40
|
+
"advance_superstep",
|
|
41
|
+
"plan_supersteps",
|
|
42
|
+
"hash_event",
|
|
43
|
+
"verify_chain",
|
|
44
|
+
"aggregate_cost",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_MAGIC = b"YAAB"
|
|
48
|
+
_VERSION = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def backend() -> str:
|
|
52
|
+
"""Return ``"rust"`` or ``"python"`` for diagnostics and tests."""
|
|
53
|
+
return "rust" if RUST else "python"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
|
57
|
+
if RUST:
|
|
58
|
+
return _rust.cosine_similarity(list(a), list(b))
|
|
59
|
+
if len(a) != len(b) or not a:
|
|
60
|
+
return 0.0
|
|
61
|
+
dot = sum(x * y for x, y in zip(a, b, strict=False))
|
|
62
|
+
na = math.sqrt(sum(x * x for x in a))
|
|
63
|
+
nb = math.sqrt(sum(y * y for y in b))
|
|
64
|
+
if na == 0.0 or nb == 0.0:
|
|
65
|
+
return 0.0
|
|
66
|
+
return dot / (na * nb)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def top_k(query: list[float], matrix: list[list[float]], k: int) -> list[tuple[int, float]]:
|
|
70
|
+
if RUST:
|
|
71
|
+
return _rust.top_k(list(query), [list(r) for r in matrix], k)
|
|
72
|
+
scored = [(i, cosine_similarity(query, row)) for i, row in enumerate(matrix)]
|
|
73
|
+
scored.sort(key=lambda t: t[1], reverse=True)
|
|
74
|
+
return scored[:k]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def encode_checkpoint(state: Any) -> bytes:
|
|
78
|
+
"""Serialize a JSON-compatible state object into a framed checkpoint blob."""
|
|
79
|
+
json_str = json.dumps(state, separators=(",", ":"), sort_keys=True)
|
|
80
|
+
if RUST:
|
|
81
|
+
return bytes(_rust.encode_checkpoint(json_str))
|
|
82
|
+
return _MAGIC + bytes([_VERSION]) + json_str.encode("utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decode_checkpoint(blob: bytes) -> Any:
|
|
86
|
+
"""Decode a framed checkpoint blob back into a Python object."""
|
|
87
|
+
if RUST:
|
|
88
|
+
return json.loads(_rust.decode_checkpoint(bytes(blob)))
|
|
89
|
+
if len(blob) < 5 or blob[0:4] != _MAGIC:
|
|
90
|
+
raise ValueError("invalid checkpoint: bad magic header")
|
|
91
|
+
if blob[4] != _VERSION:
|
|
92
|
+
raise ValueError(f"unsupported checkpoint version: {blob[4]}")
|
|
93
|
+
return json.loads(blob[5:].decode("utf-8"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reduce_channel(reducer: str, current: Any, update: Any) -> Any:
|
|
97
|
+
"""Merge ``update`` into ``current`` under the named channel reducer."""
|
|
98
|
+
if RUST:
|
|
99
|
+
out = _rust.reduce_channel(
|
|
100
|
+
reducer,
|
|
101
|
+
json.dumps(current, separators=(",", ":")),
|
|
102
|
+
json.dumps(update, separators=(",", ":")),
|
|
103
|
+
)
|
|
104
|
+
return json.loads(out)
|
|
105
|
+
if reducer == "last_value":
|
|
106
|
+
return update
|
|
107
|
+
if reducer == "append":
|
|
108
|
+
base = (
|
|
109
|
+
list(current) if isinstance(current, list) else ([] if current is None else [current])
|
|
110
|
+
)
|
|
111
|
+
if isinstance(update, list):
|
|
112
|
+
base.extend(update)
|
|
113
|
+
else:
|
|
114
|
+
base.append(update)
|
|
115
|
+
return base
|
|
116
|
+
if reducer == "add":
|
|
117
|
+
a = current if isinstance(current, (int, float)) else 0
|
|
118
|
+
b = update if isinstance(update, (int, float)) else 0
|
|
119
|
+
return a + b
|
|
120
|
+
return update
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def advance_superstep(
|
|
124
|
+
state: dict[str, Any],
|
|
125
|
+
reducers: dict[str, str],
|
|
126
|
+
updates: list[Any],
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
"""Apply a whole superstep's node updates to ``state`` in one operation.
|
|
129
|
+
|
|
130
|
+
``updates`` is a list of per-node update dicts (or ``None``), applied in
|
|
131
|
+
order; each key is reduced with the channel reducer named in ``reducers``
|
|
132
|
+
(defaulting to last-value). The Rust path does the entire fold in one call;
|
|
133
|
+
the pure-Python fallback reuses :func:`reduce_channel`.
|
|
134
|
+
"""
|
|
135
|
+
if RUST:
|
|
136
|
+
out = _rust.advance_superstep(
|
|
137
|
+
json.dumps(state, separators=(",", ":")),
|
|
138
|
+
json.dumps(reducers, separators=(",", ":")),
|
|
139
|
+
json.dumps(updates, separators=(",", ":")),
|
|
140
|
+
)
|
|
141
|
+
return json.loads(out)
|
|
142
|
+
new_state = dict(state)
|
|
143
|
+
for update in updates:
|
|
144
|
+
if not update:
|
|
145
|
+
continue
|
|
146
|
+
for key, value in update.items():
|
|
147
|
+
reducer = reducers.get(key, "last_value")
|
|
148
|
+
new_state[key] = reduce_channel(reducer, new_state.get(key), value)
|
|
149
|
+
return new_state
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def plan_supersteps(nodes: list[str], edges: list[tuple[str, str]]) -> list[list[str]]:
|
|
153
|
+
"""Group nodes into BSP supersteps; trailing cycle members form a final step."""
|
|
154
|
+
if RUST:
|
|
155
|
+
return _rust.plan_supersteps(list(nodes), [tuple(e) for e in edges])
|
|
156
|
+
node_set = set(nodes)
|
|
157
|
+
indegree = {n: 0 for n in nodes}
|
|
158
|
+
adj: dict[str, list[str]] = {n: [] for n in nodes}
|
|
159
|
+
for src, dst in edges:
|
|
160
|
+
if src not in node_set or dst not in node_set or src == dst:
|
|
161
|
+
continue
|
|
162
|
+
adj[src].append(dst)
|
|
163
|
+
indegree[dst] += 1
|
|
164
|
+
layers: list[list[str]] = []
|
|
165
|
+
ready = [n for n, d in indegree.items() if d == 0]
|
|
166
|
+
visited: set[str] = set()
|
|
167
|
+
while ready:
|
|
168
|
+
layer = sorted(n for n in ready if n not in visited)
|
|
169
|
+
visited.update(layer)
|
|
170
|
+
nxt: list[str] = []
|
|
171
|
+
for node in ready:
|
|
172
|
+
for child in adj.get(node, []):
|
|
173
|
+
indegree[child] -= 1
|
|
174
|
+
if indegree[child] == 0 and child not in visited:
|
|
175
|
+
nxt.append(child)
|
|
176
|
+
if layer:
|
|
177
|
+
layers.append(layer)
|
|
178
|
+
ready = nxt
|
|
179
|
+
remaining = sorted(n for n in nodes if n not in visited)
|
|
180
|
+
if remaining:
|
|
181
|
+
layers.append(remaining)
|
|
182
|
+
return layers
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def hash_event(prev_hash: str, payload: str) -> str:
|
|
186
|
+
"""Chained audit hash: ``sha256(prev_hash || payload)``."""
|
|
187
|
+
if RUST:
|
|
188
|
+
return _rust.hash_event(prev_hash, payload)
|
|
189
|
+
import hashlib
|
|
190
|
+
|
|
191
|
+
h = hashlib.sha256()
|
|
192
|
+
h.update(prev_hash.encode("utf-8"))
|
|
193
|
+
h.update(b"\x1f")
|
|
194
|
+
h.update(payload.encode("utf-8"))
|
|
195
|
+
return h.hexdigest()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def verify_chain(genesis: str, entries: list[tuple[str, str]]) -> int | None:
|
|
199
|
+
"""Return the index of the first broken hash-chain link, or ``None``."""
|
|
200
|
+
if RUST:
|
|
201
|
+
return _rust.verify_chain(genesis, [tuple(e) for e in entries])
|
|
202
|
+
prev = genesis
|
|
203
|
+
for i, (payload, recorded) in enumerate(entries):
|
|
204
|
+
expected = hash_event(prev, payload)
|
|
205
|
+
if expected != recorded:
|
|
206
|
+
return i
|
|
207
|
+
prev = expected
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def aggregate_cost(values: list[float]) -> float:
|
|
212
|
+
if RUST:
|
|
213
|
+
return _rust.aggregate_cost([float(v) for v in values])
|
|
214
|
+
return float(sum(values))
|
yaab/a2a/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Agent-to-Agent (A2A) interop.
|
|
2
|
+
|
|
3
|
+
The server side lives in :mod:`yaab.serve` (agent card + ``/a2a/tasks``). This
|
|
4
|
+
package is the *client* side: :class:`RemoteAgent` discovers a remote agent via
|
|
5
|
+
its Agent Card and delegates tasks to it. A ``RemoteAgent`` satisfies both the
|
|
6
|
+
:class:`~yaab.tools.base.Tool` protocol (so it can be handed to a local agent as
|
|
7
|
+
a tool) and the workflow ``run`` surface (so it composes like any agent).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .client import RemoteAgent
|
|
13
|
+
|
|
14
|
+
__all__ = ["RemoteAgent"]
|
yaab/a2a/client.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""A2A client — discover and delegate to a remote agent over HTTP.
|
|
2
|
+
|
|
3
|
+
``httpx`` is an optional dependency, imported lazily. An injectable
|
|
4
|
+
``transport`` callable (``method, path, json -> dict``) keeps the client fully
|
|
5
|
+
testable without a network — the test suite drives a real :func:`fastapi_server_app`
|
|
6
|
+
server through an in-process transport.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..types import RunContext, RunResult, Usage
|
|
15
|
+
|
|
16
|
+
Transport = Callable[[str, str, dict | None], Awaitable[dict]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RemoteAgent:
|
|
20
|
+
"""A handle to a remote A2A agent — usable as a tool or as an agent."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
url: str,
|
|
25
|
+
*,
|
|
26
|
+
name: str | None = None,
|
|
27
|
+
auth_token: str | None = None,
|
|
28
|
+
token_provider: Callable[[], str] | None = None,
|
|
29
|
+
transport: Transport | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.url = url.rstrip("/")
|
|
32
|
+
self.name = name or "remote_agent"
|
|
33
|
+
self.auth_token = auth_token
|
|
34
|
+
# token_provider lets an OAuth2 client supply a fresh bearer token per
|
|
35
|
+
# request (token exchange / refresh handled by the caller's IdP client).
|
|
36
|
+
self.token_provider = token_provider
|
|
37
|
+
self._transport = transport
|
|
38
|
+
self._card: dict[str, Any] | None = None
|
|
39
|
+
|
|
40
|
+
# --- transport -----------------------------------------------------
|
|
41
|
+
def _headers(self) -> dict[str, str]:
|
|
42
|
+
token = self.token_provider() if self.token_provider is not None else self.auth_token
|
|
43
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
44
|
+
|
|
45
|
+
async def _request(self, method: str, path: str, json: dict | None = None) -> dict:
|
|
46
|
+
if self._transport is not None:
|
|
47
|
+
return await self._transport(method, path, json)
|
|
48
|
+
try:
|
|
49
|
+
import httpx
|
|
50
|
+
except ImportError as exc: # pragma: no cover - optional extra
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
"httpx is required for the A2A client. `pip install httpx`."
|
|
53
|
+
) from exc
|
|
54
|
+
async with httpx.AsyncClient() as client:
|
|
55
|
+
resp = await client.request(
|
|
56
|
+
method, f"{self.url}{path}", json=json, headers=self._headers()
|
|
57
|
+
)
|
|
58
|
+
resp.raise_for_status()
|
|
59
|
+
return resp.json()
|
|
60
|
+
|
|
61
|
+
# --- discovery -----------------------------------------------------
|
|
62
|
+
async def fetch_card(self, *, refresh: bool = False) -> dict[str, Any]:
|
|
63
|
+
"""Fetch (and cache) the remote Agent Card."""
|
|
64
|
+
if self._card is None or refresh:
|
|
65
|
+
self._card = await self._request("GET", "/.well-known/agent.json", None)
|
|
66
|
+
if not self._card.get("name"):
|
|
67
|
+
self._card["name"] = self.name
|
|
68
|
+
else:
|
|
69
|
+
self.name = self.name or self._card["name"]
|
|
70
|
+
return self._card
|
|
71
|
+
|
|
72
|
+
# --- delegation ----------------------------------------------------
|
|
73
|
+
async def run(
|
|
74
|
+
self,
|
|
75
|
+
prompt: str,
|
|
76
|
+
*,
|
|
77
|
+
deps: Any = None,
|
|
78
|
+
session_id: str | None = None,
|
|
79
|
+
identity: str | None = None,
|
|
80
|
+
) -> RunResult[str]:
|
|
81
|
+
"""Send the prompt as an A2A task and return the remote agent's output."""
|
|
82
|
+
body = {"message": {"role": "user", "parts": [{"text": prompt}]}}
|
|
83
|
+
task = await self._request("POST", "/a2a/tasks", body)
|
|
84
|
+
text = _extract_text(task)
|
|
85
|
+
return RunResult(output=text, usage=Usage(requests=1), run_id=task.get("id", ""))
|
|
86
|
+
|
|
87
|
+
def run_sync(self, prompt: str, **kwargs: Any) -> RunResult[str]:
|
|
88
|
+
import asyncio
|
|
89
|
+
|
|
90
|
+
return asyncio.run(self.run(prompt, **kwargs))
|
|
91
|
+
|
|
92
|
+
async def get_task(self, task_id: str) -> dict[str, Any]:
|
|
93
|
+
"""Poll a long-running task by id (returns the A2A task object)."""
|
|
94
|
+
return await self._request("GET", f"/a2a/tasks/{task_id}", None)
|
|
95
|
+
|
|
96
|
+
#: A2A task states considered terminal (stop polling).
|
|
97
|
+
TERMINAL_STATES = frozenset({"completed", "failed", "canceled", "cancelled", "rejected"})
|
|
98
|
+
|
|
99
|
+
async def poll_task(
|
|
100
|
+
self,
|
|
101
|
+
task_id: str,
|
|
102
|
+
*,
|
|
103
|
+
interval: float = 1.0,
|
|
104
|
+
timeout: float | None = 60.0,
|
|
105
|
+
terminal_states: set[str] | frozenset[str] | None = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Poll a long-running remote task until it reaches a terminal state.
|
|
108
|
+
|
|
109
|
+
Returns the final task object. Raises :class:`TimeoutError` if the task
|
|
110
|
+
has not finished within ``timeout`` seconds (``None`` = no timeout).
|
|
111
|
+
``interval`` is the delay between polls.
|
|
112
|
+
"""
|
|
113
|
+
import asyncio
|
|
114
|
+
import time
|
|
115
|
+
|
|
116
|
+
terminal = terminal_states or self.TERMINAL_STATES
|
|
117
|
+
started = time.monotonic()
|
|
118
|
+
while True:
|
|
119
|
+
task = await self.get_task(task_id)
|
|
120
|
+
state = (task.get("status") or {}).get("state")
|
|
121
|
+
if state in terminal:
|
|
122
|
+
return task
|
|
123
|
+
if timeout is not None and time.monotonic() - started > timeout:
|
|
124
|
+
raise TimeoutError(
|
|
125
|
+
f"A2A task {task_id!r} did not reach a terminal state within {timeout}s "
|
|
126
|
+
f"(last state: {state!r})"
|
|
127
|
+
)
|
|
128
|
+
if interval:
|
|
129
|
+
await asyncio.sleep(interval)
|
|
130
|
+
|
|
131
|
+
# --- Tool protocol -------------------------------------------------
|
|
132
|
+
@property
|
|
133
|
+
def description(self) -> str:
|
|
134
|
+
if self._card:
|
|
135
|
+
return self._card.get("description", f"Remote agent at {self.url}")
|
|
136
|
+
return f"Remote A2A agent at {self.url}"
|
|
137
|
+
|
|
138
|
+
def schema(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"type": "function",
|
|
141
|
+
"function": {
|
|
142
|
+
"name": self.name,
|
|
143
|
+
"description": self.description,
|
|
144
|
+
"parameters": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"properties": {
|
|
147
|
+
"prompt": {"type": "string", "description": "Task for the remote agent."}
|
|
148
|
+
},
|
|
149
|
+
"required": ["prompt"],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async def execute(self, ctx: RunContext, *, prompt: str) -> Any:
|
|
155
|
+
result = await self.run(prompt, identity=ctx.identity)
|
|
156
|
+
ctx.usage.add(result.usage)
|
|
157
|
+
return result.output
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extract_text(task: dict[str, Any]) -> str:
|
|
161
|
+
"""Pull the text out of an A2A task's artifacts."""
|
|
162
|
+
parts_text: list[str] = []
|
|
163
|
+
for artifact in task.get("artifacts", []):
|
|
164
|
+
for part in artifact.get("parts", []):
|
|
165
|
+
if "text" in part:
|
|
166
|
+
parts_text.append(part["text"])
|
|
167
|
+
return "\n".join(parts_text)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["RemoteAgent"]
|