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
core/contracts/agent.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Agent contract independent of any model or vendor."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class AgentContract:
|
|
9
|
+
id: str
|
|
10
|
+
role: str
|
|
11
|
+
capabilities: frozenset[str] = frozenset()
|
|
12
|
+
tools: frozenset[str] = frozenset()
|
|
13
|
+
permissions: frozenset[str] = frozenset()
|
|
14
|
+
model_ids: tuple[str, ...] = ()
|
|
15
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
16
|
+
kind: str = "custom"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentRuntime(Protocol):
|
|
20
|
+
def execute(self, agent: AgentContract, work_unit: object) -> object:
|
|
21
|
+
...
|
core/contracts/ai.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""AI provider/model contracts used by VYRELON routing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ModelSpec:
|
|
9
|
+
id: str
|
|
10
|
+
provider_id: str
|
|
11
|
+
capabilities: frozenset[str] = frozenset()
|
|
12
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class AIProvider:
|
|
17
|
+
id: str
|
|
18
|
+
kind: str
|
|
19
|
+
models: tuple[ModelSpec, ...] = ()
|
|
20
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AIExecutor(Protocol):
|
|
24
|
+
def generate(self, model: ModelSpec, prompt: str, **kwargs: object) -> str:
|
|
25
|
+
...
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Model capability contracts for VYRELON routing."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CapabilityConfidence(str, Enum):
|
|
8
|
+
DECLARED = "declared"
|
|
9
|
+
OBSERVED = "observed"
|
|
10
|
+
UNKNOWN = "unknown"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ModelCapabilityProfile:
|
|
15
|
+
"""Normalized capabilities exposed by one model."""
|
|
16
|
+
|
|
17
|
+
model_id: str
|
|
18
|
+
provider_id: str
|
|
19
|
+
capabilities: frozenset[str] = frozenset()
|
|
20
|
+
confidence: CapabilityConfidence = CapabilityConfidence.DECLARED
|
|
21
|
+
source: str = "model_spec"
|
|
22
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def supports(self, requirements: frozenset[str]) -> bool:
|
|
25
|
+
return requirements.issubset(self.capabilities)
|
|
26
|
+
|
|
27
|
+
def match_score(self, requirements: frozenset[str]) -> float:
|
|
28
|
+
if not requirements:
|
|
29
|
+
return 1.0
|
|
30
|
+
return len(self.capabilities & requirements) / len(requirements)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CapabilityMatch:
|
|
35
|
+
model_id: str
|
|
36
|
+
required: frozenset[str]
|
|
37
|
+
matched: frozenset[str]
|
|
38
|
+
missing: frozenset[str]
|
|
39
|
+
score: float
|
|
40
|
+
confidence: CapabilityConfidence
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def compatible(self) -> bool:
|
|
44
|
+
return not self.missing
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Contracts for chat-facing AI agents used with VYRELON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ChatAgentProvider(str, Enum):
|
|
10
|
+
CHATGPT = "chatgpt"
|
|
11
|
+
GEMINI = "gemini"
|
|
12
|
+
CLAUDE = "claude"
|
|
13
|
+
OTHER = "other"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ChatAgentContract:
|
|
18
|
+
"""A conversational agent that can drive or collaborate with VYRELON."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
provider: ChatAgentProvider
|
|
22
|
+
name: str
|
|
23
|
+
capabilities: frozenset[str] = frozenset()
|
|
24
|
+
instruction_profile: str = "default"
|
|
25
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
26
|
+
primary: bool = False
|
|
27
|
+
|
|
28
|
+
def validate(self) -> None:
|
|
29
|
+
if not self.id.strip():
|
|
30
|
+
raise ValueError("chat agent id must not be empty")
|
|
31
|
+
if not self.name.strip():
|
|
32
|
+
raise ValueError("chat agent name must not be empty")
|
|
33
|
+
if self.primary and self.provider is not ChatAgentProvider.CHATGPT:
|
|
34
|
+
raise ValueError("the default primary chat agent must be ChatGPT")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Durable checkpoint state for resumable VYRELON workflows."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class WorkflowCheckpoint:
|
|
8
|
+
"""Serializable checkpoint describing the last durable workflow boundary."""
|
|
9
|
+
|
|
10
|
+
schema_version: int
|
|
11
|
+
work_unit_id: str
|
|
12
|
+
workflow: str
|
|
13
|
+
status: str
|
|
14
|
+
stage: str
|
|
15
|
+
sequence: int = 0
|
|
16
|
+
next_action: str | None = None
|
|
17
|
+
agent_ids: tuple[str, ...] = ()
|
|
18
|
+
model_ids: tuple[str, ...] = ()
|
|
19
|
+
artifact_ids: tuple[str, ...] = ()
|
|
20
|
+
findings: tuple[str, ...] = ()
|
|
21
|
+
resumable: bool = True
|
|
22
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def validate(self) -> None:
|
|
25
|
+
if self.schema_version < 1:
|
|
26
|
+
raise ValueError("checkpoint schema_version must be >= 1")
|
|
27
|
+
if not self.work_unit_id.strip():
|
|
28
|
+
raise ValueError("checkpoint work_unit_id must not be empty")
|
|
29
|
+
if not self.workflow.strip():
|
|
30
|
+
raise ValueError("checkpoint workflow must not be empty")
|
|
31
|
+
if not self.status.strip():
|
|
32
|
+
raise ValueError("checkpoint status must not be empty")
|
|
33
|
+
if not self.stage.strip():
|
|
34
|
+
raise ValueError("checkpoint stage must not be empty")
|
|
35
|
+
if self.sequence < 0:
|
|
36
|
+
raise ValueError("checkpoint sequence must be >= 0")
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, object]:
|
|
39
|
+
self.validate()
|
|
40
|
+
return {
|
|
41
|
+
"schema_version": self.schema_version,
|
|
42
|
+
"work_unit_id": self.work_unit_id,
|
|
43
|
+
"workflow": self.workflow,
|
|
44
|
+
"status": self.status,
|
|
45
|
+
"stage": self.stage,
|
|
46
|
+
"sequence": self.sequence,
|
|
47
|
+
"next_action": self.next_action,
|
|
48
|
+
"agent_ids": list(self.agent_ids),
|
|
49
|
+
"model_ids": list(self.model_ids),
|
|
50
|
+
"artifact_ids": list(self.artifact_ids),
|
|
51
|
+
"findings": list(self.findings),
|
|
52
|
+
"resumable": self.resumable,
|
|
53
|
+
"metadata": self.metadata,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_dict(cls, data: dict[str, object]) -> "WorkflowCheckpoint":
|
|
58
|
+
checkpoint = cls(
|
|
59
|
+
schema_version=int(data.get("schema_version", 1)),
|
|
60
|
+
work_unit_id=str(data["work_unit_id"]),
|
|
61
|
+
workflow=str(data["workflow"]),
|
|
62
|
+
status=str(data["status"]),
|
|
63
|
+
stage=str(data["stage"]),
|
|
64
|
+
sequence=int(data.get("sequence", 0)),
|
|
65
|
+
next_action=(
|
|
66
|
+
str(data["next_action"])
|
|
67
|
+
if data.get("next_action") is not None
|
|
68
|
+
else None
|
|
69
|
+
),
|
|
70
|
+
agent_ids=tuple(str(value) for value in data.get("agent_ids", ())),
|
|
71
|
+
model_ids=tuple(str(value) for value in data.get("model_ids", ())),
|
|
72
|
+
artifact_ids=tuple(str(value) for value in data.get("artifact_ids", ())),
|
|
73
|
+
findings=tuple(str(value) for value in data.get("findings", ())),
|
|
74
|
+
resumable=bool(data.get("resumable", True)),
|
|
75
|
+
metadata=dict(data.get("metadata", {})),
|
|
76
|
+
)
|
|
77
|
+
checkpoint.validate()
|
|
78
|
+
return checkpoint
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Provider discovery contracts for VYRELON model intelligence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from core.contracts.capability import ModelCapabilityProfile
|
|
9
|
+
from core.contracts.quota import QuotaSnapshot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ProviderDiscoveryResult:
|
|
14
|
+
"""Normalized discovery result returned by a provider adapter."""
|
|
15
|
+
|
|
16
|
+
provider_id: str
|
|
17
|
+
capabilities: tuple[ModelCapabilityProfile, ...] = ()
|
|
18
|
+
quotas: tuple[QuotaSnapshot, ...] = ()
|
|
19
|
+
source: str = "unknown"
|
|
20
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class DiscoveryError:
|
|
25
|
+
"""Non-fatal discovery failure for one provider."""
|
|
26
|
+
|
|
27
|
+
provider_id: str
|
|
28
|
+
operation: str
|
|
29
|
+
message: str
|
|
30
|
+
retryable: bool = False
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Vendor-neutral execution, verification, and review contracts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from core.contracts.agent import AgentContract
|
|
7
|
+
from core.contracts.work_unit import WorkUnit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgentExecutor(Protocol):
|
|
11
|
+
def execute(self, *, agent: AgentContract, model_id: str, work_unit: WorkUnit) -> object:
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ResultVerifier(Protocol):
|
|
16
|
+
def verify(self, *, work_unit: WorkUnit, output: object) -> bool:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ReviewDecision:
|
|
22
|
+
approved: bool
|
|
23
|
+
feedback: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResultReviewer(Protocol):
|
|
27
|
+
def review(self, *, work_unit: WorkUnit, output: object) -> ReviewDecision:
|
|
28
|
+
...
|
core/contracts/github.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Provider-neutral GitHub contracts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol, Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class GitHubRepository:
|
|
9
|
+
full_name: str
|
|
10
|
+
default_branch: str
|
|
11
|
+
private: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class GitHubBranch:
|
|
16
|
+
name: str
|
|
17
|
+
sha: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class GitHubFile:
|
|
22
|
+
path: str
|
|
23
|
+
content: str
|
|
24
|
+
sha: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class GitHubIssue:
|
|
29
|
+
number: int
|
|
30
|
+
title: str
|
|
31
|
+
url: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PullRequest:
|
|
36
|
+
number: int
|
|
37
|
+
title: str
|
|
38
|
+
url: str
|
|
39
|
+
head: str
|
|
40
|
+
base: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ReviewResult:
|
|
45
|
+
success: bool
|
|
46
|
+
action: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class MergeResult:
|
|
51
|
+
merged: bool
|
|
52
|
+
sha: str | None
|
|
53
|
+
message: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class WorkflowRun:
|
|
58
|
+
id: int
|
|
59
|
+
status: str
|
|
60
|
+
conclusion: str | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GitHubGateway(Protocol):
|
|
64
|
+
def get_repository(self, full_name: str) -> GitHubRepository: ...
|
|
65
|
+
def get_branch(self, full_name: str, branch: str) -> GitHubBranch: ...
|
|
66
|
+
def create_branch(self, full_name: str, branch: str, base: str) -> GitHubBranch: ...
|
|
67
|
+
def get_file(self, full_name: str, path: str, ref: str) -> GitHubFile: ...
|
|
68
|
+
def create_file(self, full_name: str, path: str, content: str, branch: str, message: str) -> str: ...
|
|
69
|
+
def update_file(self, full_name: str, path: str, content: str, sha: str, branch: str, message: str) -> str: ...
|
|
70
|
+
def create_issue(self, full_name: str, title: str, body: str = "") -> GitHubIssue: ...
|
|
71
|
+
def create_pull_request(self, full_name: str, title: str, body: str, head: str, base: str, draft: bool = False) -> PullRequest: ...
|
|
72
|
+
def review_pull_request(self, full_name: str, number: int, action: str, body: str = "") -> ReviewResult: ...
|
|
73
|
+
def merge_pull_request(self, full_name: str, number: int, method: str = "squash") -> MergeResult: ...
|
|
74
|
+
def list_workflows(self, full_name: str, ref: str) -> Sequence[WorkflowRun]: ...
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Handoff artifact contracts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class HandoffArtifact:
|
|
8
|
+
work_unit_id: str
|
|
9
|
+
from_agent: str
|
|
10
|
+
to_agent: str
|
|
11
|
+
summary: str
|
|
12
|
+
artifacts: tuple[str, ...] = ()
|
|
13
|
+
findings: tuple[str, ...] = ()
|
|
14
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ReviewContext:
|
|
19
|
+
"""Structured input presented to a reviewer by VYRELON."""
|
|
20
|
+
|
|
21
|
+
work_unit_id: str
|
|
22
|
+
artifact_ids: tuple[str, ...] = ()
|
|
23
|
+
findings: tuple[str, ...] = ()
|
|
24
|
+
last_agent_id: str | None = None
|
|
25
|
+
review_cycle: int = 0
|
|
26
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ReviewResult:
|
|
31
|
+
approved: bool
|
|
32
|
+
reviewer_id: str
|
|
33
|
+
feedback: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ArtifactContract:
|
|
38
|
+
"""Durable artifact passed between VYRELON workflow stages."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
kind: str
|
|
42
|
+
producer_agent_id: str
|
|
43
|
+
content_ref: str
|
|
44
|
+
summary: str = ""
|
|
45
|
+
media_type: str = "text/plain"
|
|
46
|
+
metadata: dict[str, object] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def validate(self) -> None:
|
|
49
|
+
for name, value in (
|
|
50
|
+
("id", self.id),
|
|
51
|
+
("kind", self.kind),
|
|
52
|
+
("producer_agent_id", self.producer_agent_id),
|
|
53
|
+
("content_ref", self.content_ref),
|
|
54
|
+
):
|
|
55
|
+
if not value.strip():
|
|
56
|
+
raise ValueError(f"artifact {name} must not be empty")
|
core/contracts/health.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Persistent model health and cooldown registry for VYRELON."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HealthStatus(StrEnum):
|
|
13
|
+
HEALTHY = "healthy"
|
|
14
|
+
RATE_LIMITED = "rate_limited"
|
|
15
|
+
QUOTA_EXHAUSTED = "quota_exhausted"
|
|
16
|
+
TEMPORARILY_UNAVAILABLE = "temporarily_unavailable"
|
|
17
|
+
AUTH_FAILED = "auth_failed"
|
|
18
|
+
DISABLED = "disabled"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ModelHealth:
|
|
23
|
+
model_id: str
|
|
24
|
+
provider_id: str
|
|
25
|
+
status: HealthStatus = HealthStatus.HEALTHY
|
|
26
|
+
consecutive_failures: int = 0
|
|
27
|
+
successes: int = 0
|
|
28
|
+
last_error: str | None = None
|
|
29
|
+
last_failure_at: datetime | None = None
|
|
30
|
+
last_success_at: datetime | None = None
|
|
31
|
+
cooldown_until: datetime | None = None
|
|
32
|
+
metadata: dict[str, Any] | None = None
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def available(self) -> bool:
|
|
36
|
+
if self.status in {HealthStatus.AUTH_FAILED, HealthStatus.DISABLED}:
|
|
37
|
+
return False
|
|
38
|
+
if self.cooldown_until is None:
|
|
39
|
+
return self.status is HealthStatus.HEALTHY
|
|
40
|
+
return self.cooldown_until <= datetime.now(timezone.utc)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now() -> datetime:
|
|
44
|
+
return datetime.now(timezone.utc)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_dt(value: object) -> datetime | None:
|
|
48
|
+
if not value:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def classify_failure(exc: Exception) -> HealthStatus | None:
|
|
57
|
+
status = getattr(exc, "status_code", getattr(exc, "status", None))
|
|
58
|
+
code = str(getattr(exc, "code", "")).lower()
|
|
59
|
+
message = str(exc).lower()
|
|
60
|
+
combined = f"{code} {message}"
|
|
61
|
+
if status in {401, 403}:
|
|
62
|
+
return HealthStatus.AUTH_FAILED
|
|
63
|
+
if any(token in combined for token in (
|
|
64
|
+
"quota_exceeded", "quota exceeded", "daily quota",
|
|
65
|
+
"resource_exhausted", "resource exhausted",
|
|
66
|
+
)):
|
|
67
|
+
return HealthStatus.QUOTA_EXHAUSTED
|
|
68
|
+
if status == 429 or any(token in combined for token in (
|
|
69
|
+
"rate_limit", "rate-limit", "too_many_requests",
|
|
70
|
+
)):
|
|
71
|
+
return HealthStatus.RATE_LIMITED
|
|
72
|
+
if status in {408, 500, 502, 503, 504} or any(token in combined for token in (
|
|
73
|
+
"temporarily_unavailable", "service_unavailable",
|
|
74
|
+
"overloaded", "timeout",
|
|
75
|
+
)):
|
|
76
|
+
return HealthStatus.TEMPORARILY_UNAVAILABLE
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ModelHealthStore:
|
|
81
|
+
"""Persist one health record per model without storing credentials."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, root: Path) -> None:
|
|
84
|
+
self.root = Path(root)
|
|
85
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
def path_for(self, model_id: str) -> Path:
|
|
88
|
+
safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in model_id)
|
|
89
|
+
return self.root / f"{safe}.json"
|
|
90
|
+
|
|
91
|
+
def save(self, health: ModelHealth) -> Path:
|
|
92
|
+
payload = {
|
|
93
|
+
"model_id": health.model_id,
|
|
94
|
+
"provider_id": health.provider_id,
|
|
95
|
+
"status": health.status.value,
|
|
96
|
+
"consecutive_failures": health.consecutive_failures,
|
|
97
|
+
"successes": health.successes,
|
|
98
|
+
"last_error": health.last_error,
|
|
99
|
+
"last_failure_at": health.last_failure_at.isoformat() if health.last_failure_at else None,
|
|
100
|
+
"last_success_at": health.last_success_at.isoformat() if health.last_success_at else None,
|
|
101
|
+
"cooldown_until": health.cooldown_until.isoformat() if health.cooldown_until else None,
|
|
102
|
+
"metadata": health.metadata or {},
|
|
103
|
+
}
|
|
104
|
+
path = self.path_for(health.model_id)
|
|
105
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
def load(self, model_id: str) -> ModelHealth:
|
|
109
|
+
data = json.loads(self.path_for(model_id).read_text(encoding="utf-8"))
|
|
110
|
+
return ModelHealth(
|
|
111
|
+
model_id=data["model_id"],
|
|
112
|
+
provider_id=data["provider_id"],
|
|
113
|
+
status=HealthStatus(data.get("status", HealthStatus.HEALTHY.value)),
|
|
114
|
+
consecutive_failures=int(data.get("consecutive_failures", 0)),
|
|
115
|
+
successes=int(data.get("successes", 0)),
|
|
116
|
+
last_error=data.get("last_error"),
|
|
117
|
+
last_failure_at=_parse_dt(data.get("last_failure_at")),
|
|
118
|
+
last_success_at=_parse_dt(data.get("last_success_at")),
|
|
119
|
+
cooldown_until=_parse_dt(data.get("cooldown_until")),
|
|
120
|
+
metadata=dict(data.get("metadata", {})),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def exists(self, model_id: str) -> bool:
|
|
124
|
+
return self.path_for(model_id).exists()
|
|
125
|
+
|
|
126
|
+
def list_model_ids(self) -> tuple[str, ...]:
|
|
127
|
+
return tuple(sorted(path.stem for path in self.root.glob("*.json")))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ModelHealthRegistry:
|
|
131
|
+
"""Track model failures independently from quota and quarantine unhealthy models."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, store: ModelHealthStore) -> None:
|
|
134
|
+
self.store = store
|
|
135
|
+
|
|
136
|
+
def get(self, model_id: str) -> ModelHealth | None:
|
|
137
|
+
if not self.store.exists(model_id):
|
|
138
|
+
return None
|
|
139
|
+
return self.store.load(model_id)
|
|
140
|
+
|
|
141
|
+
def available(self, model_id: str) -> bool:
|
|
142
|
+
health = self.get(model_id)
|
|
143
|
+
return health is None or health.available
|
|
144
|
+
|
|
145
|
+
def record_success(self, model_id: str, provider_id: str) -> ModelHealth:
|
|
146
|
+
previous = self.get(model_id)
|
|
147
|
+
health = ModelHealth(
|
|
148
|
+
model_id=model_id,
|
|
149
|
+
provider_id=provider_id,
|
|
150
|
+
status=HealthStatus.HEALTHY,
|
|
151
|
+
consecutive_failures=0,
|
|
152
|
+
successes=(previous.successes if previous else 0) + 1,
|
|
153
|
+
last_error=None,
|
|
154
|
+
last_failure_at=previous.last_failure_at if previous else None,
|
|
155
|
+
last_success_at=_now(),
|
|
156
|
+
cooldown_until=None,
|
|
157
|
+
metadata=dict(previous.metadata or {}) if previous else {},
|
|
158
|
+
)
|
|
159
|
+
self.store.save(health)
|
|
160
|
+
return health
|
|
161
|
+
|
|
162
|
+
def record_failure(self, model_id: str, provider_id: str, exc: Exception) -> ModelHealth | None:
|
|
163
|
+
status = classify_failure(exc)
|
|
164
|
+
if status is None:
|
|
165
|
+
return None
|
|
166
|
+
previous = self.get(model_id)
|
|
167
|
+
failures = (previous.consecutive_failures if previous else 0) + 1
|
|
168
|
+
cooldown = self._cooldown(status, failures, exc)
|
|
169
|
+
health = ModelHealth(
|
|
170
|
+
model_id=model_id,
|
|
171
|
+
provider_id=provider_id,
|
|
172
|
+
status=status,
|
|
173
|
+
consecutive_failures=failures,
|
|
174
|
+
successes=previous.successes if previous else 0,
|
|
175
|
+
last_error=str(exc),
|
|
176
|
+
last_failure_at=_now(),
|
|
177
|
+
last_success_at=previous.last_success_at if previous else None,
|
|
178
|
+
cooldown_until=cooldown,
|
|
179
|
+
metadata={"failure_type": status.value},
|
|
180
|
+
)
|
|
181
|
+
self.store.save(health)
|
|
182
|
+
return health
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _cooldown(status: HealthStatus, failures: int, exc: Exception) -> datetime | None:
|
|
186
|
+
now = _now()
|
|
187
|
+
if status is HealthStatus.AUTH_FAILED:
|
|
188
|
+
return None
|
|
189
|
+
reset_at = _parse_dt(getattr(exc, "reset_at", None))
|
|
190
|
+
if reset_at is None:
|
|
191
|
+
metadata = getattr(exc, "metadata", None)
|
|
192
|
+
if isinstance(metadata, dict):
|
|
193
|
+
reset_at = _parse_dt(metadata.get("reset_at"))
|
|
194
|
+
if reset_at is None:
|
|
195
|
+
reset_seconds = metadata.get("retry_after") or metadata.get("reset_seconds")
|
|
196
|
+
try:
|
|
197
|
+
if reset_seconds is not None:
|
|
198
|
+
reset_at = now + timedelta(seconds=float(reset_seconds))
|
|
199
|
+
except (TypeError, ValueError):
|
|
200
|
+
pass
|
|
201
|
+
if reset_at is not None and reset_at > now:
|
|
202
|
+
return reset_at
|
|
203
|
+
if status is HealthStatus.QUOTA_EXHAUSTED:
|
|
204
|
+
return None
|
|
205
|
+
seconds = min(300, 15 * (2 ** min(failures - 1, 4)))
|
|
206
|
+
return now + timedelta(seconds=seconds)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Explicit human decisions for escalated VYRELON WorkUnits."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HumanReviewDecision(str, Enum):
|
|
7
|
+
"""Decision applied to a WorkUnit waiting for human approval."""
|
|
8
|
+
|
|
9
|
+
APPROVE_COMPLETION = "approve_completion"
|
|
10
|
+
APPROVE_REWORK = "approve_rework"
|
|
11
|
+
REJECT = "reject"
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def coerce(cls, value: "HumanReviewDecision | str") -> "HumanReviewDecision":
|
|
15
|
+
if isinstance(value, cls):
|
|
16
|
+
return value
|
|
17
|
+
try:
|
|
18
|
+
return cls(value)
|
|
19
|
+
except ValueError as exc:
|
|
20
|
+
raise ValueError(f"unsupported human review decision: {value}") from exc
|