deepcrew-ai 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.
deepcrew/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ deepcrew-ai — Multi-agent spawning library.
3
+
4
+ Quick start::
5
+
6
+ from deepcrew import Agent, run_agent, Orchestrator, WorkflowBuilder, tool
7
+ from deepcrew.mcp import StdioMCP, SSEMCP, HTTPMCP, MCPManager
8
+
9
+ Single agent::
10
+
11
+ agent = Agent(name="assistant", model="openai/gpt-4o",
12
+ system_prompt="You are helpful.")
13
+ result = await run_agent(agent, [{"role": "user", "content": "Hello!"}])
14
+ print(result.text)
15
+
16
+ Automated orchestration (router → parallel agents → synthesizer)::
17
+
18
+ orch = Orchestrator(
19
+ agents=[researcher, analyst, writer],
20
+ router_model="openai/gpt-4o-mini",
21
+ synthesizer_model="openai/gpt-4o",
22
+ )
23
+ result = await orch.run("Explain quantum entanglement")
24
+
25
+ DAG workflow::
26
+
27
+ workflow = (
28
+ WorkflowBuilder()
29
+ .add_agent("research", researcher, task="{input}")
30
+ .add_agent("write", writer, task="Write about:\\n{research}")
31
+ .then("research", "write")
32
+ )
33
+ result = await workflow.run("Quantum computing trends")
34
+ """
35
+
36
+ from .agent import Agent
37
+ from .exceptions import (
38
+ DeepCrewError,
39
+ MCPError,
40
+ MaxTurnsError,
41
+ RouterError,
42
+ ToolError,
43
+ WorkflowError,
44
+ )
45
+ from .orchestrator import Orchestrator
46
+ from .runner import run_agent
47
+ from .stream import make_done_event, make_error_event, queue_to_stream
48
+ from .tools import fn_to_tool_def, tool
49
+ from .types import (
50
+ AgentResult,
51
+ EventType,
52
+ OrchestratorResult,
53
+ StreamEvent,
54
+ ToolDef,
55
+ WorkflowResult,
56
+ )
57
+ from .workflow import WorkflowBuilder
58
+
59
+ __version__ = "0.1.0"
60
+
61
+ __all__ = [
62
+ # Core
63
+ "Agent",
64
+ "run_agent",
65
+ "Orchestrator",
66
+ "WorkflowBuilder",
67
+ # Tools
68
+ "tool",
69
+ "fn_to_tool_def",
70
+ # Types
71
+ "StreamEvent",
72
+ "EventType",
73
+ "AgentResult",
74
+ "WorkflowResult",
75
+ "OrchestratorResult",
76
+ "ToolDef",
77
+ # Streaming
78
+ "queue_to_stream",
79
+ "make_done_event",
80
+ "make_error_event",
81
+ # Exceptions
82
+ "DeepCrewError",
83
+ "MCPError",
84
+ "ToolError",
85
+ "MaxTurnsError",
86
+ "RouterError",
87
+ "WorkflowError",
88
+ ]
deepcrew/agent.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from .tools import fn_to_tool_def
8
+ from .types import ToolDef
9
+
10
+ if TYPE_CHECKING:
11
+ from .mcp.base import MCPClient
12
+
13
+
14
+ @dataclass
15
+ class Agent:
16
+ """
17
+ Defines a single AI agent: model, system prompt, and its tools.
18
+
19
+ Parameters
20
+ ----------
21
+ name:
22
+ Unique identifier used in logs and result objects.
23
+ model:
24
+ LiteLLM model string, e.g. ``"openai/gpt-4o"``,
25
+ ``"anthropic/claude-opus-4-8"``, ``"gemini/gemini-2.0-flash"``.
26
+ system_prompt:
27
+ The agent's system/instruction prompt.
28
+ mcps:
29
+ MCP clients (StdioMCP, SSEMCP, or HTTPMCP) whose tools this agent
30
+ can use.
31
+ tools:
32
+ Plain Python callables decorated with @tool (or undecorated).
33
+ Schemas are auto-generated from type hints.
34
+ max_turns:
35
+ Maximum LLM→tool→LLM cycles before raising MaxTurnsError.
36
+ temperature:
37
+ Sampling temperature forwarded to the LLM. None uses provider default.
38
+ max_tokens:
39
+ Maximum output tokens. None uses provider default.
40
+ extra_params:
41
+ Any additional kwargs forwarded verbatim to ``litellm.acompletion``.
42
+ """
43
+
44
+ name: str
45
+ model: str
46
+ system_prompt: str = ""
47
+ mcps: list[MCPClient] = field(default_factory=list)
48
+ tools: list[Callable[..., Any]] = field(default_factory=list)
49
+ max_turns: int = 10
50
+ temperature: float | None = None
51
+ max_tokens: int | None = None
52
+ extra_params: dict[str, Any] = field(default_factory=dict)
53
+
54
+ async def get_tool_defs(self) -> list[ToolDef]:
55
+ """Discover and merge tools from all attached MCP servers + Python functions."""
56
+ defs: list[ToolDef] = []
57
+
58
+ if self.mcps:
59
+ results = await asyncio.gather(
60
+ *[mcp.list_tools() for mcp in self.mcps],
61
+ return_exceptions=True,
62
+ )
63
+ for r in results:
64
+ if isinstance(r, list):
65
+ defs.extend(r)
66
+
67
+ for fn in self.tools:
68
+ defs.append(fn_to_tool_def(fn))
69
+
70
+ return defs
deepcrew/exceptions.py ADDED
@@ -0,0 +1,22 @@
1
+ class DeepCrewError(Exception):
2
+ """Base exception for all deepcrew errors."""
3
+
4
+
5
+ class MCPError(DeepCrewError):
6
+ """Raised when an MCP server operation fails."""
7
+
8
+
9
+ class ToolError(DeepCrewError):
10
+ """Raised when a tool call fails or a tool is not found."""
11
+
12
+
13
+ class MaxTurnsError(DeepCrewError):
14
+ """Raised when an agent exceeds its max_turns limit without finishing."""
15
+
16
+
17
+ class RouterError(DeepCrewError):
18
+ """Raised when the orchestrator router fails to parse a routing decision."""
19
+
20
+
21
+ class WorkflowError(DeepCrewError):
22
+ """Raised for invalid workflow configurations (e.g. cycles in the DAG)."""
@@ -0,0 +1,7 @@
1
+ from .base import MCPClient
2
+ from .http import HTTPMCP
3
+ from .manager import MCPManager
4
+ from .sse import SSEMCP
5
+ from .stdio import StdioMCP
6
+
7
+ __all__ = ["MCPClient", "StdioMCP", "SSEMCP", "HTTPMCP", "MCPManager"]
deepcrew/mcp/base.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ..types import ToolDef
6
+
7
+
8
+ class MCPClient(ABC):
9
+ """
10
+ Abstract base class for all MCP transport implementations.
11
+
12
+ Subclasses implement the three transport types defined by the MCP spec:
13
+ - :class:`StdioMCP` — subprocess JSON-RPC over stdin/stdout
14
+ - :class:`SSEMCP` — legacy HTTP/SSE protocol
15
+ - :class:`HTTPMCP` — modern streamable-HTTP protocol
16
+
17
+ All clients support the async context manager protocol::
18
+
19
+ async with HTTPMCP("https://example.com/mcp") as mcp:
20
+ tools = await mcp.list_tools()
21
+ """
22
+
23
+ @abstractmethod
24
+ async def connect(self) -> None:
25
+ """Establish the connection and run the MCP initialize handshake."""
26
+
27
+ @abstractmethod
28
+ async def list_tools(self) -> list[ToolDef]:
29
+ """Return all tools exposed by this MCP server."""
30
+
31
+ @abstractmethod
32
+ async def call_tool(self, name: str, args: dict) -> dict | str:
33
+ """Invoke a tool and return its result."""
34
+
35
+ @abstractmethod
36
+ async def close(self) -> None:
37
+ """Tear down the connection and release resources."""
38
+
39
+ async def __aenter__(self) -> MCPClient:
40
+ await self.connect()
41
+ return self
42
+
43
+ async def __aexit__(self, *_: object) -> None:
44
+ await self.close()
45
+
46
+
47
+ def _parse_mcp_tool(raw: dict, client: MCPClient) -> ToolDef:
48
+ """Convert the raw tools/list entry from an MCP server into a ToolDef."""
49
+ schema = raw.get("inputSchema") or raw.get("input_schema") or {}
50
+ return ToolDef(
51
+ name=raw["name"],
52
+ description=raw.get("description", ""),
53
+ parameters=schema,
54
+ _mcp_client=client,
55
+ )
deepcrew/mcp/http.py ADDED
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ..exceptions import MCPError
10
+ from ..types import ToolDef
11
+ from .base import MCPClient, _parse_mcp_tool
12
+
13
+
14
+ class HTTPMCP(MCPClient):
15
+ """
16
+ MCP client using the modern streamable-HTTP transport (MCP spec 2024-11-05+).
17
+
18
+ Performs an initialize handshake to obtain a ``Mcp-Session-Id`` header,
19
+ then uses that session ID for all subsequent requests. Supports automatic
20
+ session re-initialization on 400/401 responses and exponential-backoff
21
+ retries.
22
+
23
+ Parameters
24
+ ----------
25
+ url:
26
+ Full URL of the MCP server endpoint, e.g. ``"https://example.com/mcp"``.
27
+ headers:
28
+ Extra HTTP headers (e.g. ``{"Authorization": "Bearer token"}``).
29
+ timeout:
30
+ Request timeout in seconds.
31
+ retries:
32
+ Number of retry attempts on transient errors.
33
+
34
+ Example
35
+ -------
36
+ ::
37
+
38
+ async with HTTPMCP("https://my-mcp.example.com/mcp",
39
+ headers={"Authorization": "Bearer sk-..."}) as mcp:
40
+ tools = await mcp.list_tools()
41
+ result = await mcp.call_tool("search", {"query": "hello"})
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ url: str,
47
+ headers: dict[str, str] | None = None,
48
+ timeout: int = 30,
49
+ retries: int = 2,
50
+ ) -> None:
51
+ self.url = url.rstrip("/")
52
+ self._extra_headers: dict[str, str] = headers or {}
53
+ self._timeout = timeout
54
+ self._retries = retries
55
+ self._session_id: str | None = None
56
+ self._http: httpx.AsyncClient | None = None
57
+ self._tool_cache: list[ToolDef] | None = None
58
+ self._msg_id = 0
59
+
60
+ def _headers(self) -> dict[str, str]:
61
+ h = {"Content-Type": "application/json", **self._extra_headers}
62
+ if self._session_id:
63
+ h["Mcp-Session-Id"] = self._session_id
64
+ return h
65
+
66
+ async def connect(self) -> None:
67
+ self._http = httpx.AsyncClient(timeout=self._timeout)
68
+ await self._initialize()
69
+
70
+ async def _initialize(self) -> None:
71
+ self._msg_id += 1
72
+ resp = await self._raw_post(
73
+ {
74
+ "jsonrpc": "2.0",
75
+ "id": self._msg_id,
76
+ "method": "initialize",
77
+ "params": {
78
+ "protocolVersion": "2024-11-05",
79
+ "capabilities": {},
80
+ "clientInfo": {"name": "deepcrew", "version": "0.1.0"},
81
+ },
82
+ },
83
+ skip_session=True,
84
+ )
85
+ self._session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
86
+ body = resp.json()
87
+ if "error" in body:
88
+ raise MCPError(f"MCP initialize error: {body['error']}")
89
+
90
+ # Send notifications/initialized (fire-and-forget)
91
+ if self._http:
92
+ try:
93
+ await self._http.post(
94
+ self.url,
95
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
96
+ headers=self._headers(),
97
+ )
98
+ except Exception:
99
+ pass
100
+
101
+ async def _raw_post(
102
+ self, payload: dict[str, Any], skip_session: bool = False
103
+ ) -> httpx.Response:
104
+ if self._http is None:
105
+ raise MCPError("HTTPMCP is not connected. Call connect() first.")
106
+ headers = {"Content-Type": "application/json", **self._extra_headers}
107
+ if not skip_session and self._session_id:
108
+ headers["Mcp-Session-Id"] = self._session_id
109
+ resp = await self._http.post(self.url, json=payload, headers=headers)
110
+ return resp
111
+
112
+ async def _post(self, payload: dict[str, Any], attempt: int = 0) -> dict[str, Any]:
113
+ try:
114
+ resp = await self._raw_post(payload)
115
+ if resp.status_code in (400, 401):
116
+ # Session expired — re-initialize and retry once
117
+ self._session_id = None
118
+ self._tool_cache = None
119
+ await self._initialize()
120
+ if attempt < self._retries:
121
+ return await self._post(payload, attempt + 1)
122
+ resp.raise_for_status()
123
+ return resp.json()
124
+ except httpx.HTTPStatusError as exc:
125
+ if attempt < self._retries:
126
+ await asyncio.sleep(2**attempt)
127
+ return await self._post(payload, attempt + 1)
128
+ raise MCPError(f"HTTPMCP request failed: {exc}") from exc
129
+ except httpx.RequestError as exc:
130
+ if attempt < self._retries:
131
+ await asyncio.sleep(2**attempt)
132
+ return await self._post(payload, attempt + 1)
133
+ raise MCPError(f"HTTPMCP connection error: {exc}") from exc
134
+
135
+ async def list_tools(self) -> list[ToolDef]:
136
+ if self._tool_cache is not None:
137
+ return self._tool_cache
138
+ self._msg_id += 1
139
+ body = await self._post({"jsonrpc": "2.0", "id": self._msg_id, "method": "tools/list", "params": {}})
140
+ if "error" in body:
141
+ raise MCPError(f"tools/list failed: {body['error']}")
142
+ raw_tools: list[dict] = body.get("result", {}).get("tools", [])
143
+ self._tool_cache = [_parse_mcp_tool(t, self) for t in raw_tools]
144
+ return self._tool_cache
145
+
146
+ async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
147
+ self._msg_id += 1
148
+ body = await self._post(
149
+ {
150
+ "jsonrpc": "2.0",
151
+ "id": self._msg_id,
152
+ "method": "tools/call",
153
+ "params": {"name": name, "arguments": args},
154
+ }
155
+ )
156
+ if "error" in body:
157
+ raise MCPError(f"Tool {name!r} call failed: {body['error']}")
158
+ result = body.get("result", {})
159
+ if isinstance(result, dict) and "content" in result:
160
+ contents = result["content"]
161
+ if contents and isinstance(contents[0], dict):
162
+ return contents[0].get("text", json.dumps(result))
163
+ return result
164
+
165
+ async def close(self) -> None:
166
+ if self._http:
167
+ await self._http.aclose()
168
+ self._http = None
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ from ..exceptions import MCPError
7
+ from ..types import ToolDef
8
+ from .base import MCPClient
9
+
10
+
11
+ class MCPManager:
12
+ """
13
+ Aggregates multiple MCP clients into a single interface.
14
+
15
+ Discovers tools from all attached clients in parallel and maintains a
16
+ routing table so that tool calls are automatically dispatched to the
17
+ correct server.
18
+
19
+ Parameters
20
+ ----------
21
+ clients:
22
+ Any combination of StdioMCP, SSEMCP, and HTTPMCP instances.
23
+
24
+ Example
25
+ -------
26
+ ::
27
+
28
+ manager = MCPManager([
29
+ StdioMCP("npx", ["-y", "@modelcontextprotocol/server-filesystem", "."]),
30
+ HTTPMCP("https://my-search-mcp.example.com/mcp"),
31
+ ])
32
+ await manager.connect_all()
33
+ tools = await manager.discover_tools()
34
+
35
+ # Pass manager as a single MCP to an Agent
36
+ agent = Agent("assistant", model="openai/gpt-4o", mcps=[manager])
37
+ """
38
+
39
+ def __init__(self, clients: list[MCPClient]) -> None:
40
+ self._clients = clients
41
+ self._routing: dict[str, MCPClient] = {}
42
+
43
+ async def connect_all(self) -> None:
44
+ """Connect all clients in parallel."""
45
+ await asyncio.gather(*[c.connect() for c in self._clients], return_exceptions=True)
46
+
47
+ async def discover_tools(self) -> list[ToolDef]:
48
+ """
49
+ Fetch tools from all servers in parallel and build the routing table.
50
+ Tools from earlier clients take precedence if names collide.
51
+ """
52
+ all_tools: list[ToolDef] = []
53
+ results = await asyncio.gather(
54
+ *[c.list_tools() for c in self._clients],
55
+ return_exceptions=True,
56
+ )
57
+ for r in results:
58
+ if isinstance(r, list):
59
+ for td in r:
60
+ if td.name not in self._routing:
61
+ self._routing[td.name] = td._mcp_client # type: ignore[assignment]
62
+ all_tools.append(td)
63
+ return all_tools
64
+
65
+ async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
66
+ client = self._routing.get(name)
67
+ if not client:
68
+ raise MCPError(
69
+ f"No MCP server owns tool {name!r}. "
70
+ "Did you call discover_tools() before calling call_tool()?"
71
+ )
72
+ return await client.call_tool(name, args)
73
+
74
+ async def list_tools(self) -> list[ToolDef]:
75
+ """Implement the MCPClient interface so MCPManager can be passed as an MCP."""
76
+ return await self.discover_tools()
77
+
78
+ async def close_all(self) -> None:
79
+ """Close all clients, ignoring individual errors."""
80
+ await asyncio.gather(*[c.close() for c in self._clients], return_exceptions=True)
81
+
82
+ async def __aenter__(self) -> MCPManager:
83
+ await self.connect_all()
84
+ return self
85
+
86
+ async def __aexit__(self, *_: object) -> None:
87
+ await self.close_all()
deepcrew/mcp/sse.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any
6
+ from urllib.parse import urljoin
7
+
8
+ import httpx
9
+
10
+ from ..exceptions import MCPError
11
+ from ..types import ToolDef
12
+ from .base import MCPClient, _parse_mcp_tool
13
+
14
+
15
+ class SSEMCP(MCPClient):
16
+ """
17
+ MCP client for the legacy Server-Sent Events transport.
18
+
19
+ This transport was used by early MCP servers (pre-2024-11-05). It
20
+ connects to a ``/sse`` endpoint to receive an event that tells it which
21
+ URL to POST requests to.
22
+
23
+ Parameters
24
+ ----------
25
+ base_url:
26
+ Base URL of the MCP server, e.g. ``"http://localhost:3000"``.
27
+ The client appends ``/sse`` for the event stream and uses the
28
+ ``endpoint`` event to discover the POST URL.
29
+ headers:
30
+ Extra HTTP headers forwarded with every request.
31
+
32
+ Example
33
+ -------
34
+ ::
35
+
36
+ async with SSEMCP("http://localhost:3000") as mcp:
37
+ tools = await mcp.list_tools()
38
+ """
39
+
40
+ def __init__(self, base_url: str, headers: dict[str, str] | None = None) -> None:
41
+ self.base_url = base_url.rstrip("/")
42
+ self._extra_headers: dict[str, str] = headers or {}
43
+ self._post_url: str | None = None
44
+ self._http: httpx.AsyncClient | None = None
45
+ self._tool_cache: list[ToolDef] | None = None
46
+ self._msg_id = 0
47
+
48
+ async def connect(self) -> None:
49
+ self._http = httpx.AsyncClient(timeout=60.0)
50
+ await self._sse_handshake()
51
+ await self._initialize()
52
+
53
+ async def _sse_handshake(self) -> None:
54
+ """Subscribe to /sse until we receive the 'endpoint' event."""
55
+ try:
56
+ from httpx_sse import aconnect_sse
57
+ except ImportError as exc:
58
+ raise MCPError(
59
+ "httpx-sse is required for SSEMCP. Install it with: pip install httpx-sse"
60
+ ) from exc
61
+
62
+ if self._http is None:
63
+ raise MCPError("SSEMCP not connected")
64
+
65
+ sse_url = f"{self.base_url}/sse"
66
+ async with aconnect_sse(
67
+ self._http, "GET", sse_url, headers=self._extra_headers
68
+ ) as event_source:
69
+ async for event in event_source.aiter_sse():
70
+ if event.event == "endpoint":
71
+ data = event.data.strip()
72
+ if data.startswith("http"):
73
+ self._post_url = data
74
+ else:
75
+ self._post_url = urljoin(self.base_url, data)
76
+ return
77
+
78
+ raise MCPError("SSE stream closed before receiving 'endpoint' event")
79
+
80
+ async def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
81
+ if self._http is None or self._post_url is None:
82
+ raise MCPError("SSEMCP is not connected. Call connect() first.")
83
+ resp = await self._http.post(
84
+ self._post_url, json=payload, headers=self._extra_headers
85
+ )
86
+ resp.raise_for_status()
87
+ try:
88
+ return resp.json()
89
+ except Exception:
90
+ return {"result": resp.text}
91
+
92
+ async def _initialize(self) -> None:
93
+ self._msg_id += 1
94
+ body = await self._post(
95
+ {
96
+ "jsonrpc": "2.0",
97
+ "id": self._msg_id,
98
+ "method": "initialize",
99
+ "params": {
100
+ "protocolVersion": "2024-11-05",
101
+ "capabilities": {},
102
+ "clientInfo": {"name": "deepcrew", "version": "0.1.0"},
103
+ },
104
+ }
105
+ )
106
+ if "error" in body:
107
+ raise MCPError(f"SSEMCP initialize error: {body['error']}")
108
+
109
+ # Fire-and-forget notification
110
+ try:
111
+ if self._http and self._post_url:
112
+ await self._http.post(
113
+ self._post_url,
114
+ json={"jsonrpc": "2.0", "method": "notifications/initialized"},
115
+ headers=self._extra_headers,
116
+ )
117
+ except Exception:
118
+ pass
119
+
120
+ async def list_tools(self) -> list[ToolDef]:
121
+ if self._tool_cache is not None:
122
+ return self._tool_cache
123
+ self._msg_id += 1
124
+ body = await self._post(
125
+ {"jsonrpc": "2.0", "id": self._msg_id, "method": "tools/list", "params": {}}
126
+ )
127
+ if "error" in body:
128
+ raise MCPError(f"tools/list failed: {body['error']}")
129
+ raw_tools: list[dict] = body.get("result", {}).get("tools", [])
130
+ self._tool_cache = [_parse_mcp_tool(t, self) for t in raw_tools]
131
+ return self._tool_cache
132
+
133
+ async def call_tool(self, name: str, args: dict[str, Any]) -> dict[str, Any] | str:
134
+ self._msg_id += 1
135
+ body = await self._post(
136
+ {
137
+ "jsonrpc": "2.0",
138
+ "id": self._msg_id,
139
+ "method": "tools/call",
140
+ "params": {"name": name, "arguments": args},
141
+ }
142
+ )
143
+ if "error" in body:
144
+ raise MCPError(f"Tool {name!r} call failed: {body['error']}")
145
+ result = body.get("result", {})
146
+ if isinstance(result, dict) and "content" in result:
147
+ contents = result["content"]
148
+ if contents and isinstance(contents[0], dict):
149
+ return contents[0].get("text", json.dumps(result))
150
+ return result
151
+
152
+ async def close(self) -> None:
153
+ if self._http:
154
+ await self._http.aclose()
155
+ self._http = None