readyagentsdev 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,253 @@
1
+ """Connect to MCP servers declared in a workflow (optional extra)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import threading
8
+ from contextlib import AsyncExitStack
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from readyagents.errors import MCPError
13
+ from readyagents.tools import FunctionTool, Tool
14
+ from readyagents.workflow.schema import MCPServerSpec
15
+
16
+ # Stdio children get a narrow env. API keys are not inherited unless the
17
+ # workflow sets them under mcp_servers.<name>.env.
18
+ _PASSTHROUGH_ENV = frozenset(
19
+ {
20
+ "PATH",
21
+ "HOME",
22
+ "LANG",
23
+ "LC_ALL",
24
+ "LC_CTYPE",
25
+ "LC_MESSAGES",
26
+ "TERM",
27
+ "TMPDIR",
28
+ "TEMP",
29
+ "TMP",
30
+ "USER",
31
+ "LOGNAME",
32
+ "SHELL",
33
+ }
34
+ )
35
+
36
+
37
+ def mcp_child_env(spec: MCPServerSpec) -> dict[str, str]:
38
+ """Env mapping passed to an MCP stdio subprocess."""
39
+ env = {key: value for key, value in os.environ.items() if key in _PASSTHROUGH_ENV}
40
+ if spec.env:
41
+ env.update(spec.env)
42
+ return env
43
+
44
+
45
+ def mcp_available() -> bool:
46
+ try:
47
+ import mcp # noqa: F401
48
+
49
+ return True
50
+ except ImportError:
51
+ return False
52
+
53
+
54
+ def _resolve_mcp_cwd(spec: MCPServerSpec, workspace: Path) -> Path:
55
+ """Use the run workspace, or confine an explicit cwd under it, before spawn."""
56
+ root = Path(workspace).resolve()
57
+ raw = (spec.cwd or "").strip()
58
+ if not raw:
59
+ return root
60
+ from readyagents.workflow.runner import confine_under
61
+
62
+ return confine_under(raw, root, what="MCP cwd")
63
+
64
+
65
+ def _schema_from_mcp_tool(item: Any) -> dict[str, Any]:
66
+ raw: Any = None
67
+ if isinstance(item, dict):
68
+ raw = item.get("inputSchema", item.get("input_schema"))
69
+ else:
70
+ raw = getattr(item, "inputSchema", None)
71
+ if raw is None:
72
+ raw = getattr(item, "input_schema", None)
73
+ if isinstance(raw, dict):
74
+ return dict(raw)
75
+ return {}
76
+
77
+
78
+ def _result_text(result: Any) -> str:
79
+ content = getattr(result, "content", None)
80
+ if not content:
81
+ return str(result)
82
+ texts: list[str] = []
83
+ for block in content:
84
+ if isinstance(block, dict):
85
+ text = block.get("text")
86
+ else:
87
+ text = getattr(block, "text", None)
88
+ if text is not None:
89
+ texts.append(str(text))
90
+ return "\n".join(texts) if texts else str(result)
91
+
92
+
93
+ class _AsyncLoop:
94
+ """Background asyncio loop so MCP stdio sessions outlive a single coroutine."""
95
+
96
+ def __init__(self) -> None:
97
+ self.loop = asyncio.new_event_loop()
98
+ self._ready = threading.Event()
99
+ self._thread = threading.Thread(target=self._run, name="readyagents-mcp", daemon=True)
100
+ self._thread.start()
101
+ if not self._ready.wait(timeout=5):
102
+ raise MCPError("MCP event loop failed to start")
103
+
104
+ def _run(self) -> None:
105
+ asyncio.set_event_loop(self.loop)
106
+ self._ready.set()
107
+ self.loop.run_forever()
108
+ pending = asyncio.all_tasks(self.loop)
109
+ for task in pending:
110
+ task.cancel()
111
+ if pending:
112
+ self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
113
+ self.loop.close()
114
+
115
+ def run(self, coro: Any, *, timeout: float = 60) -> Any:
116
+ fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
117
+ try:
118
+ return fut.result(timeout=timeout)
119
+ except TimeoutError:
120
+ fut.cancel()
121
+ raise MCPError("MCP operation timed out") from None
122
+
123
+ def stop(self) -> None:
124
+ if not self.loop.is_closed():
125
+ self.loop.call_soon_threadsafe(self.loop.stop)
126
+ self._thread.join(timeout=10)
127
+
128
+
129
+ class MCPClient:
130
+ """Long-lived stdio MCP sessions, one child per ``mcp_servers`` name."""
131
+
132
+ def __init__(self, servers: dict[str, MCPServerSpec], workspace: Path) -> None:
133
+ self._servers = dict(servers)
134
+ self._workspace = Path(workspace)
135
+ # Confine every declared cwd before any subprocess is spawned.
136
+ self._cwds = {
137
+ name: _resolve_mcp_cwd(spec, self._workspace) for name, spec in self._servers.items()
138
+ }
139
+ self._tools: dict[str, Tool] | None = None
140
+ self._sessions: dict[str, Any] = {}
141
+ self._stack: AsyncExitStack | None = None
142
+ self._loop: _AsyncLoop | None = None
143
+ self._lock = threading.Lock()
144
+
145
+ def tools(self) -> dict[str, Tool]:
146
+ if not self._servers:
147
+ return {}
148
+ with self._lock:
149
+ if self._tools is None:
150
+ self._tools = self._connect_locked()
151
+ return self._tools
152
+
153
+ def close(self) -> None:
154
+ with self._lock:
155
+ self._shutdown_locked()
156
+
157
+ def _connect_locked(self) -> dict[str, Tool]:
158
+ if not mcp_available():
159
+ names = ", ".join(self._servers)
160
+ raise MCPError(
161
+ f"Workflow declares MCP servers ({names}) but the mcp extra is not installed. "
162
+ 'Run: pip install -e ".[mcp]"'
163
+ )
164
+ self._loop = _AsyncLoop()
165
+ try:
166
+ return self._loop.run(self._connect())
167
+ except BaseException:
168
+ self._shutdown_locked()
169
+ raise
170
+
171
+ def _shutdown_locked(self) -> None:
172
+ loop = self._loop
173
+ if loop is not None:
174
+ try:
175
+ if self._stack is not None:
176
+ loop.run(self._stack.aclose(), timeout=15)
177
+ except Exception: # noqa: BLE001
178
+ pass
179
+ loop.stop()
180
+ self._loop = None
181
+ self._stack = None
182
+ self._sessions.clear()
183
+ self._tools = None
184
+
185
+ async def _connect(self) -> dict[str, Tool]:
186
+ from mcp import ClientSession, StdioServerParameters
187
+ from mcp.client.stdio import stdio_client
188
+
189
+ stack = AsyncExitStack()
190
+ tools: dict[str, Tool] = {}
191
+ try:
192
+ for name, spec in self._servers.items():
193
+ params = StdioServerParameters(
194
+ command=spec.command,
195
+ args=spec.args,
196
+ env=mcp_child_env(spec),
197
+ cwd=str(self._cwds[name]),
198
+ )
199
+ try:
200
+ read, write = await stack.enter_async_context(stdio_client(params))
201
+ session = await stack.enter_async_context(ClientSession(read, write))
202
+ await session.initialize()
203
+ listed = await session.list_tools()
204
+ except MCPError:
205
+ raise
206
+ except Exception as exc: # noqa: BLE001
207
+ raise MCPError(f"Failed to connect to MCP server '{name}': {exc}") from exc
208
+ self._sessions[name] = session
209
+ for item in listed.tools:
210
+ tool_name = item.name
211
+ qualified = f"{name}.{tool_name}"
212
+ desc = item.description or ""
213
+ tools[qualified] = FunctionTool(
214
+ name=qualified,
215
+ description=desc or f"MCP tool {tool_name} from {name}",
216
+ handler=self._handler(name, tool_name),
217
+ schema=_schema_from_mcp_tool(item),
218
+ )
219
+ except BaseException:
220
+ await stack.aclose()
221
+ raise
222
+ self._stack = stack
223
+ return tools
224
+
225
+ def _handler(self, server: str, tool_name: str) -> Any:
226
+ def handler(**kwargs: Any) -> Any:
227
+ return self._call(server, tool_name, kwargs)
228
+
229
+ return handler
230
+
231
+ def _call(self, server: str, tool_name: str, arguments: dict[str, Any]) -> Any:
232
+ with self._lock:
233
+ loop = self._loop
234
+ session = self._sessions.get(server)
235
+ if loop is None or session is None:
236
+ raise MCPError(f"MCP server '{server}' is not connected")
237
+ try:
238
+ return loop.run(self._call_on(session, tool_name, arguments))
239
+ except MCPError:
240
+ raise
241
+ except Exception as exc: # noqa: BLE001
242
+ raise MCPError(f"MCP tool '{tool_name}' failed: {exc}") from exc
243
+
244
+ async def _call_on(self, session: Any, tool_name: str, arguments: dict[str, Any]) -> Any:
245
+ try:
246
+ result = await session.call_tool(tool_name, arguments)
247
+ except MCPError:
248
+ raise
249
+ except Exception as exc: # noqa: BLE001
250
+ raise MCPError(f"MCP tool '{tool_name}' failed: {exc}") from exc
251
+ if getattr(result, "isError", False) or getattr(result, "is_error", False):
252
+ raise MCPError(f"MCP tool '{tool_name}' returned an error: {result}")
253
+ return _result_text(result)