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.
Files changed (122) hide show
  1. agents/__init__.py +1 -0
  2. agents/catalog.py +52 -0
  3. agents/registry.py +11 -0
  4. core/__init__.py +38 -0
  5. core/artifacts.py +47 -0
  6. core/chat_agent_bridge.py +280 -0
  7. core/chat_agent_registry.py +61 -0
  8. core/chat_agent_router.py +85 -0
  9. core/chat_session.py +62 -0
  10. core/contracts/__init__.py +22 -0
  11. core/contracts/agent.py +21 -0
  12. core/contracts/ai.py +25 -0
  13. core/contracts/capability.py +44 -0
  14. core/contracts/chat_agent.py +34 -0
  15. core/contracts/checkpoint.py +78 -0
  16. core/contracts/discovery.py +30 -0
  17. core/contracts/execution.py +28 -0
  18. core/contracts/github.py +74 -0
  19. core/contracts/handoff.py +56 -0
  20. core/contracts/health.py +206 -0
  21. core/contracts/human_review.py +20 -0
  22. core/contracts/ide.py +113 -0
  23. core/contracts/mcp.py +76 -0
  24. core/contracts/model_runtime.py +25 -0
  25. core/contracts/planning.py +25 -0
  26. core/contracts/profile.py +66 -0
  27. core/contracts/quota.py +62 -0
  28. core/contracts/repository.py +34 -0
  29. core/contracts/resume.py +55 -0
  30. core/contracts/runtime.py +19 -0
  31. core/contracts/vyrelon_runtime.py +118 -0
  32. core/contracts/work_unit.py +48 -0
  33. core/delegation.py +33 -0
  34. core/handoff.py +68 -0
  35. core/lifecycle.py +85 -0
  36. core/multi_agent_workflow.py +574 -0
  37. core/orchestrator.py +51 -0
  38. core/planning.py +22 -0
  39. core/registry.py +44 -0
  40. core/routing.py +195 -0
  41. core/state.py +155 -0
  42. installer/__init__.py +1 -0
  43. installer/init.py +71 -0
  44. integrations/__init__.py +0 -0
  45. integrations/github/__init__.py +3 -0
  46. integrations/github/gateway.py +164 -0
  47. integrations/openai/__init__.py +1 -0
  48. integrations/openai/chat_agent.py +147 -0
  49. multiagentos/__init__.py +1 -0
  50. multiagentos/cli.py +759 -0
  51. multiagentos-0.4.0.dist-info/METADATA +7 -0
  52. multiagentos-0.4.0.dist-info/RECORD +122 -0
  53. multiagentos-0.4.0.dist-info/WHEEL +5 -0
  54. multiagentos-0.4.0.dist-info/entry_points.txt +2 -0
  55. multiagentos-0.4.0.dist-info/top_level.txt +7 -0
  56. profiles/__init__.py +1 -0
  57. profiles/common.py +11 -0
  58. profiles/detector.py +67 -0
  59. profiles/registry.py +46 -0
  60. profiles/resolver.py +63 -0
  61. runtime/__init__.py +5 -0
  62. runtime/agent/__init__.py +1 -0
  63. runtime/agent/ide.py +74 -0
  64. runtime/agent/model.py +197 -0
  65. runtime/agent/model_backed.py +49 -0
  66. runtime/agent/process.py +21 -0
  67. runtime/builtin_tools.py +57 -0
  68. runtime/capability.py +143 -0
  69. runtime/chat_adapter_registry.py +43 -0
  70. runtime/chat_config.py +57 -0
  71. runtime/chat_execution.py +52 -0
  72. runtime/config/__init__.py +1 -0
  73. runtime/config/loader.py +67 -0
  74. runtime/discovery.py +334 -0
  75. runtime/execution_config.py +71 -0
  76. runtime/execution_registry.py +50 -0
  77. runtime/git.py +84 -0
  78. runtime/github.py +56 -0
  79. runtime/github_probe.py +23 -0
  80. runtime/health.py +14 -0
  81. runtime/ide/__init__.py +6 -0
  82. runtime/ide/bridge.py +198 -0
  83. runtime/ide/registry.py +24 -0
  84. runtime/ide/runtime.py +35 -0
  85. runtime/local/__init__.py +18 -0
  86. runtime/local/filesystem.py +58 -0
  87. runtime/local/patch.py +79 -0
  88. runtime/local/path_security.py +31 -0
  89. runtime/local/permissions.py +20 -0
  90. runtime/local/process.py +56 -0
  91. runtime/local/shell.py +97 -0
  92. runtime/mcp/__init__.py +7 -0
  93. runtime/mcp/client.py +120 -0
  94. runtime/mcp/config.py +36 -0
  95. runtime/mcp/policy.py +57 -0
  96. runtime/mcp/proxy.py +30 -0
  97. runtime/mcp/server.py +137 -0
  98. runtime/mcp/session.py +19 -0
  99. runtime/model/__init__.py +5 -0
  100. runtime/model/ai_runtime.py +165 -0
  101. runtime/model/cli.py +49 -0
  102. runtime/model/config.py +98 -0
  103. runtime/model/credentials.py +42 -0
  104. runtime/model/factory.py +82 -0
  105. runtime/model/http.py +78 -0
  106. runtime/model/invoker.py +16 -0
  107. runtime/model/materializer.py +101 -0
  108. runtime/model/native.py +244 -0
  109. runtime/model/providers.py +55 -0
  110. runtime/model/registry.py +26 -0
  111. runtime/model/runtime_loader.py +51 -0
  112. runtime/model_control.py +136 -0
  113. runtime/multi_agent.py +142 -0
  114. runtime/policy.py +28 -0
  115. runtime/process.py +41 -0
  116. runtime/quota.py +259 -0
  117. runtime/repository.py +103 -0
  118. runtime/repository_tools.py +62 -0
  119. runtime/status.py +87 -0
  120. runtime/tool_calling.py +134 -0
  121. runtime/validation.py +121 -0
  122. 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,5 @@
1
+ """Provider-neutral VYRELON model runtime."""
2
+
3
+ from runtime.model.ai_runtime import AIExecution, AIExecutionError, AIRuntime
4
+
5
+ __all__ = ["AIExecution", "AIExecutionError", "AIRuntime"]
@@ -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
+ )