devagent-physical-engine 0.10.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 (84) hide show
  1. devagent_physical_engine/__init__.py +44 -0
  2. devagent_physical_engine/agent/__init__.py +40 -0
  3. devagent_physical_engine/agent/compiler.py +285 -0
  4. devagent_physical_engine/agent/contracts.py +129 -0
  5. devagent_physical_engine/agent/coordinator.py +108 -0
  6. devagent_physical_engine/agent/critic.py +72 -0
  7. devagent_physical_engine/agent/evidence.py +34 -0
  8. devagent_physical_engine/agent/interpreter.py +179 -0
  9. devagent_physical_engine/agent/planner.py +105 -0
  10. devagent_physical_engine/agent/recovery.py +54 -0
  11. devagent_physical_engine/agent/routing.py +76 -0
  12. devagent_physical_engine/agent/runtime.py +270 -0
  13. devagent_physical_engine/agent/semantic.py +304 -0
  14. devagent_physical_engine/agent/structured.py +423 -0
  15. devagent_physical_engine/ai_cli.py +226 -0
  16. devagent_physical_engine/cli.py +392 -0
  17. devagent_physical_engine/doctor.py +20 -0
  18. devagent_physical_engine/engineering_agent.py +243 -0
  19. devagent_physical_engine/engineering_request.py +630 -0
  20. devagent_physical_engine/execution.py +90 -0
  21. devagent_physical_engine/models.py +143 -0
  22. devagent_physical_engine/operating_envelope.py +120 -0
  23. devagent_physical_engine/optimization/__init__.py +50 -0
  24. devagent_physical_engine/optimization/benchmark.py +122 -0
  25. devagent_physical_engine/optimization/candidates.py +198 -0
  26. devagent_physical_engine/optimization/contracts.py +235 -0
  27. devagent_physical_engine/optimization/evaluator.py +107 -0
  28. devagent_physical_engine/optimization/evidence.py +53 -0
  29. devagent_physical_engine/optimization/experience.py +105 -0
  30. devagent_physical_engine/optimization/measured.py +125 -0
  31. devagent_physical_engine/optimization/optimizer.py +215 -0
  32. devagent_physical_engine/optimization/orchestrator.py +155 -0
  33. devagent_physical_engine/physical_campaign.py +413 -0
  34. devagent_physical_engine/physical_evidence.py +214 -0
  35. devagent_physical_engine/physical_motion.py +196 -0
  36. devagent_physical_engine/planning.py +80 -0
  37. devagent_physical_engine/preexecution_contract.py +65 -0
  38. devagent_physical_engine/provider_adapters/__init__.py +22 -0
  39. devagent_physical_engine/provider_adapters/anthropic.py +112 -0
  40. devagent_physical_engine/provider_adapters/common.py +187 -0
  41. devagent_physical_engine/provider_adapters/factory.py +20 -0
  42. devagent_physical_engine/provider_adapters/gemini.py +126 -0
  43. devagent_physical_engine/provider_adapters/openai.py +95 -0
  44. devagent_physical_engine/provider_qualification.py +268 -0
  45. devagent_physical_engine/providers.py +94 -0
  46. devagent_physical_engine/qualification.py +44 -0
  47. devagent_physical_engine/qualification_cli.py +195 -0
  48. devagent_physical_engine/qualification_harness.py +917 -0
  49. devagent_physical_engine/robot_platform.py +411 -0
  50. devagent_physical_engine/robots.py +76 -0
  51. devagent_physical_engine/ros2/__init__.py +35 -0
  52. devagent_physical_engine/ros2/acceptance.py +324 -0
  53. devagent_physical_engine/ros2/commands.py +175 -0
  54. devagent_physical_engine/ros2/doctor.py +116 -0
  55. devagent_physical_engine/ros2/fk_probe.py +83 -0
  56. devagent_physical_engine/ros2/frame_alignment.py +61 -0
  57. devagent_physical_engine/ros2/gazebo_world.py +125 -0
  58. devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
  59. devagent_physical_engine/ros2/measured_motion.py +233 -0
  60. devagent_physical_engine/ros2/moveit_scene.py +121 -0
  61. devagent_physical_engine/ros2/preexecution.py +113 -0
  62. devagent_physical_engine/ros2/qualification.py +81 -0
  63. devagent_physical_engine/ros2/qualification_v10.py +252 -0
  64. devagent_physical_engine/ros2/scene_probe.py +219 -0
  65. devagent_physical_engine/ros2/state_validity_probe.py +125 -0
  66. devagent_physical_engine/ros2/tf_probe.py +51 -0
  67. devagent_physical_engine/ros2/trajectory.py +188 -0
  68. devagent_physical_engine/ros2/ur5e.py +59 -0
  69. devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
  70. devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
  71. devagent_physical_engine/setup_profile.py +356 -0
  72. devagent_physical_engine/simulation.py +32 -0
  73. devagent_physical_engine/simulation_platform.py +269 -0
  74. devagent_physical_engine/trajectory_qualification.py +201 -0
  75. devagent_physical_engine/twin.py +939 -0
  76. devagent_physical_engine/twin_builder.py +309 -0
  77. devagent_physical_engine/twin_materialization.py +404 -0
  78. devagent_physical_engine/verification.py +46 -0
  79. devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
  80. devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
  81. devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
  82. devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
  83. devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
  84. devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from ..agent.contracts import ModelRequest, ModelResponse
6
+ from ..agent.runtime import ProviderError, ProviderProtocolError, ProviderUnavailable
7
+ from .common import (
8
+ DiagnosticsStore,
9
+ ProviderCallDiagnostics,
10
+ classify_provider_exception,
11
+ model_matches,
12
+ parse_json_object,
13
+ provider_schema,
14
+ request_input,
15
+ require_api_key,
16
+ safe_token_count,
17
+ )
18
+
19
+
20
+ class GeminiProvider:
21
+ provider_name = "gemini"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str | None = None,
26
+ *,
27
+ max_output_tokens: int = 4096,
28
+ client_factory: Callable[..., Any] | None = None,
29
+ ) -> None:
30
+ if not 256 <= max_output_tokens <= 65536:
31
+ raise ValueError("max_output_tokens_out_of_range")
32
+ self._api_key = api_key
33
+ self._max_output_tokens = max_output_tokens
34
+ self._client_factory = client_factory
35
+ self._diagnostics = DiagnosticsStore()
36
+
37
+ def diagnostics(self, request_id: str) -> ProviderCallDiagnostics | None:
38
+ return self._diagnostics.get(request_id)
39
+
40
+ def _client(self, timeout_s: float) -> Any:
41
+ key = require_api_key(self._api_key, "GEMINI_API_KEY", "GOOGLE_API_KEY")
42
+ timeout_ms = max(100, int(timeout_s * 1000))
43
+ if self._client_factory is not None:
44
+ return self._client_factory(api_key=key, timeout_ms=timeout_ms)
45
+ try:
46
+ from google import genai
47
+ from google.genai import types
48
+ except ImportError as exc:
49
+ raise ProviderUnavailable("sdk_not_installed:google-genai") from exc
50
+ return genai.Client(
51
+ api_key=key,
52
+ http_options=types.HttpOptions(
53
+ timeout=timeout_ms,
54
+ retry_options=types.HttpRetryOptions(attempts=1),
55
+ ),
56
+ )
57
+
58
+ def generate(self, request: ModelRequest, *, model: str) -> ModelResponse:
59
+ client = None
60
+ try:
61
+ client = self._client(request.timeout_s)
62
+ interaction = client.interactions.create(
63
+ model=model,
64
+ system_instruction=request.system_instruction,
65
+ input=request_input(request),
66
+ response_format={
67
+ "type": "text",
68
+ "mime_type": "application/json",
69
+ "schema": provider_schema(request.output_schema),
70
+ },
71
+ generation_config={"max_output_tokens": self._max_output_tokens},
72
+ store=False,
73
+ )
74
+ except Exception as exc:
75
+ if isinstance(exc, ProviderError):
76
+ raise
77
+ raise classify_provider_exception(exc) from exc
78
+ finally:
79
+ if client is not None and self._client_factory is None:
80
+ close = getattr(client, "close", None)
81
+ if callable(close):
82
+ close()
83
+
84
+ request_id = str(getattr(interaction, "id", "") or "")
85
+ if not request_id:
86
+ raise ProviderProtocolError("provider_request_id_missing")
87
+ resolved_model = str(getattr(interaction, "model", "") or "")
88
+ if not model_matches(model, resolved_model):
89
+ raise ProviderProtocolError("response_model_identity_mismatch")
90
+ payload = parse_json_object(getattr(interaction, "output_text", None))
91
+
92
+ usage = getattr(interaction, "usage", None) or getattr(
93
+ interaction, "usage_metadata", None
94
+ )
95
+ input_tokens = safe_token_count(
96
+ getattr(usage, "input_tokens", None) if usage is not None else None
97
+ )
98
+ if input_tokens is None and usage is not None:
99
+ input_tokens = safe_token_count(getattr(usage, "prompt_token_count", None))
100
+ output_tokens = safe_token_count(
101
+ getattr(usage, "output_tokens", None) if usage is not None else None
102
+ )
103
+ if output_tokens is None and usage is not None:
104
+ output_tokens = safe_token_count(
105
+ getattr(usage, "candidates_token_count", None)
106
+ )
107
+ total_tokens = safe_token_count(
108
+ getattr(usage, "total_tokens", None) if usage is not None else None
109
+ )
110
+ if total_tokens is None and usage is not None:
111
+ total_tokens = safe_token_count(getattr(usage, "total_token_count", None))
112
+ if total_tokens is None and input_tokens is not None and output_tokens is not None:
113
+ total_tokens = input_tokens + output_tokens
114
+
115
+ self._diagnostics.record(
116
+ ProviderCallDiagnostics(
117
+ request_id=request_id,
118
+ provider=self.provider_name,
119
+ requested_model=model,
120
+ resolved_model=resolved_model or model,
121
+ input_tokens=input_tokens,
122
+ output_tokens=output_tokens,
123
+ total_tokens=total_tokens,
124
+ )
125
+ )
126
+ return ModelResponse(self.provider_name, model, payload, request_id=request_id)
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+ from ..agent.contracts import ModelRequest, ModelResponse
6
+ from ..agent.runtime import ProviderError, ProviderProtocolError, ProviderUnavailable
7
+ from .common import (
8
+ DiagnosticsStore,
9
+ ProviderCallDiagnostics,
10
+ classify_provider_exception,
11
+ model_matches,
12
+ parse_json_object,
13
+ provider_schema,
14
+ request_input,
15
+ require_api_key,
16
+ safe_token_count,
17
+ )
18
+
19
+
20
+ class OpenAIProvider:
21
+ provider_name = "openai"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str | None = None,
26
+ *,
27
+ max_output_tokens: int = 4096,
28
+ client_factory: Callable[..., Any] | None = None,
29
+ ) -> None:
30
+ if not 256 <= max_output_tokens <= 65536:
31
+ raise ValueError("max_output_tokens_out_of_range")
32
+ self._api_key = api_key
33
+ self._max_output_tokens = max_output_tokens
34
+ self._client_factory = client_factory
35
+ self._diagnostics = DiagnosticsStore()
36
+
37
+ def diagnostics(self, request_id: str) -> ProviderCallDiagnostics | None:
38
+ return self._diagnostics.get(request_id)
39
+
40
+ def _client(self, timeout_s: float) -> Any:
41
+ key = require_api_key(self._api_key, "OPENAI_API_KEY")
42
+ if self._client_factory is not None:
43
+ return self._client_factory(api_key=key, timeout=timeout_s, max_retries=0)
44
+ try:
45
+ from openai import OpenAI
46
+ except ImportError as exc:
47
+ raise ProviderUnavailable("sdk_not_installed:openai") from exc
48
+ return OpenAI(api_key=key, timeout=timeout_s, max_retries=0)
49
+
50
+ def generate(self, request: ModelRequest, *, model: str) -> ModelResponse:
51
+ try:
52
+ response = self._client(request.timeout_s).responses.create(
53
+ model=model,
54
+ instructions=request.system_instruction,
55
+ input=request_input(request),
56
+ text={
57
+ "format": {
58
+ "type": "json_schema",
59
+ "name": f"devagent_{request.role.value}",
60
+ "strict": True,
61
+ "schema": provider_schema(request.output_schema),
62
+ }
63
+ },
64
+ max_output_tokens=self._max_output_tokens,
65
+ store=False,
66
+ )
67
+ except Exception as exc:
68
+ if isinstance(exc, ProviderError):
69
+ raise
70
+ raise classify_provider_exception(exc) from exc
71
+
72
+ request_id = str(getattr(response, "id", "") or "")
73
+ if not request_id:
74
+ raise ProviderProtocolError("provider_request_id_missing")
75
+ resolved_model = str(getattr(response, "model", "") or "")
76
+ if not model_matches(model, resolved_model):
77
+ raise ProviderProtocolError("response_model_identity_mismatch")
78
+ payload = parse_json_object(getattr(response, "output_text", None))
79
+
80
+ usage = getattr(response, "usage", None)
81
+ input_tokens = safe_token_count(getattr(usage, "input_tokens", None))
82
+ output_tokens = safe_token_count(getattr(usage, "output_tokens", None))
83
+ total_tokens = safe_token_count(getattr(usage, "total_tokens", None))
84
+ self._diagnostics.record(
85
+ ProviderCallDiagnostics(
86
+ request_id=request_id,
87
+ provider=self.provider_name,
88
+ requested_model=model,
89
+ resolved_model=resolved_model or model,
90
+ input_tokens=input_tokens,
91
+ output_tokens=output_tokens,
92
+ total_tokens=total_tokens,
93
+ )
94
+ )
95
+ return ModelResponse(self.provider_name, model, payload, request_id=request_id)
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from typing import Any, Iterable
5
+
6
+ from .agent import (
7
+ AgentRuntime,
8
+ CriticAgent,
9
+ ModelRouter,
10
+ PlannerAgent,
11
+ PlanningCoordinator,
12
+ ProviderPool,
13
+ RecoveryAgent,
14
+ RequirementInterpreterAgent,
15
+ RoutingPolicy,
16
+ RoutingStrategy,
17
+ )
18
+ from .agent.runtime import AgentRuntimeError
19
+ from .execution import ExecutionMode, ExecutionSupervisor
20
+ from .models import WorldState
21
+ from .provider_adapters.common import ProviderCallDiagnostics
22
+ from .providers import ModelRegistry, ModelSpec, QualificationStatus
23
+ from .simulation import DeterministicSimulationBackend
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class LiveProviderQualificationReport:
28
+ provider: str
29
+ model: str
30
+ passed: bool
31
+ request_validated: bool
32
+ plan_hash: str | None
33
+ revision_rounds: int | None
34
+ simulation_completed: bool
35
+ recovery_decision: str | None
36
+ agent_calls: int
37
+ agent_attempts: int
38
+ input_tokens: int | None
39
+ output_tokens: int | None
40
+ total_tokens: int | None
41
+ failure_code: str | None = None
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return asdict(self)
45
+
46
+
47
+ def _request_ids(evidence_items: Iterable[Any]) -> tuple[str, ...]:
48
+ values: list[str] = []
49
+ for evidence in evidence_items:
50
+ for attempt in evidence.attempts:
51
+ if attempt.outcome == "success" and attempt.request_id:
52
+ values.append(attempt.request_id)
53
+ return tuple(dict.fromkeys(values))
54
+
55
+
56
+ def _attempt_count(evidence_items: Iterable[Any]) -> int:
57
+ return sum(len(evidence.attempts) for evidence in evidence_items)
58
+
59
+
60
+ def _append_exception_evidence(evidence: list[Any], exc: BaseException) -> None:
61
+ """Preserve bounded AgentEvidence on failure without exposing provider text."""
62
+ if isinstance(exc, AgentRuntimeError):
63
+ evidence.append(exc.evidence)
64
+ return
65
+ nested = getattr(exc, "__cause__", None)
66
+ if isinstance(nested, AgentRuntimeError):
67
+ evidence.append(nested.evidence)
68
+
69
+
70
+ def _usage(
71
+ provider: Any, request_ids: tuple[str, ...]
72
+ ) -> tuple[int | None, int | None, int | None]:
73
+ getter = getattr(provider, "diagnostics", None)
74
+ if not callable(getter):
75
+ return None, None, None
76
+ records: list[ProviderCallDiagnostics] = []
77
+ for request_id in request_ids:
78
+ value = getter(request_id)
79
+ if isinstance(value, ProviderCallDiagnostics):
80
+ records.append(value)
81
+ if not records:
82
+ return None, None, None
83
+
84
+ def total(field: str) -> int | None:
85
+ values = [getattr(item, field) for item in records]
86
+ if any(value is None for value in values):
87
+ return None
88
+ return sum(int(value) for value in values)
89
+
90
+ return total("input_tokens"), total("output_tokens"), total("total_tokens")
91
+
92
+
93
+ def qualify_live_provider(
94
+ provider: Any,
95
+ *,
96
+ model: str,
97
+ timeout_s: float = 30.0,
98
+ max_attempts: int = 2,
99
+ max_revision_rounds: int = 2,
100
+ ) -> LiveProviderQualificationReport:
101
+ """Run Interpreter -> Planner -> Critic -> verify/simulate -> Recovery.
102
+
103
+ The model is deliberately routed as SIMULATION_ONLY for qualification.
104
+ This never unlocks real execution and never persists qualification state.
105
+ """
106
+ provider_name = str(getattr(provider, "provider_name", "") or "").strip().lower()
107
+ if not provider_name:
108
+ raise ValueError("provider_name_required")
109
+ if not model.strip():
110
+ raise ValueError("model_required")
111
+
112
+ registry = ModelRegistry()
113
+ registry.register(
114
+ ModelSpec(
115
+ provider=provider_name,
116
+ model=model.strip(),
117
+ status=QualificationStatus.SIMULATION_ONLY,
118
+ supports_structured_output=True,
119
+ supports_tools=False,
120
+ quality_score=0.5,
121
+ latency_score=0.5,
122
+ cost_score=0.5,
123
+ roles=frozenset({"interpreter", "planner", "critic", "recovery"}),
124
+ )
125
+ )
126
+ pool = ProviderPool()
127
+ pool.register(provider)
128
+ runtime = AgentRuntime(ModelRouter(registry), pool)
129
+ policy = RoutingPolicy(
130
+ strategy=RoutingStrategy.FIXED,
131
+ fixed_provider=provider_name,
132
+ fixed_model=model.strip(),
133
+ max_attempts=max_attempts,
134
+ timeout_s=timeout_s,
135
+ require_qualified=False,
136
+ )
137
+
138
+ world = WorldState(facts={"robot_ready", "object_available"})
139
+ evidence: list[Any] = []
140
+ request_validated = False
141
+
142
+ try:
143
+ interpreted = RequirementInterpreterAgent(runtime).interpret(
144
+ "Use the UR5e to load P17 from conveyor_a into cnc_04. Simulate the operation using an engineering test preset.",
145
+ policy,
146
+ )
147
+ evidence.append(interpreted.evidence)
148
+ if interpreted.validated is None:
149
+ request_ids = _request_ids(evidence)
150
+ input_tokens, output_tokens, total_tokens = _usage(provider, request_ids)
151
+ return LiveProviderQualificationReport(
152
+ provider=provider_name,
153
+ model=model,
154
+ passed=False,
155
+ request_validated=False,
156
+ plan_hash=None,
157
+ revision_rounds=None,
158
+ simulation_completed=False,
159
+ recovery_decision=None,
160
+ agent_calls=len(request_ids),
161
+ agent_attempts=_attempt_count(evidence),
162
+ input_tokens=input_tokens,
163
+ output_tokens=output_tokens,
164
+ total_tokens=total_tokens,
165
+ failure_code=(
166
+ interpreted.rejection_code or "interpreter_request_not_validated"
167
+ ),
168
+ )
169
+
170
+ request_validated = True
171
+ request = interpreted.validated
172
+ goal = request.goal()
173
+ robot = request.resource()
174
+
175
+ planner = PlannerAgent(runtime)
176
+ critic = CriticAgent(runtime)
177
+ coordinator = PlanningCoordinator(
178
+ planner,
179
+ critic,
180
+ max_revision_rounds=max_revision_rounds,
181
+ min_accept_score=0.70,
182
+ )
183
+ recovery = RecoveryAgent(runtime)
184
+
185
+ artifact = coordinator.create_verified_plan(
186
+ goal,
187
+ [robot],
188
+ world,
189
+ policy,
190
+ policy,
191
+ engineering_context=request.to_dict(),
192
+ )
193
+ evidence.extend(artifact.planner_evidence)
194
+ evidence.extend(artifact.critic_evidence)
195
+
196
+ simulation_world = WorldState(facts=set(world.facts), values=dict(world.values))
197
+ simulation = ExecutionSupervisor().execute(
198
+ artifact.graph,
199
+ [robot],
200
+ simulation_world,
201
+ DeterministicSimulationBackend(),
202
+ ExecutionMode.SIMULATION,
203
+ )
204
+ if not simulation.completed:
205
+ request_ids = _request_ids(evidence)
206
+ input_tokens, output_tokens, total_tokens = _usage(provider, request_ids)
207
+ return LiveProviderQualificationReport(
208
+ provider=provider_name,
209
+ model=model,
210
+ passed=False,
211
+ request_validated=True,
212
+ plan_hash=artifact.graph_hash,
213
+ revision_rounds=artifact.revision_rounds,
214
+ simulation_completed=False,
215
+ recovery_decision=None,
216
+ agent_calls=len(request_ids),
217
+ agent_attempts=_attempt_count(evidence),
218
+ input_tokens=input_tokens,
219
+ output_tokens=output_tokens,
220
+ total_tokens=total_tokens,
221
+ failure_code="deterministic_simulation_failed",
222
+ )
223
+
224
+ recovered = recovery.advise(
225
+ task_id="T2",
226
+ issue_codes=("state_unknown",),
227
+ world=world,
228
+ policy=policy,
229
+ )
230
+ evidence.append(recovered.evidence)
231
+ request_ids = _request_ids(evidence)
232
+ input_tokens, output_tokens, total_tokens = _usage(provider, request_ids)
233
+ return LiveProviderQualificationReport(
234
+ provider=provider_name,
235
+ model=model,
236
+ passed=True,
237
+ request_validated=True,
238
+ plan_hash=artifact.graph_hash,
239
+ revision_rounds=artifact.revision_rounds,
240
+ simulation_completed=True,
241
+ recovery_decision=recovered.advice.decision.value,
242
+ agent_calls=len(request_ids),
243
+ agent_attempts=_attempt_count(evidence),
244
+ input_tokens=input_tokens,
245
+ output_tokens=output_tokens,
246
+ total_tokens=total_tokens,
247
+ failure_code=None,
248
+ )
249
+ except Exception as exc:
250
+ _append_exception_evidence(evidence, exc)
251
+ request_ids = _request_ids(evidence)
252
+ input_tokens, output_tokens, total_tokens = _usage(provider, request_ids)
253
+ return LiveProviderQualificationReport(
254
+ provider=provider_name,
255
+ model=model,
256
+ passed=False,
257
+ request_validated=request_validated,
258
+ plan_hash=None,
259
+ revision_rounds=None,
260
+ simulation_completed=False,
261
+ recovery_decision=None,
262
+ agent_calls=len(request_ids),
263
+ agent_attempts=_attempt_count(evidence),
264
+ input_tokens=input_tokens,
265
+ output_tokens=output_tokens,
266
+ total_tokens=total_tokens,
267
+ failure_code=f"qualification_failed:{type(exc).__name__}",
268
+ )
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Protocol
6
+
7
+ from .models import Goal, Resource, TaskGraph
8
+
9
+
10
+ class QualificationStatus(str, Enum):
11
+ QUALIFIED = "qualified"
12
+ SIMULATION_ONLY = "simulation_only"
13
+ NOT_TESTED = "not_tested"
14
+ BLOCKED = "blocked"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ModelSpec:
19
+ """Model capabilities and qualification data used by the routing layer.
20
+
21
+ Scores are normalized to ``[0, 1]`` and intentionally provider-independent.
22
+ A higher score is always better: higher quality, lower latency, or lower cost.
23
+ """
24
+
25
+ provider: str
26
+ model: str
27
+ status: QualificationStatus
28
+ supports_structured_output: bool = True
29
+ supports_tools: bool = True
30
+ private: bool = False
31
+ quality_score: float = 0.5
32
+ latency_score: float = 0.5
33
+ cost_score: float = 0.5
34
+ roles: frozenset[str] = frozenset()
35
+
36
+ def __post_init__(self) -> None:
37
+ provider = self.provider.strip().lower()
38
+ model = self.model.strip()
39
+ if not provider or not model:
40
+ raise ValueError("provider_and_model_required")
41
+
42
+ for name, value in (
43
+ ("quality_score", self.quality_score),
44
+ ("latency_score", self.latency_score),
45
+ ("cost_score", self.cost_score),
46
+ ):
47
+ if not 0.0 <= value <= 1.0:
48
+ raise ValueError(f"{name}_out_of_range")
49
+
50
+ normalized_roles = frozenset(
51
+ role.strip().lower() for role in self.roles if role.strip()
52
+ )
53
+ object.__setattr__(self, "provider", provider)
54
+ object.__setattr__(self, "model", model)
55
+ object.__setattr__(self, "roles", normalized_roles)
56
+
57
+ @property
58
+ def key(self) -> tuple[str, str]:
59
+ return self.provider, self.model
60
+
61
+ def supports_role(self, role: str) -> bool:
62
+ normalized = role.strip().lower()
63
+ return not self.roles or normalized in self.roles
64
+
65
+
66
+ class PlannerModel(Protocol):
67
+ def generate_task_graph(
68
+ self, goal: Goal, resources: list[Resource]
69
+ ) -> TaskGraph: ...
70
+
71
+
72
+ class ModelRegistry:
73
+ """In-memory qualification registry with deterministic duplicate handling."""
74
+
75
+ def __init__(self) -> None:
76
+ self._models: dict[tuple[str, str], ModelSpec] = {}
77
+
78
+ def register(self, spec: ModelSpec, *, replace: bool = False) -> None:
79
+ if spec.key in self._models and not replace:
80
+ raise ValueError(f"duplicate_model:{spec.provider}:{spec.model}")
81
+ self._models[spec.key] = spec
82
+
83
+ def get(self, provider: str, model: str) -> ModelSpec:
84
+ key = (provider.strip().lower(), model.strip())
85
+ try:
86
+ return self._models[key]
87
+ except KeyError as exc:
88
+ raise KeyError(f"unregistered_model:{key[0]}:{key[1]}") from exc
89
+
90
+ def all(self) -> tuple[ModelSpec, ...]:
91
+ return tuple(self._models.values())
92
+
93
+ def real_execution_allowed(self, provider: str, model: str) -> bool:
94
+ return self.get(provider, model).status is QualificationStatus.QUALIFIED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .execution import ExecutionMode, ExecutionSupervisor
6
+ from .models import Goal, WorldState
7
+ from .planning import CapabilityPlanner
8
+ from .robots import CATALOG
9
+ from .simulation import DeterministicSimulationBackend
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class QualificationCaseResult:
14
+ robot: str
15
+ passed: bool
16
+ executed_tasks: int
17
+ violations: int
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class QualificationSummary:
22
+ cases: tuple[QualificationCaseResult, ...]
23
+
24
+ @property
25
+ def passed(self) -> bool:
26
+ return all(case.passed for case in self.cases)
27
+
28
+ @property
29
+ def violations(self) -> int:
30
+ return sum(case.violations for case in self.cases)
31
+
32
+
33
+ def run_cross_vendor_smoke() -> QualificationSummary:
34
+ planner = CapabilityPlanner()
35
+ supervisor = ExecutionSupervisor()
36
+ goal = Goal("G1", "load", "P17", "conveyor_a", "cnc_04")
37
+ results: list[QualificationCaseResult] = []
38
+ for robot_name, factory in CATALOG.items():
39
+ resource = factory()
40
+ graph = planner.plan(goal, [resource])
41
+ world = WorldState(facts={"robot_ready", "object_available"})
42
+ report = supervisor.execute(graph, [resource], world, DeterministicSimulationBackend(), ExecutionMode.SIMULATION)
43
+ results.append(QualificationCaseResult(robot_name, report.completed, len(report.executed_tasks), len(report.issues)))
44
+ return QualificationSummary(tuple(results))