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/quota.py ADDED
@@ -0,0 +1,259 @@
1
+ """Persistent quota intelligence for VYRELON model routing."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from core.contracts.ai import ModelSpec
10
+ from core.contracts.quota import QuotaConfidence, QuotaDimension, QuotaSnapshot
11
+
12
+
13
+ def _now() -> datetime:
14
+ return datetime.now(timezone.utc)
15
+
16
+
17
+ def _parse_dt(value: object) -> datetime | None:
18
+ if not value:
19
+ return None
20
+ try:
21
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
22
+ except ValueError:
23
+ return None
24
+
25
+
26
+ class QuotaStore:
27
+ """Persist the latest quota snapshot per model without storing credentials."""
28
+
29
+ def __init__(self, root: Path) -> None:
30
+ self.root = Path(root)
31
+ self.root.mkdir(parents=True, exist_ok=True)
32
+
33
+ def path_for(self, model_id: str) -> Path:
34
+ safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in model_id)
35
+ return self.root / f"{safe}.json"
36
+
37
+ def save(self, snapshot: QuotaSnapshot) -> Path:
38
+ payload = {
39
+ "model_id": snapshot.model_id,
40
+ "provider_id": snapshot.provider_id,
41
+ "observed_at": snapshot.observed_at.isoformat(),
42
+ "scope": snapshot.scope,
43
+ "metadata": snapshot.metadata,
44
+ "dimensions": [
45
+ {
46
+ "name": item.name,
47
+ "limit": item.limit,
48
+ "used": item.used,
49
+ "remaining": item.remaining,
50
+ "reset_at": item.reset_at.isoformat() if item.reset_at else None,
51
+ "window_seconds": item.window_seconds,
52
+ "confidence": item.confidence.value,
53
+ "source": item.source,
54
+ }
55
+ for item in snapshot.dimensions
56
+ ],
57
+ }
58
+ path = self.path_for(snapshot.model_id)
59
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
60
+ return path
61
+
62
+ def load(self, model_id: str) -> QuotaSnapshot:
63
+ data = json.loads(self.path_for(model_id).read_text(encoding="utf-8"))
64
+ dimensions = tuple(
65
+ QuotaDimension(
66
+ name=item["name"],
67
+ limit=item.get("limit"),
68
+ used=item.get("used"),
69
+ remaining=item.get("remaining"),
70
+ reset_at=_parse_dt(item.get("reset_at")),
71
+ window_seconds=item.get("window_seconds"),
72
+ confidence=QuotaConfidence(item.get("confidence", "unknown")),
73
+ source=item.get("source", "unknown"),
74
+ )
75
+ for item in data.get("dimensions", ())
76
+ )
77
+ observed_at = _parse_dt(data.get("observed_at")) or _now()
78
+ return QuotaSnapshot(
79
+ model_id=data["model_id"],
80
+ provider_id=data["provider_id"],
81
+ observed_at=observed_at,
82
+ dimensions=dimensions,
83
+ scope=data.get("scope", "model"),
84
+ metadata=dict(data.get("metadata", {})),
85
+ )
86
+
87
+ def exists(self, model_id: str) -> bool:
88
+ return self.path_for(model_id).exists()
89
+
90
+ def list_model_ids(self) -> tuple[str, ...]:
91
+ return tuple(sorted(path.stem for path in self.root.glob("*.json")))
92
+
93
+
94
+ def merge(self, snapshot: QuotaSnapshot) -> QuotaSnapshot:
95
+ """Merge a newer snapshot while preserving stronger existing dimensions."""
96
+ if not self.exists(snapshot.model_id):
97
+ self.save(snapshot)
98
+ return snapshot
99
+ current = self.load(snapshot.model_id)
100
+ if current.provider_id != snapshot.provider_id:
101
+ raise ValueError(f"Quota provider mismatch for {snapshot.model_id}")
102
+ rank = {
103
+ QuotaConfidence.UNKNOWN: 0,
104
+ QuotaConfidence.OBSERVED: 1,
105
+ QuotaConfidence.ESTIMATED: 2,
106
+ QuotaConfidence.ACTUAL: 3,
107
+ }
108
+ merged = {item.name: item for item in current.dimensions}
109
+ for item in snapshot.dimensions:
110
+ existing = merged.get(item.name)
111
+ if existing is None or rank[item.confidence] >= rank[existing.confidence]:
112
+ merged[item.name] = item
113
+ combined = QuotaSnapshot(
114
+ model_id=snapshot.model_id,
115
+ provider_id=snapshot.provider_id,
116
+ observed_at=max(current.observed_at, snapshot.observed_at),
117
+ dimensions=tuple(merged.values()),
118
+ scope=snapshot.scope or current.scope,
119
+ metadata={**current.metadata, **snapshot.metadata},
120
+ )
121
+ self.save(combined)
122
+ return combined
123
+
124
+
125
+
126
+ class QuotaIntelligence:
127
+ """Turn provider metadata and configured limits into normalized snapshots."""
128
+
129
+ def __init__(self, store: QuotaStore) -> None:
130
+ self.store = store
131
+
132
+ def observe_response(self, model: ModelSpec, metadata: dict[str, Any]) -> QuotaSnapshot:
133
+ dimensions: list[QuotaDimension] = []
134
+ raw_limits = model.metadata.get("quota_limits", {})
135
+ configured = raw_limits if isinstance(raw_limits, dict) else {}
136
+ raw_actual = metadata.get("rate_limits", {})
137
+ actual = raw_actual if isinstance(raw_actual, dict) else {}
138
+ usage = metadata.get("usage", {})
139
+ usage = usage if isinstance(usage, dict) else {}
140
+
141
+ for name, value in actual.items():
142
+ if not isinstance(value, dict):
143
+ continue
144
+ limit = _number(value.get("limit"))
145
+ remaining = _number(value.get("remaining"))
146
+ reset_at = _parse_dt(value.get("reset_at"))
147
+ reset_seconds = _number(value.get("reset_seconds"))
148
+ if reset_at is None and reset_seconds is not None:
149
+ reset_at = datetime.fromtimestamp(
150
+ _now().timestamp() + float(reset_seconds), tz=timezone.utc
151
+ )
152
+ used = limit - remaining if limit is not None and remaining is not None else None
153
+ dimensions.append(
154
+ QuotaDimension(
155
+ name=name,
156
+ limit=limit,
157
+ used=used,
158
+ remaining=remaining,
159
+ reset_at=reset_at,
160
+ confidence=QuotaConfidence.ACTUAL,
161
+ source=str(value.get("source", "provider")),
162
+ )
163
+ )
164
+
165
+ usage_map = {
166
+ "requests": 1,
167
+ "input_tokens": usage.get("input_tokens"),
168
+ "output_tokens": usage.get("output_tokens"),
169
+ "total_tokens": usage.get("total_tokens"),
170
+ }
171
+ for name, increment in usage_map.items():
172
+ if increment is None or any(item.name == name for item in dimensions):
173
+ continue
174
+ limit_spec = configured.get(name)
175
+ if not isinstance(limit_spec, dict):
176
+ dimensions.append(
177
+ QuotaDimension(name=name, used=_number(increment), confidence=QuotaConfidence.OBSERVED, source="response_usage")
178
+ )
179
+ continue
180
+ limit = _number(limit_spec.get("limit"))
181
+ prior_used = 0
182
+ if self.store.exists(model.id):
183
+ previous = self.store.load(model.id).dimension(name)
184
+ prior_used = float(previous.used or 0) if previous else 0
185
+ used = prior_used + float(increment)
186
+ remaining = max(0, limit - used) if limit is not None else None
187
+ dimensions.append(
188
+ QuotaDimension(
189
+ name=name,
190
+ limit=limit,
191
+ used=used,
192
+ remaining=remaining,
193
+ reset_at=_parse_dt(limit_spec.get("reset_at")),
194
+ window_seconds=int(limit_spec["window_seconds"]) if limit_spec.get("window_seconds") else None,
195
+ confidence=QuotaConfidence.ESTIMATED if limit is not None else QuotaConfidence.OBSERVED,
196
+ source="configured_limit",
197
+ )
198
+ )
199
+
200
+ merged: dict[str, QuotaDimension] = {}
201
+ if self.store.exists(model.id):
202
+ for item in self.store.load(model.id).dimensions:
203
+ merged[item.name] = item
204
+ for item in dimensions:
205
+ merged[item.name] = item
206
+
207
+ snapshot = QuotaSnapshot(
208
+ model_id=model.id,
209
+ provider_id=model.provider_id,
210
+ observed_at=_now(),
211
+ dimensions=tuple(merged.values()),
212
+ scope=str(model.metadata.get("quota_scope", "model")),
213
+ )
214
+ return self.store.merge(snapshot)
215
+
216
+
217
+ def _number(value: object) -> int | float | None:
218
+ if value is None:
219
+ return None
220
+ try:
221
+ number = float(value)
222
+ except (TypeError, ValueError):
223
+ return None
224
+ return int(number) if number.is_integer() else number
225
+
226
+
227
+ def quota_available(snapshot: QuotaSnapshot, threshold: float = 0.05) -> bool:
228
+ """Return whether any known limiting dimension has usable capacity."""
229
+ now = _now()
230
+ known = [
231
+ item for item in snapshot.dimensions
232
+ if item.remaining is not None
233
+ and not (item.reset_at is not None and item.reset_at <= now)
234
+ ]
235
+ if not known:
236
+ return True
237
+ return all(
238
+ float(item.remaining) > 0
239
+ and (
240
+ item.limit in (None, 0)
241
+ or float(item.remaining) / float(item.limit) > threshold
242
+ )
243
+ for item in known
244
+ )
245
+
246
+
247
+ def quota_score(snapshot: QuotaSnapshot) -> float:
248
+ """Return a normalized 0..1 score for routing among known quotas."""
249
+ now = _now()
250
+ known = [
251
+ item
252
+ for item in snapshot.dimensions
253
+ if item.remaining is not None
254
+ and item.limit not in (None, 0)
255
+ and not (item.reset_at is not None and item.reset_at <= now)
256
+ ]
257
+ if not known:
258
+ return 0.5
259
+ return max(0.0, min(1.0, min(float(item.remaining) / float(item.limit) for item in known)))
runtime/repository.py ADDED
@@ -0,0 +1,103 @@
1
+ """Repository runtime combining Git, GitHub, checkpoints, recovery, and evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from core.contracts.repository import RecoveryResult, RepositoryCheckpoint, RepositoryEvidence
11
+ from runtime.git import GitRuntime
12
+ from runtime.github import GitHubRuntime
13
+ from runtime.policy import ExecutionPolicy
14
+
15
+
16
+ class RepositoryRuntime:
17
+ def __init__(
18
+ self,
19
+ git: GitRuntime,
20
+ github: GitHubRuntime,
21
+ policy: ExecutionPolicy | None = None,
22
+ ) -> None:
23
+ self.policy = policy or ExecutionPolicy()
24
+ self.git = git
25
+ self.github = github
26
+
27
+ def checkpoint(self, project_root: Path, *, metadata: dict[str, object] | None = None) -> RepositoryCheckpoint:
28
+ checkpoint_id = f"cp-{uuid.uuid4().hex[:12]}"
29
+ marker = f"VYRELON checkpoint {checkpoint_id}"
30
+ initial_status = self.git.status(project_root.as_posix())
31
+ has_changes = bool(initial_status.stdout.splitlines()[1:])
32
+ stashed = False
33
+ if has_changes:
34
+ result = self.git.stash(project_root.as_posix(), "push", marker)
35
+ stashed = result.returncode == 0
36
+ if not stashed:
37
+ raise RuntimeError(result.stderr.strip() or "failed to create Git checkpoint")
38
+ status = self.git.status(project_root.as_posix())
39
+ diff = self.git.diff(project_root.as_posix())
40
+ checkpoint = RepositoryCheckpoint(
41
+ id=checkpoint_id,
42
+ project_root=str(project_root),
43
+ marker=marker,
44
+ status=status.stdout,
45
+ diff=diff.stdout,
46
+ created_at=datetime.now(timezone.utc).isoformat(),
47
+ metadata={**dict(metadata or {}), "stashed": stashed},
48
+ )
49
+ self._persist_checkpoint(project_root, checkpoint)
50
+ return checkpoint
51
+
52
+ def recover(self, project_root: Path, checkpoint: RepositoryCheckpoint) -> RecoveryResult:
53
+ if not bool(checkpoint.metadata.get("stashed", False)):
54
+ return RecoveryResult(checkpoint.id, True, "No local changes required recovery", "")
55
+ result = self.git.stash(project_root.as_posix(), "pop")
56
+ if result.returncode == 0:
57
+ return RecoveryResult(checkpoint.id, True, result.stdout, "")
58
+ return RecoveryResult(checkpoint.id, False, result.stdout, result.stderr)
59
+
60
+ def evidence(
61
+ self,
62
+ project_root: Path,
63
+ *,
64
+ repository: str | None = None,
65
+ ref: str | None = None,
66
+ validation: dict[str, object] | None = None,
67
+ ) -> RepositoryEvidence:
68
+ status = self.git.status(project_root.as_posix())
69
+ diff = self.git.diff(project_root.as_posix())
70
+ workflows = ()
71
+ if repository and ref:
72
+ workflows = tuple(self.github.gateway.list_workflows(repository, ref))
73
+ evidence = RepositoryEvidence(
74
+ checkpoint_id=self._latest_checkpoint_id(project_root),
75
+ git_status=status.stdout,
76
+ git_diff=diff.stdout,
77
+ workflows=workflows,
78
+ validation=validation,
79
+ metadata={"runtime": "vyrelon-repository"},
80
+ )
81
+ path = project_root / ".multiagentos" / "evidence" / "repository.json"
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ path.write_text(json.dumps({
84
+ "checkpoint_id": evidence.checkpoint_id,
85
+ "git_status": evidence.git_status,
86
+ "git_diff": evidence.git_diff,
87
+ "workflows": [getattr(item, "__dict__", str(item)) for item in evidence.workflows],
88
+ "validation": evidence.validation,
89
+ "metadata": evidence.metadata,
90
+ }, indent=2, default=str) + "\n", encoding="utf-8")
91
+ return evidence
92
+
93
+ def _persist_checkpoint(self, project_root: Path, checkpoint: RepositoryCheckpoint) -> None:
94
+ path = project_root / ".multiagentos" / "checkpoints" / f"{checkpoint.id}.json"
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(json.dumps(checkpoint.__dict__, indent=2) + "\n", encoding="utf-8")
97
+
98
+ def _latest_checkpoint_id(self, project_root: Path) -> str | None:
99
+ directory = project_root / ".multiagentos" / "checkpoints"
100
+ if not directory.exists():
101
+ return None
102
+ files = sorted(directory.glob("cp-*.json"), key=lambda item: item.stat().st_mtime, reverse=True)
103
+ return files[0].stem if files else None
@@ -0,0 +1,62 @@
1
+ """Repository and MCP bindings for the normalized ToolRuntime."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from core.contracts.mcp import MCPToolCall
5
+ from core.contracts.vyrelon_runtime import ToolRequest, ToolSideEffect, ToolSpec
6
+ from runtime.git import GitRuntime
7
+ from runtime.mcp.client import MCPClient
8
+ from runtime.tool_calling import ToolRuntime
9
+
10
+ @dataclass
11
+ class GitToolBindings:
12
+ project_root: str
13
+ runtime: ToolRuntime
14
+ git: GitRuntime | None = None
15
+
16
+ def __post_init__(self) -> None:
17
+ self.git = self.git or GitRuntime(policy=self.runtime.policy)
18
+ self._register()
19
+
20
+ def _register(self) -> None:
21
+ self.runtime.register(ToolSpec("git.status","Show Git working tree status.",ToolSideEffect.READ,input_schema={"type":"object"}),self._status)
22
+ self.runtime.register(ToolSpec("git.diff","Show Git diff.",ToolSideEffect.READ,input_schema={"type":"object"}),self._diff)
23
+ self.runtime.register(ToolSpec("git.log","Show recent Git commits.",ToolSideEffect.READ,input_schema={"type":"object"}),self._log)
24
+ self.runtime.register(ToolSpec("git.add","Stage Git paths.",ToolSideEffect.WRITE,frozenset({"git.write"}),{"type":"object"}),self._add)
25
+ self.runtime.register(ToolSpec("git.commit","Create a Git commit.",ToolSideEffect.WRITE,frozenset({"git.write"}),{"type":"object","required":["message"]}),self._commit)
26
+ self.runtime.register(ToolSpec("git.push","Push Git changes.",ToolSideEffect.NETWORK,frozenset({"git.write","network"}),{"type":"object"}),self._push)
27
+
28
+ def _result(self,result: object) -> object:
29
+ return {"returncode":result.returncode,"stdout":result.stdout,"stderr":result.stderr}
30
+
31
+ def _status(self,r: ToolRequest): return self._result(self.git.status(self.project_root))
32
+ def _diff(self,r: ToolRequest): return self._result(self.git.diff(self.project_root,staged=r.arguments.get("staged") is True,path=r.arguments.get("path") if isinstance(r.arguments.get("path"),str) else None))
33
+ def _log(self,r: ToolRequest): return self._result(self.git.log(self.project_root,int(r.arguments.get("count",10))))
34
+ def _add(self,r: ToolRequest): return self._result(self.git.add(self.project_root,list(r.arguments.get("paths",[])) or None))
35
+ def _commit(self,r: ToolRequest):
36
+ message=r.arguments.get("message")
37
+ if not isinstance(message,str) or not message.strip(): raise ValueError("message must be a non-empty string")
38
+ return self._result(self.git.commit(self.project_root,message,approved=r.metadata.get("approved") is True))
39
+ def _push(self,r: ToolRequest):
40
+ return self._result(self.git.push(self.project_root,str(r.arguments.get("remote","origin")),r.arguments.get("branch") if isinstance(r.arguments.get("branch"),str) else None,approved=r.metadata.get("approved") is True))
41
+
42
+ @dataclass
43
+ class MCPToolBindings:
44
+ runtime: ToolRuntime
45
+ client: MCPClient
46
+
47
+ def register_tools(self, tools: tuple[object,...] | None = None) -> tuple[str,...]:
48
+ available=tools if tools is not None else self.client.list_tools()
49
+ ids=[]
50
+ for tool in available:
51
+ qualified=f"mcp.{tool.server_id}.{tool.name}"
52
+ self.runtime.register(ToolSpec(qualified,tool.description or qualified,tool.side_effect,tool.permissions,tool.input_schema,{"source":"mcp","server_id":tool.server_id,"tool_name":tool.name}),self._handler(tool.server_id,tool.name))
53
+ ids.append(qualified)
54
+ return tuple(ids)
55
+
56
+ def _handler(self,server_id:str,tool_name:str):
57
+ def invoke(request:ToolRequest):
58
+ result=self.client.call_tool(MCPToolCall(server_id,tool_name,request.arguments,request.session_id))
59
+ if result.is_error:
60
+ raise RuntimeError(f"MCP tool error: {server_id}:{tool_name}")
61
+ return {"content":result.content,"raw":result.raw}
62
+ return invoke
runtime/status.py ADDED
@@ -0,0 +1,87 @@
1
+ """Project-level VYRELON installation and workflow status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+
9
+ def project_status(project_root: Path) -> dict[str, object]:
10
+ """Return a read-only summary of installed components and durable state."""
11
+ root = project_root.expanduser().resolve()
12
+ target = root / ".multiagentos"
13
+ result: dict[str, object] = {
14
+ "project_root": str(root),
15
+ "initialized": target.is_dir(),
16
+ "components": [],
17
+ "profiles": [],
18
+ "execution": None,
19
+ "chat": None,
20
+ "agents": [],
21
+ "work_units": [],
22
+ "checkpoints": [],
23
+ }
24
+ if not target.is_dir():
25
+ return result
26
+
27
+ components_path = target / "components.json"
28
+ if components_path.exists():
29
+ data = json.loads(components_path.read_text(encoding="utf-8"))
30
+ result["components"] = list(data.get("components", []))
31
+
32
+ profile_path = target / "profile.json"
33
+ if profile_path.exists():
34
+ data = json.loads(profile_path.read_text(encoding="utf-8"))
35
+ result["profiles"] = list(data.get("profiles", []))
36
+
37
+ chat_path = target / "chat.json"
38
+ if chat_path.exists():
39
+ data = json.loads(chat_path.read_text(encoding="utf-8"))
40
+ result["chat"] = {
41
+ "agent_id": data.get("agent_id", ""),
42
+ "model": data.get("model"),
43
+ }
44
+
45
+ execution_path = target / "execution.json"
46
+ if execution_path.exists():
47
+ data = json.loads(execution_path.read_text(encoding="utf-8"))
48
+ result["execution"] = {
49
+ "runtime": data.get("runtime", ""),
50
+ "agent_id": data.get("agent_id", ""),
51
+ "model_id": data.get("model_id", ""),
52
+ }
53
+
54
+ agents_path = target / "agents.json"
55
+ if agents_path.exists():
56
+ data = json.loads(agents_path.read_text(encoding="utf-8"))
57
+ result["agents"] = [agent.get("id", "") for agent in data.get("agents", [])]
58
+
59
+ state_root = target / "state"
60
+ if state_root.is_dir():
61
+ for path in sorted(state_root.glob("*.json")):
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ result["work_units"].append(
64
+ {
65
+ "id": data.get("id", path.stem),
66
+ "objective": data.get("objective", ""),
67
+ "status": data.get("status", ""),
68
+ }
69
+ )
70
+
71
+ checkpoint_root = target / "checkpoints"
72
+ if checkpoint_root.is_dir():
73
+ for path in sorted(checkpoint_root.glob("*.json")):
74
+ data = json.loads(path.read_text(encoding="utf-8"))
75
+ result["checkpoints"].append(
76
+ {
77
+ "work_unit_id": data.get("work_unit_id", path.stem),
78
+ "workflow": data.get("workflow", ""),
79
+ "stage": data.get("stage", ""),
80
+ "status": data.get("status", ""),
81
+ "next_action": data.get("next_action"),
82
+ "sequence": data.get("sequence", 0),
83
+ "resumable": bool(data.get("resumable", True)),
84
+ }
85
+ )
86
+
87
+ return result
@@ -0,0 +1,134 @@
1
+ """Provider-neutral AI tool-calling runtime."""
2
+ from __future__ import annotations
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from typing import Protocol
6
+ from core.contracts.ai import ModelSpec
7
+ from core.contracts.model_runtime import ModelAdapter, ModelRequest, ModelResponse
8
+ from core.contracts.vyrelon_runtime import RuntimeEvent, RuntimeEventKind, SessionSpec, ToolRequest, ToolResult, ToolSideEffect, ToolSpec
9
+ from runtime.policy import ExecutionPolicy
10
+
11
+ class ToolExecutionError(RuntimeError):
12
+ pass
13
+
14
+ class ToolHandler(Protocol):
15
+ def __call__(self, request: ToolRequest) -> object: ...
16
+
17
+ @dataclass(frozen=True)
18
+ class RegisteredTool:
19
+ spec: ToolSpec
20
+ handler: ToolHandler
21
+
22
+ class ToolRuntime:
23
+ """Central registry and policy boundary for normalized tool calls."""
24
+ def __init__(self, policy: ExecutionPolicy | None = None) -> None:
25
+ self.policy = policy or ExecutionPolicy()
26
+ self._tools: dict[str, RegisteredTool] = {}
27
+
28
+ def register(self, spec: ToolSpec, handler: ToolHandler) -> None:
29
+ if not spec.id.strip():
30
+ raise ValueError("tool id must not be empty")
31
+ if spec.id in self._tools:
32
+ raise ValueError(f"tool already registered: {spec.id}")
33
+ self._tools[spec.id] = RegisteredTool(spec, handler)
34
+
35
+ def specs(self) -> tuple[ToolSpec, ...]:
36
+ return tuple(item.spec for item in self._tools.values())
37
+
38
+ def unregister(self, tool_id: str) -> None:
39
+ """Remove a registered tool from the current scoped runtime."""
40
+ self._tools.pop(tool_id, None)
41
+
42
+ def execute(self, request: ToolRequest, *, granted_permissions: frozenset[str] = frozenset(), approved: bool = False) -> ToolResult:
43
+ item = self._tools.get(request.tool_id)
44
+ if item is None:
45
+ return ToolResult(request.tool_id, False, error=f"tool not registered: {request.tool_id}")
46
+ missing = item.spec.permissions - granted_permissions
47
+ if missing:
48
+ return ToolResult(item.spec.id, False, error=f"tool permission denied: missing={sorted(missing)}")
49
+ capability = {ToolSideEffect.READ: None, ToolSideEffect.WRITE: "filesystem.write", ToolSideEffect.EXECUTE: "process", ToolSideEffect.NETWORK: "network"}[item.spec.side_effect]
50
+ if capability and not self.policy.permits(capability):
51
+ return ToolResult(item.spec.id, False, error=f"tool capability is disabled: {capability}")
52
+ if capability and self.policy.requires_approval(capability) and not approved:
53
+ return ToolResult(item.spec.id, False, error=f"explicit approval required for: {capability}")
54
+ try:
55
+ output = item.handler(request)
56
+ except Exception as exc:
57
+ return ToolResult(item.spec.id, False, error=str(exc))
58
+ return ToolResult(item.spec.id, True, output=output, metadata={"side_effect": item.spec.side_effect.value})
59
+
60
+ class ToolCallingAdapter(Protocol):
61
+ def generate_with_tools(self, model: ModelSpec, request: ModelRequest, tools: tuple[ToolSpec, ...]) -> ModelResponse: ...
62
+
63
+ @dataclass(frozen=True)
64
+ class ToolCallingExecution:
65
+ response: ModelResponse
66
+ model_id: str
67
+ rounds: int
68
+ tool_results: tuple[ToolResult, ...]
69
+
70
+ class ToolCallingRuntime:
71
+ """Execute normalized model tool calls until the model returns a final response."""
72
+ def __init__(self, *, models: dict[str, ModelSpec], adapters: dict[str, ModelAdapter], tools: ToolRuntime, max_rounds: int = 8) -> None:
73
+ if max_rounds < 1:
74
+ raise ValueError("max_rounds must be at least 1")
75
+ self.models, self.adapters, self.tools, self.max_rounds = models, adapters, tools, max_rounds
76
+
77
+ def execute(self, request: ModelRequest, *, model_id: str, session: SessionSpec | None = None, work_unit_id: str | None = None, granted_permissions: frozenset[str] = frozenset(), approved: bool = False) -> ToolCallingExecution:
78
+ model = self.models[model_id]
79
+ adapter = self.adapters[model_id]
80
+ generate = getattr(adapter, "generate_with_tools", None)
81
+ if not callable(generate):
82
+ raise ToolExecutionError(f"model adapter does not support tool calling: {model_id}")
83
+ current, results = request, []
84
+ history: list[dict[str, object]] = []
85
+ for round_number in range(1, self.max_rounds + 1):
86
+ response = generate(model, current, self.tools.specs())
87
+ calls = self._normalize_calls(response.metadata.get("tool_calls", ()))
88
+ if not calls:
89
+ return ToolCallingExecution(response, model_id, round_number, tuple(results))
90
+ round_results = []
91
+ for call in calls:
92
+ result = self.tools.execute(
93
+ ToolRequest(call["tool_id"], call["arguments"], session_id=session.id if session else None, work_unit_id=work_unit_id, metadata={"call_id": call["call_id"]}),
94
+ granted_permissions=granted_permissions, approved=approved,
95
+ )
96
+ results.append(result)
97
+ round_results.append({"call_id": call["call_id"], "tool_id": result.tool_id, "ok": result.ok, "output": result.output, "error": result.error})
98
+ history.append({"tool_calls": calls, "tool_results": tuple(round_results)})
99
+ metadata = dict(current.metadata)
100
+ metadata["tool_results"] = tuple(round_results)
101
+ metadata["tool_history"] = tuple(history)
102
+ current = ModelRequest(prompt=current.prompt, system=current.system, metadata=metadata)
103
+ raise ToolExecutionError(f"tool calling exceeded maximum rounds: {self.max_rounds}")
104
+
105
+ def events(self, request: ModelRequest, *, model_id: str, session: SessionSpec | None = None, work_unit_id: str | None = None, granted_permissions: frozenset[str] = frozenset(), approved: bool = False) -> Iterator[RuntimeEvent]:
106
+ sequence = 0
107
+ def emit(kind: RuntimeEventKind, payload: object) -> RuntimeEvent:
108
+ nonlocal sequence
109
+ sequence += 1
110
+ return RuntimeEvent(kind=kind, session_id=session.id if session else None, work_unit_id=work_unit_id, payload=payload, sequence=sequence)
111
+ yield emit(RuntimeEventKind.REQUEST, {"model_id": model_id})
112
+ result = self.execute(request, model_id=model_id, session=session, work_unit_id=work_unit_id, granted_permissions=granted_permissions, approved=approved)
113
+ for item in result.tool_results:
114
+ yield emit(RuntimeEventKind.TOOL_CALL, {"tool_id": item.tool_id})
115
+ yield emit(RuntimeEventKind.TOOL_RESULT, item)
116
+ yield emit(RuntimeEventKind.MESSAGE, {"model_id": result.model_id, "text": result.response.text})
117
+ yield emit(RuntimeEventKind.COMPLETED, {"model_id": result.model_id, "rounds": result.rounds})
118
+
119
+ @staticmethod
120
+ def _normalize_calls(value: object) -> tuple[dict[str, object], ...]:
121
+ if not isinstance(value, (list, tuple)):
122
+ return ()
123
+ out = []
124
+ for i, item in enumerate(value):
125
+ if not isinstance(item, dict):
126
+ raise ToolExecutionError("tool call must be an object")
127
+ tool_id = item.get("tool_id", item.get("name"))
128
+ if not isinstance(tool_id, str) or not tool_id:
129
+ raise ToolExecutionError("tool call is missing tool_id")
130
+ args = item.get("arguments", {})
131
+ if not isinstance(args, dict):
132
+ raise ToolExecutionError(f"tool call arguments must be an object: {tool_id}")
133
+ out.append({"call_id": str(item.get("call_id", item.get("id", f"call-{i+1}"))), "tool_id": tool_id, "arguments": dict(args)})
134
+ return tuple(out)