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
@@ -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
+ ...
@@ -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")
@@ -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