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/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
|
runtime/tool_calling.py
ADDED
|
@@ -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)
|