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 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,6 @@
1
+ """``python -m babelagent`` entry point."""
2
+
3
+ from .io.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)