babelagent 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.
- babelagent/__init__.py +87 -0
- babelagent/__main__.py +6 -0
- babelagent/adapters/__init__.py +22 -0
- babelagent/adapters/a2a_agent.py +143 -0
- babelagent/adapters/auto.py +137 -0
- babelagent/adapters/base.py +104 -0
- babelagent/adapters/callable_agent.py +52 -0
- babelagent/adapters/frameworks/__init__.py +81 -0
- babelagent/adapters/http_agent.py +278 -0
- babelagent/adapters/llm_agent.py +117 -0
- babelagent/adapters/mcp_agent.py +57 -0
- babelagent/config.py +42 -0
- babelagent/core/__init__.py +47 -0
- babelagent/core/agent.py +74 -0
- babelagent/core/errors.py +39 -0
- babelagent/core/grade.py +85 -0
- babelagent/core/graph.py +164 -0
- babelagent/core/message.py +47 -0
- babelagent/core/node.py +50 -0
- babelagent/core/scheduler.py +252 -0
- babelagent/core/topology.py +88 -0
- babelagent/io/__init__.py +3 -0
- babelagent/io/_demo_assets.py +45 -0
- babelagent/io/cli.py +104 -0
- babelagent/io/doctor.py +52 -0
- babelagent/io/mcp.py +68 -0
- babelagent/io/rest.py +233 -0
- babelagent/py.typed +0 -0
- babelagent-0.1.0.dist-info/METADATA +135 -0
- babelagent-0.1.0.dist-info/RECORD +33 -0
- babelagent-0.1.0.dist-info/WHEEL +4 -0
- babelagent-0.1.0.dist-info/entry_points.txt +2 -0
- babelagent-0.1.0.dist-info/licenses/LICENSE +21 -0
babelagent/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Babelagent — the Babel that lets AI agents understand each other.
|
|
2
|
+
|
|
3
|
+
A neutral layer that gives heterogeneous agents (plain callables, HTTP/OpenAPI
|
|
4
|
+
endpoints, MCP tools, framework agents, or LLMs) one shared tongue, so they can
|
|
5
|
+
exchange messages and collaborate on a task, agent-to-agent, and be graded at
|
|
6
|
+
each hop.
|
|
7
|
+
|
|
8
|
+
from babelagent import Graph, adapt
|
|
9
|
+
|
|
10
|
+
graph = Graph().node("shout", str.upper)
|
|
11
|
+
result = await graph.run("hello") # result.output == "HELLO"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .core import (
|
|
19
|
+
Agent,
|
|
20
|
+
BarrierKind,
|
|
21
|
+
BarrierPolicy,
|
|
22
|
+
CompiledGraph,
|
|
23
|
+
Context,
|
|
24
|
+
GateMode,
|
|
25
|
+
Grade,
|
|
26
|
+
Graph,
|
|
27
|
+
IdentityAgent,
|
|
28
|
+
Message,
|
|
29
|
+
Node,
|
|
30
|
+
Result,
|
|
31
|
+
Topology,
|
|
32
|
+
Verdict,
|
|
33
|
+
is_agent,
|
|
34
|
+
)
|
|
35
|
+
from .core.errors import (
|
|
36
|
+
AdapterError,
|
|
37
|
+
BabelagentError,
|
|
38
|
+
CycleError,
|
|
39
|
+
MissingDependencyError,
|
|
40
|
+
TopologyError,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"__version__",
|
|
47
|
+
"Graph",
|
|
48
|
+
"CompiledGraph",
|
|
49
|
+
"Message",
|
|
50
|
+
"Result",
|
|
51
|
+
"Context",
|
|
52
|
+
"Agent",
|
|
53
|
+
"IdentityAgent",
|
|
54
|
+
"is_agent",
|
|
55
|
+
"Node",
|
|
56
|
+
"Topology",
|
|
57
|
+
"BarrierKind",
|
|
58
|
+
"BarrierPolicy",
|
|
59
|
+
"GateMode",
|
|
60
|
+
"Grade",
|
|
61
|
+
"Verdict",
|
|
62
|
+
"adapt",
|
|
63
|
+
"register_adapter",
|
|
64
|
+
"A2ARef",
|
|
65
|
+
"McpRef",
|
|
66
|
+
"BabelagentError",
|
|
67
|
+
"AdapterError",
|
|
68
|
+
"CycleError",
|
|
69
|
+
"TopologyError",
|
|
70
|
+
"MissingDependencyError",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# adapt() and the adapter refs live in the adapters package; expose lazily so
|
|
74
|
+
# importing babelagent never eagerly pulls optional adapter dependencies.
|
|
75
|
+
_LAZY = {"adapt", "register_adapter", "A2ARef", "McpRef"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def __getattr__(name: str) -> Any:
|
|
79
|
+
if name in _LAZY:
|
|
80
|
+
from . import adapters
|
|
81
|
+
|
|
82
|
+
return getattr(adapters, name)
|
|
83
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __dir__() -> list[str]:
|
|
87
|
+
return sorted(__all__)
|
babelagent/__main__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Babelagent adapters: normalize anything a user brings into a uniform Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .a2a_agent import A2AAgent, A2ARef
|
|
6
|
+
from .auto import adapt, register_adapter
|
|
7
|
+
from .callable_agent import CallableAgent
|
|
8
|
+
from .http_agent import HttpAgent
|
|
9
|
+
from .llm_agent import LLM
|
|
10
|
+
from .mcp_agent import McpAgent, McpRef
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"adapt",
|
|
14
|
+
"register_adapter",
|
|
15
|
+
"A2AAgent",
|
|
16
|
+
"A2ARef",
|
|
17
|
+
"CallableAgent",
|
|
18
|
+
"HttpAgent",
|
|
19
|
+
"LLM",
|
|
20
|
+
"McpAgent",
|
|
21
|
+
"McpRef",
|
|
22
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Adapt a remote Agent2Agent (A2A) agent as a node in the graph.
|
|
2
|
+
|
|
3
|
+
A2A is a wire protocol for (usually remote) agents to talk over JSON-RPC/HTTP.
|
|
4
|
+
Babelagent treats such an agent as *just another Agent*: point at its base URL
|
|
5
|
+
and it becomes a node that other agents can hand messages to. Implemented on
|
|
6
|
+
httpx (a base dependency), so no extra is required; the same SSRF guard as the
|
|
7
|
+
HTTP adapter applies.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ..core.agent import Context
|
|
19
|
+
from ..core.errors import AdapterError
|
|
20
|
+
from ..core.message import Message
|
|
21
|
+
from .http_agent import MAX_RESPONSE_BYTES, _guarded_client, _read_capped, guard_url
|
|
22
|
+
|
|
23
|
+
# Agent Card discovery paths, newest first (the A2A spec renamed the file).
|
|
24
|
+
_CARD_PATHS = ("/.well-known/agent-card.json", "/.well-known/agent.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class A2ARef:
|
|
29
|
+
"""A reference to a remote A2A agent by base URL (recognized by ``adapt()``)."""
|
|
30
|
+
|
|
31
|
+
url: str
|
|
32
|
+
name: str | None = None
|
|
33
|
+
allow_private: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class A2AAgent:
|
|
37
|
+
"""Calls a remote A2A agent via the ``message/send`` JSON-RPC method."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
url: str,
|
|
42
|
+
*,
|
|
43
|
+
name: str | None = None,
|
|
44
|
+
timeout: float = 60.0,
|
|
45
|
+
allow_private: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.url = guard_url(url, allow_private=allow_private)
|
|
48
|
+
self.name = name or f"a2a:{httpx.URL(url).host}"
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.allow_private = allow_private
|
|
51
|
+
|
|
52
|
+
async def run(self, message: Message, ctx: Context) -> Message:
|
|
53
|
+
text = message.payload if isinstance(message.payload, str) else str(message.payload)
|
|
54
|
+
request = {
|
|
55
|
+
"jsonrpc": "2.0",
|
|
56
|
+
"id": uuid.uuid4().hex,
|
|
57
|
+
"method": "message/send",
|
|
58
|
+
"params": {
|
|
59
|
+
"message": {
|
|
60
|
+
"role": "user",
|
|
61
|
+
"parts": [{"kind": "text", "text": text}],
|
|
62
|
+
"messageId": uuid.uuid4().hex,
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
guard_url(self.url, allow_private=self.allow_private) # re-validate (TOCTOU)
|
|
67
|
+
async with _guarded_client(allow_private=self.allow_private, timeout=self.timeout) as client:
|
|
68
|
+
async with client.stream("POST", self.url, json=request) as resp:
|
|
69
|
+
resp.raise_for_status()
|
|
70
|
+
raw = await _read_capped(resp)
|
|
71
|
+
try:
|
|
72
|
+
import json
|
|
73
|
+
|
|
74
|
+
body = json.loads(raw)
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise AdapterError("A2A response was not valid JSON") from exc
|
|
77
|
+
if not isinstance(body, dict):
|
|
78
|
+
raise AdapterError("A2A response was not a JSON object")
|
|
79
|
+
if body.get("error"):
|
|
80
|
+
raise AdapterError(f"A2A agent returned error: {body['error']}")
|
|
81
|
+
out = _extract_text(body.get("result", body))
|
|
82
|
+
return message.with_payload(out, node=self.name)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
async def from_card(
|
|
86
|
+
cls, base_url: str, *, allow_private: bool = False, **kwargs: Any
|
|
87
|
+
) -> A2AAgent:
|
|
88
|
+
"""Discover an agent's card to resolve its service URL and name."""
|
|
89
|
+
card = await _fetch_agent_card(base_url, allow_private=allow_private)
|
|
90
|
+
service_url = card.get("url") or base_url
|
|
91
|
+
name = kwargs.pop("name", None) or card.get("name")
|
|
92
|
+
return cls(service_url, name=name, allow_private=allow_private, **kwargs)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _extract_text(result: Any) -> Any:
|
|
96
|
+
"""Pull text out of an A2A Message or Task result, tolerant of shape drift."""
|
|
97
|
+
if not isinstance(result, dict):
|
|
98
|
+
return result
|
|
99
|
+
# A direct Message: {"parts": [...]}
|
|
100
|
+
if "parts" in result:
|
|
101
|
+
return _join_parts(result["parts"])
|
|
102
|
+
# A Task: prefer artifacts, then the status message.
|
|
103
|
+
texts: list[str] = []
|
|
104
|
+
for artifact in result.get("artifacts") or []:
|
|
105
|
+
if isinstance(artifact, dict):
|
|
106
|
+
texts.append(_join_parts(artifact.get("parts") or []))
|
|
107
|
+
status = result.get("status") or {}
|
|
108
|
+
status_msg = status.get("message") if isinstance(status, dict) else None
|
|
109
|
+
if isinstance(status_msg, dict):
|
|
110
|
+
texts.append(_join_parts(status_msg.get("parts") or []))
|
|
111
|
+
joined = "\n".join(t for t in texts if t)
|
|
112
|
+
return joined or result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _join_parts(parts: Any) -> str:
|
|
116
|
+
if not isinstance(parts, list):
|
|
117
|
+
return ""
|
|
118
|
+
out: list[str] = []
|
|
119
|
+
for part in parts:
|
|
120
|
+
if isinstance(part, dict) and "text" in part:
|
|
121
|
+
out.append(str(part["text"]))
|
|
122
|
+
return "".join(out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _fetch_agent_card(base_url: str, *, allow_private: bool) -> dict[str, Any]:
|
|
126
|
+
base = base_url.rstrip("/")
|
|
127
|
+
async with _guarded_client(allow_private=allow_private, timeout=15.0) as client:
|
|
128
|
+
for path in _CARD_PATHS:
|
|
129
|
+
url = guard_url(base + path, allow_private=allow_private)
|
|
130
|
+
try:
|
|
131
|
+
async with client.stream("GET", url) as resp:
|
|
132
|
+
if resp.status_code != 200:
|
|
133
|
+
continue
|
|
134
|
+
raw = await _read_capped(resp, cap=MAX_RESPONSE_BYTES)
|
|
135
|
+
except httpx.HTTPError:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
import json
|
|
139
|
+
|
|
140
|
+
return json.loads(raw)
|
|
141
|
+
except ValueError:
|
|
142
|
+
continue
|
|
143
|
+
raise AdapterError(f"no A2A agent card found under {base_url!r}")
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""``adapt()`` — the on-the-fly adapter creator.
|
|
2
|
+
|
|
3
|
+
Hand it anything a user brought (a callable, an HTTP/OpenAPI URL, an MCP ref, a
|
|
4
|
+
framework agent, or an already-conforming Agent) and it returns a uniform
|
|
5
|
+
:class:`~babelagent.core.agent.Agent`. Extensible: third parties register their
|
|
6
|
+
own matchers via :func:`register_adapter` or the ``babelagent.adapters``
|
|
7
|
+
entry-point group.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..core.agent import Agent, is_agent
|
|
18
|
+
from ..core.errors import AdapterError
|
|
19
|
+
|
|
20
|
+
# A matcher decides whether it can handle *obj*; a builder turns it into an Agent.
|
|
21
|
+
Matcher = Callable[[Any], bool]
|
|
22
|
+
Builder = Callable[..., Agent]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class _Registration:
|
|
27
|
+
name: str
|
|
28
|
+
matches: Matcher
|
|
29
|
+
build: Builder
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_REGISTRY: list[_Registration] = []
|
|
33
|
+
_ENTRYPOINTS_LOADED = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register_adapter(name: str, matches: Matcher, build: Builder) -> None:
|
|
37
|
+
"""Register a custom adapter. Later registrations take precedence."""
|
|
38
|
+
_REGISTRY.insert(0, _Registration(name=name, matches=matches, build=build))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_entrypoint_adapters() -> None:
|
|
42
|
+
global _ENTRYPOINTS_LOADED
|
|
43
|
+
if _ENTRYPOINTS_LOADED:
|
|
44
|
+
return
|
|
45
|
+
_ENTRYPOINTS_LOADED = True
|
|
46
|
+
# Discovery imports (runs) code from any installed `babelagent.adapters`
|
|
47
|
+
# plugin. Locked-down deployments can disable it. Trust your dependency set.
|
|
48
|
+
if os.environ.get("BABELAGENT_NO_PLUGINS"):
|
|
49
|
+
return
|
|
50
|
+
try:
|
|
51
|
+
from importlib.metadata import entry_points
|
|
52
|
+
|
|
53
|
+
eps = entry_points(group="babelagent.adapters")
|
|
54
|
+
except Exception: # noqa: BLE001 — discovery is best-effort
|
|
55
|
+
return
|
|
56
|
+
for ep in eps:
|
|
57
|
+
try:
|
|
58
|
+
obj = ep.load()
|
|
59
|
+
except Exception: # noqa: BLE001 — a broken plugin must not break adapt()
|
|
60
|
+
continue
|
|
61
|
+
if hasattr(obj, "matches") and hasattr(obj, "build"):
|
|
62
|
+
register_adapter(ep.name, obj.matches, obj.build)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _string_looks_http(value: str) -> bool:
|
|
66
|
+
return value.startswith(("http://", "https://"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _string_looks_openapi(value: str) -> bool:
|
|
70
|
+
low = value.lower()
|
|
71
|
+
return low.endswith(".json") or "openapi" in low or "swagger" in low
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def adapt(obj: Any, *, name: str | None = None, **hints: Any) -> Agent:
|
|
75
|
+
"""Return an :class:`Agent` for *obj*, inferring the right adapter.
|
|
76
|
+
|
|
77
|
+
Resolution order: already-an-Agent → custom/entry-point registrations →
|
|
78
|
+
MCP ref → framework agent → HTTP/OpenAPI → plain callable.
|
|
79
|
+
"""
|
|
80
|
+
_load_entrypoint_adapters()
|
|
81
|
+
|
|
82
|
+
if is_agent(obj):
|
|
83
|
+
return obj # type: ignore[return-value]
|
|
84
|
+
|
|
85
|
+
# Custom + entry-point adapters get first crack (most specific wins).
|
|
86
|
+
for reg in _REGISTRY:
|
|
87
|
+
try:
|
|
88
|
+
if reg.matches(obj):
|
|
89
|
+
return reg.build(obj, name=name, **hints)
|
|
90
|
+
except Exception as exc: # noqa: BLE001
|
|
91
|
+
raise AdapterError(f"adapter {reg.name!r} failed on object: {exc}") from exc
|
|
92
|
+
|
|
93
|
+
# MCP reference.
|
|
94
|
+
from .mcp_agent import McpAgent, McpRef
|
|
95
|
+
|
|
96
|
+
if isinstance(obj, McpRef):
|
|
97
|
+
return McpAgent(obj)
|
|
98
|
+
|
|
99
|
+
# Remote A2A (Agent2Agent) reference.
|
|
100
|
+
from .a2a_agent import A2AAgent, A2ARef
|
|
101
|
+
|
|
102
|
+
if isinstance(obj, A2ARef):
|
|
103
|
+
return A2AAgent(
|
|
104
|
+
obj.url, name=name or obj.name, allow_private=obj.allow_private, **hints
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Framework agents (detected without importing the frameworks).
|
|
108
|
+
from . import frameworks as fw
|
|
109
|
+
|
|
110
|
+
if fw.looks_like_langchain(obj):
|
|
111
|
+
return fw.LangChainAgent(obj, name=name)
|
|
112
|
+
if fw.looks_like_crewai(obj):
|
|
113
|
+
return fw.CrewAgent(obj, name=name)
|
|
114
|
+
if fw.looks_like_autogen(obj):
|
|
115
|
+
return fw.AutoGenAgent(obj, name=name)
|
|
116
|
+
|
|
117
|
+
# HTTP / OpenAPI endpoints.
|
|
118
|
+
from .http_agent import HttpAgent
|
|
119
|
+
|
|
120
|
+
if isinstance(obj, str) and _string_looks_http(obj):
|
|
121
|
+
if _string_looks_openapi(obj):
|
|
122
|
+
return HttpAgent.from_openapi(obj, name=name, **hints)
|
|
123
|
+
return HttpAgent(obj, name=name, **hints)
|
|
124
|
+
if isinstance(obj, dict) and ("openapi" in obj or "swagger" in obj):
|
|
125
|
+
return HttpAgent.from_openapi(obj, name=name, **hints)
|
|
126
|
+
|
|
127
|
+
# Plain callable — the universal fallback.
|
|
128
|
+
if callable(obj):
|
|
129
|
+
from .callable_agent import CallableAgent
|
|
130
|
+
|
|
131
|
+
return CallableAgent(obj, name=name)
|
|
132
|
+
|
|
133
|
+
raise AdapterError(
|
|
134
|
+
f"don't know how to adapt {type(obj).__name__}; bring an Agent, a callable, "
|
|
135
|
+
f"an http(s) URL, an McpRef, a framework agent, or register a custom adapter "
|
|
136
|
+
f"with babelagent.register_adapter(...)."
|
|
137
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared helpers for adapters: signature binding and I/O schema inference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bind_payload(payload: Any, sig: inspect.Signature | None) -> tuple[tuple, dict]:
|
|
11
|
+
"""Decide how to pass a message's *payload* into a callable.
|
|
12
|
+
|
|
13
|
+
Deterministic rules:
|
|
14
|
+
|
|
15
|
+
* 0 or 1 parameter → ``fn(payload)``
|
|
16
|
+
* dict payload whose keys EXACTLY match the callable's *required* params
|
|
17
|
+
→ ``fn(**payload)``
|
|
18
|
+
* list/tuple payload with a multi-arg callable → ``fn(*payload)``
|
|
19
|
+
* otherwise → ``fn(payload)``
|
|
20
|
+
|
|
21
|
+
Security note: a payload can come from an untrusted upstream node (e.g. a
|
|
22
|
+
remote A2A/HTTP agent's output). To prevent that output from injecting
|
|
23
|
+
keyword arguments into optional / keyword-only "flag" parameters
|
|
24
|
+
(``admin=True``, ``dry_run=False``, ...) or wholesale into ``**kwargs``, the
|
|
25
|
+
dict-spread fires ONLY when the payload keys are exactly the callable's
|
|
26
|
+
required parameters. Anything else falls back to a single positional arg.
|
|
27
|
+
"""
|
|
28
|
+
if sig is None:
|
|
29
|
+
return (payload,), {}
|
|
30
|
+
|
|
31
|
+
params = [
|
|
32
|
+
p
|
|
33
|
+
for p in sig.parameters.values()
|
|
34
|
+
if p.kind
|
|
35
|
+
in (
|
|
36
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
37
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
38
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
39
|
+
)
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
if len(params) <= 1:
|
|
43
|
+
return (payload,), {}
|
|
44
|
+
|
|
45
|
+
if isinstance(payload, dict):
|
|
46
|
+
required = {
|
|
47
|
+
p.name
|
|
48
|
+
for p in params
|
|
49
|
+
if p.default is inspect.Parameter.empty
|
|
50
|
+
and p.kind is not inspect.Parameter.POSITIONAL_ONLY
|
|
51
|
+
}
|
|
52
|
+
if required and set(payload) == required:
|
|
53
|
+
return (), dict(payload)
|
|
54
|
+
|
|
55
|
+
if isinstance(payload, (list, tuple)):
|
|
56
|
+
# Same discipline for positional splat: only when the item count exactly
|
|
57
|
+
# fills the required positional params (and there is no ``*args`` to soak
|
|
58
|
+
# up extras), so an untrusted list cannot positionally set a flag arg.
|
|
59
|
+
req_positional = [
|
|
60
|
+
p
|
|
61
|
+
for p in params
|
|
62
|
+
if p.default is inspect.Parameter.empty
|
|
63
|
+
and p.kind
|
|
64
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
65
|
+
]
|
|
66
|
+
has_var_positional = any(
|
|
67
|
+
p.kind is inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values()
|
|
68
|
+
)
|
|
69
|
+
if not has_var_positional and len(payload) == len(req_positional):
|
|
70
|
+
return tuple(payload), {}
|
|
71
|
+
|
|
72
|
+
return (payload,), {}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def infer_io_schema(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
76
|
+
"""Best-effort JSON-schema hints for a callable's inputs and output.
|
|
77
|
+
|
|
78
|
+
Never raises; returns ``{}`` for anything it cannot introspect. Used purely
|
|
79
|
+
for ``babelagent inspect`` / documentation, never for execution.
|
|
80
|
+
"""
|
|
81
|
+
schema: dict[str, Any] = {"inputs": {}, "output": None}
|
|
82
|
+
try:
|
|
83
|
+
sig = inspect.signature(fn)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
from pydantic import TypeAdapter
|
|
89
|
+
|
|
90
|
+
for pname, p in sig.parameters.items():
|
|
91
|
+
if p.annotation is inspect.Parameter.empty:
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
schema["inputs"][pname] = TypeAdapter(p.annotation).json_schema()
|
|
95
|
+
except Exception: # noqa: BLE001 — schema hints are best-effort only
|
|
96
|
+
schema["inputs"][pname] = {"type": "unknown"}
|
|
97
|
+
if sig.return_annotation is not inspect.Signature.empty:
|
|
98
|
+
try:
|
|
99
|
+
schema["output"] = TypeAdapter(sig.return_annotation).json_schema()
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
schema["output"] = {"type": "unknown"}
|
|
102
|
+
except Exception: # noqa: BLE001 — pydantic missing or annotation unresolvable
|
|
103
|
+
return schema
|
|
104
|
+
return schema
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Wrap any Python callable as an :class:`~babelagent.core.agent.Agent`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..core.agent import Context
|
|
11
|
+
from ..core.message import Message
|
|
12
|
+
from .base import bind_payload, infer_io_schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CallableAgent:
|
|
16
|
+
"""Adapts a plain callable into a graph agent.
|
|
17
|
+
|
|
18
|
+
Sync callables run in a worker thread so they never block the event loop.
|
|
19
|
+
The callable's return value becomes the next message's payload (unless it
|
|
20
|
+
already returns a :class:`Message`).
|
|
21
|
+
|
|
22
|
+
Timeout caveat (residual): a node ``timeout_s`` / run ``deadline_s`` cancels
|
|
23
|
+
the awaiting coroutine, but Python cannot force-kill the worker thread, so a
|
|
24
|
+
*blocked* sync callable keeps running until it returns on its own (the run
|
|
25
|
+
still reports the timeout). For untrusted or possibly-blocking work, prefer
|
|
26
|
+
an async agent or an out-of-process agent, which can be interrupted.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, fn: Callable[..., Any], *, name: str | None = None) -> None:
|
|
30
|
+
if not callable(fn):
|
|
31
|
+
raise TypeError(f"CallableAgent needs a callable, got {type(fn).__name__}")
|
|
32
|
+
self.fn = fn
|
|
33
|
+
self.name = name or getattr(fn, "__name__", None) or type(fn).__name__
|
|
34
|
+
dunder_call = getattr(type(fn), "__call__", None) # noqa: B004 — async detection, not a callability test
|
|
35
|
+
self._is_async = inspect.iscoroutinefunction(fn) or inspect.iscoroutinefunction(
|
|
36
|
+
dunder_call
|
|
37
|
+
)
|
|
38
|
+
try:
|
|
39
|
+
self._sig: inspect.Signature | None = inspect.signature(fn)
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
self._sig = None
|
|
42
|
+
self.io_schema = infer_io_schema(fn)
|
|
43
|
+
|
|
44
|
+
async def run(self, message: Message, ctx: Context) -> Message:
|
|
45
|
+
args, kwargs = bind_payload(message.payload, self._sig)
|
|
46
|
+
if self._is_async:
|
|
47
|
+
out = await self.fn(*args, **kwargs)
|
|
48
|
+
else:
|
|
49
|
+
out = await asyncio.to_thread(self.fn, *args, **kwargs)
|
|
50
|
+
if isinstance(out, Message):
|
|
51
|
+
return out
|
|
52
|
+
return message.with_payload(out, node=self.name)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Adapters for third-party agent frameworks (LangChain, CrewAI, AutoGen).
|
|
2
|
+
|
|
3
|
+
Detection is by the wrapped object's module/attributes, so importing this
|
|
4
|
+
package never imports the frameworks themselves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ...core.agent import Context
|
|
12
|
+
from ...core.message import Message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def looks_like_langchain(obj: Any) -> bool:
|
|
16
|
+
mod = type(obj).__module__ or ""
|
|
17
|
+
return mod.startswith(("langchain", "langgraph")) and hasattr(obj, "invoke")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def looks_like_crewai(obj: Any) -> bool:
|
|
21
|
+
mod = type(obj).__module__ or ""
|
|
22
|
+
return mod.startswith("crewai") and hasattr(obj, "kickoff")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def looks_like_autogen(obj: Any) -> bool:
|
|
26
|
+
mod = type(obj).__module__ or ""
|
|
27
|
+
return mod.startswith(("autogen", "pyautogen")) and (
|
|
28
|
+
hasattr(obj, "generate_reply") or hasattr(obj, "run")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LangChainAgent:
|
|
33
|
+
"""Wraps a LangChain / LangGraph Runnable (anything with ``.invoke``)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, runnable: Any, *, name: str | None = None) -> None:
|
|
36
|
+
self.runnable = runnable
|
|
37
|
+
self.name = name or type(runnable).__name__
|
|
38
|
+
|
|
39
|
+
async def run(self, message: Message, ctx: Context) -> Message:
|
|
40
|
+
if hasattr(self.runnable, "ainvoke"):
|
|
41
|
+
out = await self.runnable.ainvoke(message.payload)
|
|
42
|
+
else:
|
|
43
|
+
import asyncio
|
|
44
|
+
|
|
45
|
+
out = await asyncio.to_thread(self.runnable.invoke, message.payload)
|
|
46
|
+
out = getattr(out, "content", out)
|
|
47
|
+
return message.with_payload(out, node=self.name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CrewAgent:
|
|
51
|
+
"""Wraps a CrewAI Crew (anything with ``.kickoff``)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, crew: Any, *, name: str | None = None) -> None:
|
|
54
|
+
self.crew = crew
|
|
55
|
+
self.name = name or type(crew).__name__
|
|
56
|
+
|
|
57
|
+
async def run(self, message: Message, ctx: Context) -> Message:
|
|
58
|
+
import asyncio
|
|
59
|
+
|
|
60
|
+
inputs = message.payload if isinstance(message.payload, dict) else {"input": message.payload}
|
|
61
|
+
out = await asyncio.to_thread(self.crew.kickoff, inputs)
|
|
62
|
+
out = getattr(out, "raw", out)
|
|
63
|
+
return message.with_payload(out, node=self.name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AutoGenAgent:
|
|
67
|
+
"""Wraps an AutoGen agent (``.generate_reply`` / ``.run``)."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, agent: Any, *, name: str | None = None) -> None:
|
|
70
|
+
self.agent = agent
|
|
71
|
+
self.name = name or type(agent).__name__
|
|
72
|
+
|
|
73
|
+
async def run(self, message: Message, ctx: Context) -> Message:
|
|
74
|
+
import asyncio
|
|
75
|
+
|
|
76
|
+
if hasattr(self.agent, "generate_reply"):
|
|
77
|
+
msg = [{"role": "user", "content": str(message.payload)}]
|
|
78
|
+
out = await asyncio.to_thread(self.agent.generate_reply, msg)
|
|
79
|
+
else:
|
|
80
|
+
out = await asyncio.to_thread(self.agent.run, message.payload)
|
|
81
|
+
return message.with_payload(out, node=self.name)
|