multiagentos 0.4.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.
- agents/__init__.py +1 -0
- agents/catalog.py +52 -0
- agents/registry.py +11 -0
- core/__init__.py +38 -0
- core/artifacts.py +47 -0
- core/chat_agent_bridge.py +280 -0
- core/chat_agent_registry.py +61 -0
- core/chat_agent_router.py +85 -0
- core/chat_session.py +62 -0
- core/contracts/__init__.py +22 -0
- core/contracts/agent.py +21 -0
- core/contracts/ai.py +25 -0
- core/contracts/capability.py +44 -0
- core/contracts/chat_agent.py +34 -0
- core/contracts/checkpoint.py +78 -0
- core/contracts/discovery.py +30 -0
- core/contracts/execution.py +28 -0
- core/contracts/github.py +74 -0
- core/contracts/handoff.py +56 -0
- core/contracts/health.py +206 -0
- core/contracts/human_review.py +20 -0
- core/contracts/ide.py +113 -0
- core/contracts/mcp.py +76 -0
- core/contracts/model_runtime.py +25 -0
- core/contracts/planning.py +25 -0
- core/contracts/profile.py +66 -0
- core/contracts/quota.py +62 -0
- core/contracts/repository.py +34 -0
- core/contracts/resume.py +55 -0
- core/contracts/runtime.py +19 -0
- core/contracts/vyrelon_runtime.py +118 -0
- core/contracts/work_unit.py +48 -0
- core/delegation.py +33 -0
- core/handoff.py +68 -0
- core/lifecycle.py +85 -0
- core/multi_agent_workflow.py +574 -0
- core/orchestrator.py +51 -0
- core/planning.py +22 -0
- core/registry.py +44 -0
- core/routing.py +195 -0
- core/state.py +155 -0
- installer/__init__.py +1 -0
- installer/init.py +71 -0
- integrations/__init__.py +0 -0
- integrations/github/__init__.py +3 -0
- integrations/github/gateway.py +164 -0
- integrations/openai/__init__.py +1 -0
- integrations/openai/chat_agent.py +147 -0
- multiagentos/__init__.py +1 -0
- multiagentos/cli.py +759 -0
- multiagentos-0.4.0.dist-info/METADATA +7 -0
- multiagentos-0.4.0.dist-info/RECORD +122 -0
- multiagentos-0.4.0.dist-info/WHEEL +5 -0
- multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
- multiagentos-0.4.0.dist-info/top_level.txt +7 -0
- profiles/__init__.py +1 -0
- profiles/common.py +11 -0
- profiles/detector.py +67 -0
- profiles/registry.py +46 -0
- profiles/resolver.py +63 -0
- runtime/__init__.py +5 -0
- runtime/agent/__init__.py +1 -0
- runtime/agent/ide.py +74 -0
- runtime/agent/model.py +197 -0
- runtime/agent/model_backed.py +49 -0
- runtime/agent/process.py +21 -0
- runtime/builtin_tools.py +57 -0
- runtime/capability.py +143 -0
- runtime/chat_adapter_registry.py +43 -0
- runtime/chat_config.py +57 -0
- runtime/chat_execution.py +52 -0
- runtime/config/__init__.py +1 -0
- runtime/config/loader.py +67 -0
- runtime/discovery.py +334 -0
- runtime/execution_config.py +71 -0
- runtime/execution_registry.py +50 -0
- runtime/git.py +84 -0
- runtime/github.py +56 -0
- runtime/github_probe.py +23 -0
- runtime/health.py +14 -0
- runtime/ide/__init__.py +6 -0
- runtime/ide/bridge.py +198 -0
- runtime/ide/registry.py +24 -0
- runtime/ide/runtime.py +35 -0
- runtime/local/__init__.py +18 -0
- runtime/local/filesystem.py +58 -0
- runtime/local/patch.py +79 -0
- runtime/local/path_security.py +31 -0
- runtime/local/permissions.py +20 -0
- runtime/local/process.py +56 -0
- runtime/local/shell.py +97 -0
- runtime/mcp/__init__.py +7 -0
- runtime/mcp/client.py +120 -0
- runtime/mcp/config.py +36 -0
- runtime/mcp/policy.py +57 -0
- runtime/mcp/proxy.py +30 -0
- runtime/mcp/server.py +137 -0
- runtime/mcp/session.py +19 -0
- runtime/model/__init__.py +5 -0
- runtime/model/ai_runtime.py +165 -0
- runtime/model/cli.py +49 -0
- runtime/model/config.py +98 -0
- runtime/model/credentials.py +42 -0
- runtime/model/factory.py +82 -0
- runtime/model/http.py +78 -0
- runtime/model/invoker.py +16 -0
- runtime/model/materializer.py +101 -0
- runtime/model/native.py +244 -0
- runtime/model/providers.py +55 -0
- runtime/model/registry.py +26 -0
- runtime/model/runtime_loader.py +51 -0
- runtime/model_control.py +136 -0
- runtime/multi_agent.py +142 -0
- runtime/policy.py +28 -0
- runtime/process.py +41 -0
- runtime/quota.py +259 -0
- runtime/repository.py +103 -0
- runtime/repository_tools.py +62 -0
- runtime/status.py +87 -0
- runtime/tool_calling.py +134 -0
- runtime/validation.py +121 -0
- runtime/vyrelon.py +1557 -0
runtime/mcp/client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Provider-neutral MCP client supporting stdio and Streamable HTTP."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json, os, subprocess, urllib.request, urllib.error, uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from core.contracts.mcp import MCPServerSpec, MCPSession, MCPTool, MCPToolCall, MCPToolResult
|
|
7
|
+
|
|
8
|
+
class MCPError(RuntimeError): pass
|
|
9
|
+
class MCPProtocolError(MCPError): pass
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _StdioSession:
|
|
13
|
+
process: subprocess.Popen[str]
|
|
14
|
+
session: MCPSession
|
|
15
|
+
next_id: int = 0
|
|
16
|
+
|
|
17
|
+
class MCPClient:
|
|
18
|
+
def __init__(self, server: MCPServerSpec, *, cwd: str | None = None, timeout: float = 30.0):
|
|
19
|
+
self.server, self.cwd, self.timeout = server, cwd, timeout
|
|
20
|
+
self._stdio: _StdioSession | None = None
|
|
21
|
+
self._http_session: MCPSession | None = None
|
|
22
|
+
self._next_id = 0
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def session(self) -> MCPSession | None:
|
|
26
|
+
return self._stdio.session if self._stdio else self._http_session
|
|
27
|
+
|
|
28
|
+
def connect(self) -> MCPSession:
|
|
29
|
+
if self.server.requires_explicit_enable and not self.server.enabled:
|
|
30
|
+
raise MCPError(f"External MCP server requires explicit enablement: {self.server.id}")
|
|
31
|
+
if not self.server.enabled: raise MCPError(f"MCP server disabled: {self.server.id}")
|
|
32
|
+
if self.server.transport in {"streamable-http", "http", "sse"}:
|
|
33
|
+
return self._connect_http()
|
|
34
|
+
if self.server.transport != "stdio": raise MCPError(f"Unsupported MCP transport: {self.server.transport}")
|
|
35
|
+
if not self.server.command: raise MCPError(f"MCP server has no command: {self.server.id}")
|
|
36
|
+
env=os.environ.copy(); env.update(self.server.env)
|
|
37
|
+
process=subprocess.Popen([*self.server.command,*self.server.args],cwd=self.cwd,env=env,
|
|
38
|
+
stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,
|
|
39
|
+
text=True,bufsize=1)
|
|
40
|
+
session=MCPSession(str(uuid.uuid4()),self.server.id)
|
|
41
|
+
self._stdio=_StdioSession(process,session)
|
|
42
|
+
self._request_stdio("initialize",{"protocolVersion":session.protocol_version,"capabilities":{},
|
|
43
|
+
"clientInfo":{"name":"VYRELON","version":"0.1"}})
|
|
44
|
+
self._notify_stdio("notifications/initialized",{})
|
|
45
|
+
return session
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
if self._stdio:
|
|
49
|
+
p=self._stdio.process
|
|
50
|
+
if p.poll() is None:
|
|
51
|
+
p.terminate()
|
|
52
|
+
try: p.wait(timeout=2)
|
|
53
|
+
except subprocess.TimeoutExpired: p.kill()
|
|
54
|
+
self._stdio=None
|
|
55
|
+
self._http_session=None
|
|
56
|
+
|
|
57
|
+
def list_tools(self) -> tuple[MCPTool,...]:
|
|
58
|
+
result=self._request("tools/list",{})
|
|
59
|
+
return tuple(MCPTool(str(x["name"]),str(x.get("description","")),dict(x.get("inputSchema",{})),self.server.id)
|
|
60
|
+
for x in result.get("tools",[]))
|
|
61
|
+
|
|
62
|
+
def call_tool(self, call: MCPToolCall) -> MCPToolResult:
|
|
63
|
+
if call.server_id != self.server.id: raise MCPError(f"Tool call targets {call.server_id}, not {self.server.id}")
|
|
64
|
+
result=self._request("tools/call",{"name":call.tool_name,"arguments":call.arguments})
|
|
65
|
+
return MCPToolResult(self.server.id,call.tool_name,tuple(result.get("content",[])),bool(result.get("isError",False)),result)
|
|
66
|
+
|
|
67
|
+
def _request(self,method:str,params:dict[str,Any])->dict[str,Any]:
|
|
68
|
+
if self.server.transport in {"streamable-http","http","sse"}: return self._http_request(method,params)[0]
|
|
69
|
+
return self._request_stdio(method,params)
|
|
70
|
+
|
|
71
|
+
def _request_stdio(self,method:str,params:dict[str,Any])->dict[str,Any]:
|
|
72
|
+
s=self._stdio
|
|
73
|
+
if not s or s.process.stdin is None or s.process.stdout is None: raise MCPError("MCP client is not connected")
|
|
74
|
+
s.next_id+=1; request_id=s.next_id
|
|
75
|
+
s.process.stdin.write(json.dumps({"jsonrpc":"2.0","id":request_id,"method":method,"params":params})+"\n"); s.process.stdin.flush()
|
|
76
|
+
while True:
|
|
77
|
+
line=s.process.stdout.readline()
|
|
78
|
+
if not line: raise MCPProtocolError(f"MCP server closed stdout while waiting for {method}")
|
|
79
|
+
message=json.loads(line)
|
|
80
|
+
if message.get("id") != request_id: continue
|
|
81
|
+
if "error" in message: raise MCPProtocolError(str(message["error"]))
|
|
82
|
+
return dict(message.get("result",{}))
|
|
83
|
+
|
|
84
|
+
def _notify_stdio(self,method:str,params:dict[str,Any])->None:
|
|
85
|
+
s=self._stdio
|
|
86
|
+
if not s or s.process.stdin is None: raise MCPError("MCP client is not connected")
|
|
87
|
+
s.process.stdin.write(json.dumps({"jsonrpc":"2.0","method":method,"params":params})+"\n"); s.process.stdin.flush()
|
|
88
|
+
|
|
89
|
+
def _connect_http(self)->MCPSession:
|
|
90
|
+
if not self.server.endpoint: raise MCPError(f"MCP server has no endpoint: {self.server.id}")
|
|
91
|
+
result,headers=self._http_request("initialize",{"protocolVersion":"2025-03-26","capabilities":{},
|
|
92
|
+
"clientInfo":{"name":"VYRELON","version":"0.1"}})
|
|
93
|
+
sid=headers.get("Mcp-Session-Id") or headers.get("mcp-session-id") or str(uuid.uuid4())
|
|
94
|
+
self._http_session=MCPSession(sid,self.server.id,str(result.get("protocolVersion","2025-03-26")))
|
|
95
|
+
self._http_request("notifications/initialized",{},notification=True)
|
|
96
|
+
return self._http_session
|
|
97
|
+
|
|
98
|
+
def _http_request(self,method:str,params:dict[str,Any],*,notification:bool=False):
|
|
99
|
+
if not self.server.endpoint: raise MCPError("MCP server has no endpoint")
|
|
100
|
+
self._next_id+=1
|
|
101
|
+
payload={"jsonrpc":"2.0","method":method,"params":params}
|
|
102
|
+
if not notification: payload["id"]=self._next_id
|
|
103
|
+
headers={"Content-Type":"application/json","Accept":"application/json, text/event-stream",**self.server.headers}
|
|
104
|
+
if self._http_session: headers["Mcp-Session-Id"]=self._http_session.id
|
|
105
|
+
req=urllib.request.Request(self.server.endpoint,data=json.dumps(payload).encode(),headers=headers,method="POST")
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(req,timeout=self.timeout) as response:
|
|
108
|
+
response_headers=dict(response.headers.items()); body=response.read().decode()
|
|
109
|
+
except urllib.error.HTTPError as exc:
|
|
110
|
+
raise MCPProtocolError(f"MCP HTTP {exc.code}: {exc.read().decode(errors='replace')}") from exc
|
|
111
|
+
except urllib.error.URLError as exc:
|
|
112
|
+
raise MCPError(f"MCP HTTP transport error: {exc.reason}") from exc
|
|
113
|
+
if notification: return {},response_headers
|
|
114
|
+
if "text/event-stream" in response_headers.get("Content-Type",""):
|
|
115
|
+
data=[line[5:].strip() for line in body.splitlines() if line.startswith("data:")]
|
|
116
|
+
if not data: raise MCPProtocolError("MCP SSE response contained no data event")
|
|
117
|
+
message=json.loads(data[-1])
|
|
118
|
+
else: message=json.loads(body)
|
|
119
|
+
if "error" in message: raise MCPProtocolError(str(message["error"]))
|
|
120
|
+
return dict(message.get("result",{})),response_headers
|
runtime/mcp/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Declarative MCP server configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from core.contracts.mcp import MCPServerSpec
|
|
6
|
+
|
|
7
|
+
class MCPConfigLoader:
|
|
8
|
+
def load(self, project_root: Path) -> list[MCPServerSpec]:
|
|
9
|
+
path = project_root / ".multiagentos" / "mcp.json"
|
|
10
|
+
if not path.exists():
|
|
11
|
+
return []
|
|
12
|
+
data = json.loads(path.read_text())
|
|
13
|
+
servers = data.get("servers", data.get("mcpServers", {}))
|
|
14
|
+
result = []
|
|
15
|
+
for server_id, raw in servers.items():
|
|
16
|
+
command = raw.get("command")
|
|
17
|
+
if isinstance(command, str):
|
|
18
|
+
command = (command,)
|
|
19
|
+
else:
|
|
20
|
+
command = tuple(command or ())
|
|
21
|
+
args = tuple(str(x) for x in raw.get("args", ()))
|
|
22
|
+
result.append(MCPServerSpec(
|
|
23
|
+
id=server_id,
|
|
24
|
+
command=tuple(command),
|
|
25
|
+
args=args,
|
|
26
|
+
env={str(k): str(v) for k,v in raw.get("env", {}).items()},
|
|
27
|
+
transport=str(raw.get("transport", "stdio")),
|
|
28
|
+
endpoint=raw.get("url", raw.get("endpoint")),
|
|
29
|
+
headers={str(k): str(v) for k,v in raw.get("headers", {}).items()},
|
|
30
|
+
enabled=bool(raw.get("enabled", False if raw.get("source", "built-in") == "external" else True)),
|
|
31
|
+
metadata=dict(raw.get("metadata", {})),
|
|
32
|
+
source=str(raw.get("source", "built-in")),
|
|
33
|
+
cost_policy=str(raw.get("cost_policy", "no_external_billing" if raw.get("source", "built-in") == "built-in" else "external_service_possible")),
|
|
34
|
+
requires_explicit_enable=bool(raw.get("requires_explicit_enable", raw.get("source", "built-in") == "external")),
|
|
35
|
+
))
|
|
36
|
+
return result
|
runtime/mcp/policy.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MCP tool authorization and profile loading."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from core.contracts.agent import AgentContract
|
|
6
|
+
from core.contracts.mcp import MCPTool, MCPToolProfile
|
|
7
|
+
from core.contracts.vyrelon_runtime import ToolSideEffect
|
|
8
|
+
|
|
9
|
+
class MCPAuthorizationError(PermissionError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
class MCPToolProfileLoader:
|
|
13
|
+
def load(self, project_root: Path) -> tuple[MCPToolProfile, ...]:
|
|
14
|
+
path = project_root / ".multiagentos" / "mcp-profiles.json"
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return ()
|
|
17
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
18
|
+
result = []
|
|
19
|
+
for profile_id, item in data.get("profiles", {}).items():
|
|
20
|
+
result.append(MCPToolProfile(
|
|
21
|
+
id=profile_id,
|
|
22
|
+
server_ids=frozenset(item.get("servers", [])),
|
|
23
|
+
allowed_tools=frozenset(item.get("tools", [])),
|
|
24
|
+
denied_tools=frozenset(item.get("deny_tools", [])),
|
|
25
|
+
required_permissions=frozenset(item.get("permissions", [])),
|
|
26
|
+
allowed_side_effects=frozenset(ToolSideEffect(value) for value in item.get("side_effects", ["read"])),
|
|
27
|
+
metadata=item.get("metadata", {}),
|
|
28
|
+
tool_side_effects={key: ToolSideEffect(value) for key, value in item.get("tool_side_effects", {}).items()},
|
|
29
|
+
))
|
|
30
|
+
return tuple(result)
|
|
31
|
+
|
|
32
|
+
class MCPToolAuthorizer:
|
|
33
|
+
def __init__(self, profiles: tuple[MCPToolProfile, ...] = ()):
|
|
34
|
+
self.profiles = {p.id: p for p in profiles}
|
|
35
|
+
|
|
36
|
+
def authorize(self, agent: AgentContract, tool: MCPTool, profile_id: str | None = None) -> None:
|
|
37
|
+
if profile_id:
|
|
38
|
+
profile = self.profiles.get(profile_id)
|
|
39
|
+
if profile is None:
|
|
40
|
+
raise MCPAuthorizationError(f"MCP tool profile not found: {profile_id}")
|
|
41
|
+
if not profile.allows(tool, agent.permissions):
|
|
42
|
+
raise MCPAuthorizationError(f"MCP tool denied by profile '{profile_id}': {tool.server_id}:{tool.name}")
|
|
43
|
+
elif agent.tools and tool.name not in agent.tools and f"{tool.server_id}:{tool.name}" not in agent.tools:
|
|
44
|
+
raise MCPAuthorizationError(f"MCP tool not granted to agent: {tool.server_id}:{tool.name}")
|
|
45
|
+
if tool.permissions and not tool.permissions.issubset(agent.permissions):
|
|
46
|
+
missing = sorted(tool.permissions - agent.permissions)
|
|
47
|
+
raise MCPAuthorizationError(f"MCP tool permission denied: {tool.server_id}:{tool.name}; missing={missing}")
|
|
48
|
+
|
|
49
|
+
def filter_tools(self, agent: AgentContract, tools: tuple[MCPTool, ...], profile_id: str | None = None) -> tuple[MCPTool, ...]:
|
|
50
|
+
return tuple(tool for tool in tools if self._allowed(agent, tool, profile_id))
|
|
51
|
+
|
|
52
|
+
def _allowed(self, agent: AgentContract, tool: MCPTool, profile_id: str | None) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
self.authorize(agent, tool, profile_id)
|
|
55
|
+
return True
|
|
56
|
+
except MCPAuthorizationError:
|
|
57
|
+
return False
|
runtime/mcp/proxy.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Provider-neutral MCP tool proxy with agent authorization."""
|
|
2
|
+
from core.contracts.agent import AgentContract
|
|
3
|
+
from core.contracts.mcp import MCPTool, MCPToolCall, MCPToolResult
|
|
4
|
+
from runtime.mcp.client import MCPClient
|
|
5
|
+
from runtime.mcp.policy import MCPToolAuthorizer
|
|
6
|
+
|
|
7
|
+
class MCPToolProxy:
|
|
8
|
+
def __init__(self, clients: dict[str, MCPClient], authorizer: MCPToolAuthorizer | None = None):
|
|
9
|
+
self.clients = clients
|
|
10
|
+
self.authorizer = authorizer or MCPToolAuthorizer()
|
|
11
|
+
|
|
12
|
+
def list_tools(self, agent: AgentContract | None = None, profile_id: str | None = None) -> tuple[MCPTool, ...]:
|
|
13
|
+
tools = []
|
|
14
|
+
for client in self.clients.values():
|
|
15
|
+
tools.extend(client.list_tools())
|
|
16
|
+
result = tuple(tools)
|
|
17
|
+
return result if agent is None else self.authorizer.filter_tools(agent, result, profile_id)
|
|
18
|
+
|
|
19
|
+
def call(self, request: MCPToolCall, agent: AgentContract | None = None, profile_id: str | None = None) -> MCPToolResult:
|
|
20
|
+
client = self.clients.get(request.server_id)
|
|
21
|
+
if client is None:
|
|
22
|
+
raise KeyError(f"MCP server not connected: {request.server_id}")
|
|
23
|
+
if request.session_id and (client.session is None or client.session.id != request.session_id):
|
|
24
|
+
raise KeyError(f"MCP session mismatch for {request.server_id}")
|
|
25
|
+
if agent is not None:
|
|
26
|
+
tool = next((item for item in client.list_tools() if item.name == request.tool_name), None)
|
|
27
|
+
if tool is None:
|
|
28
|
+
raise KeyError(f"MCP tool not found: {request.server_id}:{request.tool_name}")
|
|
29
|
+
self.authorizer.authorize(agent, tool, profile_id)
|
|
30
|
+
return client.call_tool(request)
|
runtime/mcp/server.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""MCP stdio server exposing a deliberately scoped VYRELON local tool surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from runtime.builtin_tools import BuiltinToolBindings
|
|
10
|
+
from runtime.policy import ExecutionPolicy
|
|
11
|
+
from runtime.tool_calling import ToolRuntime
|
|
12
|
+
from core.contracts.vyrelon_runtime import ToolRequest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VYRELONMCPServer:
|
|
16
|
+
"""Small stdio MCP server suitable for Secure MCP Tunnel's mcp-command."""
|
|
17
|
+
|
|
18
|
+
PROTOCOL_VERSION = "2025-03-26"
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
project_root: Path,
|
|
23
|
+
*,
|
|
24
|
+
allow_write: bool = False,
|
|
25
|
+
allow_process: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.project_root = project_root.resolve()
|
|
28
|
+
self.runtime = ToolRuntime(
|
|
29
|
+
ExecutionPolicy(
|
|
30
|
+
allow_process=allow_process,
|
|
31
|
+
allow_filesystem_write=allow_write,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
BuiltinToolBindings(str(self.project_root), self.runtime)
|
|
35
|
+
|
|
36
|
+
def _visible_tools(self) -> list[dict[str, object]]:
|
|
37
|
+
result = []
|
|
38
|
+
for spec in self.runtime.specs():
|
|
39
|
+
capability = {
|
|
40
|
+
"write": "filesystem.write",
|
|
41
|
+
"execute": "process",
|
|
42
|
+
}.get(spec.side_effect.value)
|
|
43
|
+
if capability and not self.runtime.policy.permits(capability):
|
|
44
|
+
continue
|
|
45
|
+
result.append(
|
|
46
|
+
{
|
|
47
|
+
"name": spec.id,
|
|
48
|
+
"description": spec.description,
|
|
49
|
+
"inputSchema": spec.input_schema,
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def _call(self, name: str, arguments: dict[str, object]) -> dict[str, object]:
|
|
55
|
+
result = self.runtime.execute(
|
|
56
|
+
ToolRequest(name, arguments),
|
|
57
|
+
granted_permissions=frozenset(
|
|
58
|
+
{
|
|
59
|
+
"filesystem.write"
|
|
60
|
+
if self.runtime.policy.allow_filesystem_write
|
|
61
|
+
else "",
|
|
62
|
+
"process" if self.runtime.policy.allow_process else "",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
- {""},
|
|
66
|
+
approved=True,
|
|
67
|
+
)
|
|
68
|
+
if result.ok:
|
|
69
|
+
output = result.output
|
|
70
|
+
text = output if isinstance(output, str) else json.dumps(output, ensure_ascii=False, default=str)
|
|
71
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
72
|
+
return {"content": [{"type": "text", "text": result.error or "tool execution failed"}], "isError": True}
|
|
73
|
+
|
|
74
|
+
def handle(self, message: dict[str, object]) -> dict[str, object] | None:
|
|
75
|
+
method = message.get("method")
|
|
76
|
+
request_id = message.get("id")
|
|
77
|
+
|
|
78
|
+
if method == "notifications/initialized":
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
if method == "initialize":
|
|
82
|
+
return {
|
|
83
|
+
"jsonrpc": "2.0",
|
|
84
|
+
"id": request_id,
|
|
85
|
+
"result": {
|
|
86
|
+
"protocolVersion": self.PROTOCOL_VERSION,
|
|
87
|
+
"capabilities": {"tools": {}},
|
|
88
|
+
"serverInfo": {"name": "VYRELON", "version": "0.3.0"},
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if method == "tools/list":
|
|
93
|
+
return {
|
|
94
|
+
"jsonrpc": "2.0",
|
|
95
|
+
"id": request_id,
|
|
96
|
+
"result": {"tools": self._visible_tools()},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if method == "tools/call":
|
|
100
|
+
params = message.get("params")
|
|
101
|
+
if not isinstance(params, dict):
|
|
102
|
+
return self._error(request_id, -32602, "params must be an object")
|
|
103
|
+
name = params.get("name")
|
|
104
|
+
arguments = params.get("arguments", {})
|
|
105
|
+
if not isinstance(name, str) or not name:
|
|
106
|
+
return self._error(request_id, -32602, "tool name is required")
|
|
107
|
+
if not isinstance(arguments, dict):
|
|
108
|
+
return self._error(request_id, -32602, "tool arguments must be an object")
|
|
109
|
+
return {
|
|
110
|
+
"jsonrpc": "2.0",
|
|
111
|
+
"id": request_id,
|
|
112
|
+
"result": self._call(name, dict(arguments)),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return self._error(request_id, -32601, f"method not found: {method}")
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _error(request_id: object, code: int, message: str) -> dict[str, object]:
|
|
119
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
120
|
+
|
|
121
|
+
def serve_forever(self, stdin=None, stdout=None) -> None:
|
|
122
|
+
stdin = stdin or sys.stdin
|
|
123
|
+
stdout = stdout or sys.stdout
|
|
124
|
+
for line in stdin:
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if not line:
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
message = json.loads(line)
|
|
130
|
+
if not isinstance(message, dict):
|
|
131
|
+
raise ValueError("JSON-RPC message must be an object")
|
|
132
|
+
response = self.handle(message)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
response = self._error(None, -32603, str(exc))
|
|
135
|
+
if response is not None:
|
|
136
|
+
stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
137
|
+
stdout.flush()
|
runtime/mcp/session.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""MCP session registry."""
|
|
2
|
+
from runtime.mcp.client import MCPClient, MCPError
|
|
3
|
+
from core.contracts.mcp import MCPSession
|
|
4
|
+
|
|
5
|
+
class MCPSessionRegistry:
|
|
6
|
+
def __init__(self): self._clients={}
|
|
7
|
+
def add(self,client:MCPClient)->MCPSession:
|
|
8
|
+
if client.session is None: raise MCPError("MCP client is not connected")
|
|
9
|
+
self._clients[client.session.id]=client
|
|
10
|
+
return client.session
|
|
11
|
+
def get(self,session_id:str)->MCPClient:
|
|
12
|
+
if session_id not in self._clients: raise MCPError(f"MCP session not found: {session_id}")
|
|
13
|
+
return self._clients[session_id]
|
|
14
|
+
def close(self,session_id:str)->None:
|
|
15
|
+
client=self._clients.pop(session_id,None)
|
|
16
|
+
if client: client.close()
|
|
17
|
+
def close_all(self)->None:
|
|
18
|
+
for client in list(self._clients.values()): client.close()
|
|
19
|
+
self._clients.clear()
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Provider-neutral AI execution runtime for VYRELON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from core.contracts.ai import ModelSpec
|
|
9
|
+
from core.contracts.model_runtime import ModelAdapter, ModelRequest, ModelResponse
|
|
10
|
+
from core.contracts.vyrelon_runtime import (
|
|
11
|
+
FallbackPolicy,
|
|
12
|
+
RuntimeEvent,
|
|
13
|
+
RuntimeEventKind,
|
|
14
|
+
SessionSpec,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AIRuntimeError(RuntimeError):
|
|
19
|
+
"""Base error raised by the VYRELON AI runtime."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AIExecutionError(AIRuntimeError):
|
|
23
|
+
"""Raised when all configured model attempts fail."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, attempts: tuple[str, ...]) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.attempts = attempts
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AIExecution:
|
|
32
|
+
"""Normalized result of one AI runtime invocation."""
|
|
33
|
+
|
|
34
|
+
response: ModelResponse
|
|
35
|
+
model_id: str
|
|
36
|
+
attempts: tuple[str, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AIRuntime:
|
|
40
|
+
"""Execute provider-neutral model adapters without a harness abstraction."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
models: dict[str, ModelSpec],
|
|
46
|
+
adapters: dict[str, ModelAdapter],
|
|
47
|
+
) -> None:
|
|
48
|
+
self._models = models
|
|
49
|
+
self._adapters = adapters
|
|
50
|
+
|
|
51
|
+
def execute(
|
|
52
|
+
self,
|
|
53
|
+
request: ModelRequest,
|
|
54
|
+
*,
|
|
55
|
+
model_id: str,
|
|
56
|
+
session: SessionSpec | None = None,
|
|
57
|
+
fallback: FallbackPolicy | None = None,
|
|
58
|
+
) -> AIExecution:
|
|
59
|
+
candidates = self._candidates(model_id, fallback)
|
|
60
|
+
attempts: list[str] = []
|
|
61
|
+
errors: list[str] = []
|
|
62
|
+
|
|
63
|
+
for candidate in candidates:
|
|
64
|
+
attempts.append(candidate)
|
|
65
|
+
model = self._model(candidate)
|
|
66
|
+
adapter = self._adapter(model)
|
|
67
|
+
try:
|
|
68
|
+
response = adapter.generate(model, request)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
if not self._retryable(exc, fallback):
|
|
71
|
+
raise
|
|
72
|
+
errors.append(f"{candidate}: {exc}")
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
metadata = dict(response.metadata)
|
|
76
|
+
metadata.update(
|
|
77
|
+
{"provider_id": model.provider_id, "attempts": tuple(attempts)}
|
|
78
|
+
)
|
|
79
|
+
if session is not None:
|
|
80
|
+
metadata["session_id"] = session.id
|
|
81
|
+
normalized = ModelResponse(
|
|
82
|
+
text=response.text,
|
|
83
|
+
model_id=response.model_id or candidate,
|
|
84
|
+
metadata=metadata,
|
|
85
|
+
)
|
|
86
|
+
return AIExecution(
|
|
87
|
+
response=normalized,
|
|
88
|
+
model_id=normalized.model_id,
|
|
89
|
+
attempts=tuple(attempts),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
detail = "; ".join(errors) if errors else "no model candidates"
|
|
93
|
+
raise AIExecutionError(
|
|
94
|
+
f"AI execution failed: {detail}",
|
|
95
|
+
attempts=tuple(attempts),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def events(
|
|
99
|
+
self,
|
|
100
|
+
request: ModelRequest,
|
|
101
|
+
*,
|
|
102
|
+
model_id: str,
|
|
103
|
+
session: SessionSpec | None = None,
|
|
104
|
+
fallback: FallbackPolicy | None = None,
|
|
105
|
+
) -> Iterator[RuntimeEvent]:
|
|
106
|
+
"""Expose normalized lifecycle events around a model invocation."""
|
|
107
|
+
sequence = 0
|
|
108
|
+
|
|
109
|
+
def emit(kind: RuntimeEventKind, payload: object = None):
|
|
110
|
+
nonlocal sequence
|
|
111
|
+
sequence += 1
|
|
112
|
+
return RuntimeEvent(
|
|
113
|
+
kind=kind,
|
|
114
|
+
session_id=session.id if session else None,
|
|
115
|
+
payload=payload,
|
|
116
|
+
sequence=sequence,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
yield emit(RuntimeEventKind.REQUEST, {"model_id": model_id})
|
|
120
|
+
try:
|
|
121
|
+
execution = self.execute(
|
|
122
|
+
request,
|
|
123
|
+
model_id=model_id,
|
|
124
|
+
session=session,
|
|
125
|
+
fallback=fallback,
|
|
126
|
+
)
|
|
127
|
+
yield emit(
|
|
128
|
+
RuntimeEventKind.MESSAGE,
|
|
129
|
+
{"model_id": execution.model_id, "text": execution.response.text},
|
|
130
|
+
)
|
|
131
|
+
yield emit(
|
|
132
|
+
RuntimeEventKind.COMPLETED,
|
|
133
|
+
{"model_id": execution.model_id, "attempts": execution.attempts},
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
yield emit(RuntimeEventKind.ERROR, {"error": str(exc)})
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def _candidates(
|
|
140
|
+
self, model_id: str, fallback: FallbackPolicy | None
|
|
141
|
+
) -> tuple[str, ...]:
|
|
142
|
+
if fallback is None:
|
|
143
|
+
return (model_id,)
|
|
144
|
+
candidates = fallback.candidates()
|
|
145
|
+
return candidates or (model_id,)
|
|
146
|
+
|
|
147
|
+
def _model(self, model_id: str) -> ModelSpec:
|
|
148
|
+
try:
|
|
149
|
+
return self._models[model_id]
|
|
150
|
+
except KeyError as exc:
|
|
151
|
+
raise AIRuntimeError(f"Model not registered: {model_id}") from exc
|
|
152
|
+
|
|
153
|
+
def _adapter(self, model: ModelSpec) -> ModelAdapter:
|
|
154
|
+
try:
|
|
155
|
+
return self._adapters[model.id]
|
|
156
|
+
except KeyError as exc:
|
|
157
|
+
raise AIRuntimeError(
|
|
158
|
+
f"Model adapter not registered: {model.id}"
|
|
159
|
+
) from exc
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _retryable(exc: Exception, fallback: FallbackPolicy | None) -> bool:
|
|
163
|
+
if fallback is None or not fallback.retryable_errors:
|
|
164
|
+
return False
|
|
165
|
+
return exc.__class__.__name__ in fallback.retryable_errors
|
runtime/model/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Generic CLI model adapter for any model exposed by a command-line client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from core.contracts.ai import ModelSpec
|
|
10
|
+
from core.contracts.model_runtime import ModelRequest, ModelResponse
|
|
11
|
+
from runtime.policy import ExecutionPolicy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CLIModelAdapter:
|
|
16
|
+
command: tuple[str, ...]
|
|
17
|
+
policy: ExecutionPolicy = ExecutionPolicy()
|
|
18
|
+
timeout: float = 300
|
|
19
|
+
|
|
20
|
+
def generate(self, model: ModelSpec, request: ModelRequest) -> ModelResponse:
|
|
21
|
+
if not self.policy.permits("process"):
|
|
22
|
+
raise PermissionError("Model CLI execution is disabled by policy")
|
|
23
|
+
if not self.command or not all(isinstance(part, str) and part for part in self.command):
|
|
24
|
+
raise ValueError("CLI adapter command must be a non-empty argv tuple")
|
|
25
|
+
|
|
26
|
+
prompt = request.prompt
|
|
27
|
+
if request.system:
|
|
28
|
+
prompt = f"{request.system}\n\n{prompt}"
|
|
29
|
+
|
|
30
|
+
environment = os.environ.copy()
|
|
31
|
+
completed = subprocess.run(
|
|
32
|
+
list(self.command),
|
|
33
|
+
input=prompt,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
timeout=self.timeout,
|
|
37
|
+
check=False,
|
|
38
|
+
env=environment,
|
|
39
|
+
)
|
|
40
|
+
if completed.returncode != 0:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"Model CLI failed with exit code {completed.returncode}: {completed.stderr.strip()}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return ModelResponse(
|
|
46
|
+
text=completed.stdout,
|
|
47
|
+
model_id=model.id,
|
|
48
|
+
metadata={"adapter": "cli"},
|
|
49
|
+
)
|