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.
- devagent_physical_engine/__init__.py +44 -0
- devagent_physical_engine/agent/__init__.py +40 -0
- devagent_physical_engine/agent/compiler.py +285 -0
- devagent_physical_engine/agent/contracts.py +129 -0
- devagent_physical_engine/agent/coordinator.py +108 -0
- devagent_physical_engine/agent/critic.py +72 -0
- devagent_physical_engine/agent/evidence.py +34 -0
- devagent_physical_engine/agent/interpreter.py +179 -0
- devagent_physical_engine/agent/planner.py +105 -0
- devagent_physical_engine/agent/recovery.py +54 -0
- devagent_physical_engine/agent/routing.py +76 -0
- devagent_physical_engine/agent/runtime.py +270 -0
- devagent_physical_engine/agent/semantic.py +304 -0
- devagent_physical_engine/agent/structured.py +423 -0
- devagent_physical_engine/ai_cli.py +226 -0
- devagent_physical_engine/cli.py +392 -0
- devagent_physical_engine/doctor.py +20 -0
- devagent_physical_engine/engineering_agent.py +243 -0
- devagent_physical_engine/engineering_request.py +630 -0
- devagent_physical_engine/execution.py +90 -0
- devagent_physical_engine/models.py +143 -0
- devagent_physical_engine/operating_envelope.py +120 -0
- devagent_physical_engine/optimization/__init__.py +50 -0
- devagent_physical_engine/optimization/benchmark.py +122 -0
- devagent_physical_engine/optimization/candidates.py +198 -0
- devagent_physical_engine/optimization/contracts.py +235 -0
- devagent_physical_engine/optimization/evaluator.py +107 -0
- devagent_physical_engine/optimization/evidence.py +53 -0
- devagent_physical_engine/optimization/experience.py +105 -0
- devagent_physical_engine/optimization/measured.py +125 -0
- devagent_physical_engine/optimization/optimizer.py +215 -0
- devagent_physical_engine/optimization/orchestrator.py +155 -0
- devagent_physical_engine/physical_campaign.py +413 -0
- devagent_physical_engine/physical_evidence.py +214 -0
- devagent_physical_engine/physical_motion.py +196 -0
- devagent_physical_engine/planning.py +80 -0
- devagent_physical_engine/preexecution_contract.py +65 -0
- devagent_physical_engine/provider_adapters/__init__.py +22 -0
- devagent_physical_engine/provider_adapters/anthropic.py +112 -0
- devagent_physical_engine/provider_adapters/common.py +187 -0
- devagent_physical_engine/provider_adapters/factory.py +20 -0
- devagent_physical_engine/provider_adapters/gemini.py +126 -0
- devagent_physical_engine/provider_adapters/openai.py +95 -0
- devagent_physical_engine/provider_qualification.py +268 -0
- devagent_physical_engine/providers.py +94 -0
- devagent_physical_engine/qualification.py +44 -0
- devagent_physical_engine/qualification_cli.py +195 -0
- devagent_physical_engine/qualification_harness.py +917 -0
- devagent_physical_engine/robot_platform.py +411 -0
- devagent_physical_engine/robots.py +76 -0
- devagent_physical_engine/ros2/__init__.py +35 -0
- devagent_physical_engine/ros2/acceptance.py +324 -0
- devagent_physical_engine/ros2/commands.py +175 -0
- devagent_physical_engine/ros2/doctor.py +116 -0
- devagent_physical_engine/ros2/fk_probe.py +83 -0
- devagent_physical_engine/ros2/frame_alignment.py +61 -0
- devagent_physical_engine/ros2/gazebo_world.py +125 -0
- devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
- devagent_physical_engine/ros2/measured_motion.py +233 -0
- devagent_physical_engine/ros2/moveit_scene.py +121 -0
- devagent_physical_engine/ros2/preexecution.py +113 -0
- devagent_physical_engine/ros2/qualification.py +81 -0
- devagent_physical_engine/ros2/qualification_v10.py +252 -0
- devagent_physical_engine/ros2/scene_probe.py +219 -0
- devagent_physical_engine/ros2/state_validity_probe.py +125 -0
- devagent_physical_engine/ros2/tf_probe.py +51 -0
- devagent_physical_engine/ros2/trajectory.py +188 -0
- devagent_physical_engine/ros2/ur5e.py +59 -0
- devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
- devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
- devagent_physical_engine/setup_profile.py +356 -0
- devagent_physical_engine/simulation.py +32 -0
- devagent_physical_engine/simulation_platform.py +269 -0
- devagent_physical_engine/trajectory_qualification.py +201 -0
- devagent_physical_engine/twin.py +939 -0
- devagent_physical_engine/twin_builder.py +309 -0
- devagent_physical_engine/twin_materialization.py +404 -0
- devagent_physical_engine/verification.py +46 -0
- devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
- devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
- devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
- devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
- devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
- devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from math import isfinite
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .contracts import CriticDecision, Critique, RecoveryAdvice, RecoveryDecision
|
|
8
|
+
from ..models import ActionContract, Capability, TaskGraph, TaskNode
|
|
9
|
+
|
|
10
|
+
MAX_TASKS = 64
|
|
11
|
+
MAX_ITEMS = 32
|
|
12
|
+
MAX_TEXT = 512
|
|
13
|
+
MAX_JSON_DEPTH = 8
|
|
14
|
+
MAX_JSON_ITEMS = 256
|
|
15
|
+
MAX_JSON_TEXT = 8192
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StructuredOutputError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JsonSafetyError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def json_safe(value: Any, *, path: str = "root", depth: int = 0) -> Any:
|
|
27
|
+
"""Return a JSON-safe copy or reject unsupported/unbounded input."""
|
|
28
|
+
|
|
29
|
+
if depth > MAX_JSON_DEPTH:
|
|
30
|
+
raise JsonSafetyError(f"json_depth_exceeded:{path}")
|
|
31
|
+
if value is None or isinstance(value, (bool, int)):
|
|
32
|
+
return value
|
|
33
|
+
if isinstance(value, float):
|
|
34
|
+
if not isfinite(value):
|
|
35
|
+
raise JsonSafetyError(f"non_finite_number:{path}")
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
if len(value) > MAX_JSON_TEXT:
|
|
39
|
+
raise JsonSafetyError(f"json_text_too_long:{path}")
|
|
40
|
+
return value
|
|
41
|
+
if isinstance(value, (list, tuple)):
|
|
42
|
+
if len(value) > MAX_JSON_ITEMS:
|
|
43
|
+
raise JsonSafetyError(f"json_array_too_large:{path}")
|
|
44
|
+
return [
|
|
45
|
+
json_safe(item, path=f"{path}[{index}]", depth=depth + 1)
|
|
46
|
+
for index, item in enumerate(value)
|
|
47
|
+
]
|
|
48
|
+
if isinstance(value, Mapping):
|
|
49
|
+
if len(value) > MAX_JSON_ITEMS:
|
|
50
|
+
raise JsonSafetyError(f"json_object_too_large:{path}")
|
|
51
|
+
output: dict[str, Any] = {}
|
|
52
|
+
for key, item in value.items():
|
|
53
|
+
if not isinstance(key, str) or not key:
|
|
54
|
+
raise JsonSafetyError(f"json_key_invalid:{path}")
|
|
55
|
+
if len(key) > 256:
|
|
56
|
+
raise JsonSafetyError(f"json_key_too_long:{path}")
|
|
57
|
+
output[key] = json_safe(
|
|
58
|
+
item, path=f"{path}.{key}", depth=depth + 1
|
|
59
|
+
)
|
|
60
|
+
return output
|
|
61
|
+
raise JsonSafetyError(f"json_type_unsupported:{path}:{type(value).__name__}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
SENSITIVE_KEY_PARTS = (
|
|
65
|
+
"api_key",
|
|
66
|
+
"apikey",
|
|
67
|
+
"authorization",
|
|
68
|
+
"credential",
|
|
69
|
+
"password",
|
|
70
|
+
"private_key",
|
|
71
|
+
"secret",
|
|
72
|
+
"token",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def redact_sensitive(
|
|
77
|
+
value: Any,
|
|
78
|
+
*,
|
|
79
|
+
path: str = "root",
|
|
80
|
+
depth: int = 0,
|
|
81
|
+
seen: set[int] | None = None,
|
|
82
|
+
) -> Any:
|
|
83
|
+
"""Redact common credential-shaped fields before model invocation.
|
|
84
|
+
|
|
85
|
+
This is defense in depth, not a substitute for keeping credentials out of
|
|
86
|
+
the world model and resource metadata entirely. JSON validation happens
|
|
87
|
+
after redaction so secret-shaped unsupported objects are never serialized.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
if depth > MAX_JSON_DEPTH:
|
|
91
|
+
raise JsonSafetyError(f"redaction_depth_exceeded:{path}")
|
|
92
|
+
|
|
93
|
+
if seen is None:
|
|
94
|
+
seen = set()
|
|
95
|
+
|
|
96
|
+
if isinstance(value, Mapping):
|
|
97
|
+
identity = id(value)
|
|
98
|
+
if identity in seen:
|
|
99
|
+
raise JsonSafetyError(f"cyclic_context:{path}")
|
|
100
|
+
seen.add(identity)
|
|
101
|
+
try:
|
|
102
|
+
output: dict[Any, Any] = {}
|
|
103
|
+
for key, child in value.items():
|
|
104
|
+
if isinstance(key, str):
|
|
105
|
+
normalized_key = key.lower().replace("-", "_")
|
|
106
|
+
if any(
|
|
107
|
+
part in normalized_key for part in SENSITIVE_KEY_PARTS
|
|
108
|
+
):
|
|
109
|
+
output[key] = "<redacted>"
|
|
110
|
+
continue
|
|
111
|
+
output[key] = redact_sensitive(
|
|
112
|
+
child,
|
|
113
|
+
path=f"{path}.{key}",
|
|
114
|
+
depth=depth + 1,
|
|
115
|
+
seen=seen,
|
|
116
|
+
)
|
|
117
|
+
return output
|
|
118
|
+
finally:
|
|
119
|
+
seen.remove(identity)
|
|
120
|
+
|
|
121
|
+
if isinstance(value, (list, tuple)):
|
|
122
|
+
identity = id(value)
|
|
123
|
+
if identity in seen:
|
|
124
|
+
raise JsonSafetyError(f"cyclic_context:{path}")
|
|
125
|
+
seen.add(identity)
|
|
126
|
+
try:
|
|
127
|
+
return [
|
|
128
|
+
redact_sensitive(
|
|
129
|
+
child,
|
|
130
|
+
path=f"{path}[{index}]",
|
|
131
|
+
depth=depth + 1,
|
|
132
|
+
seen=seen,
|
|
133
|
+
)
|
|
134
|
+
for index, child in enumerate(value)
|
|
135
|
+
]
|
|
136
|
+
finally:
|
|
137
|
+
seen.remove(identity)
|
|
138
|
+
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _object(value: Any, name: str) -> Mapping[str, Any]:
|
|
143
|
+
if not isinstance(value, Mapping):
|
|
144
|
+
raise StructuredOutputError(f"{name}_must_be_object")
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _text(value: Any, name: str, *, required: bool = True) -> str | None:
|
|
149
|
+
if value is None and not required:
|
|
150
|
+
return None
|
|
151
|
+
if not isinstance(value, str):
|
|
152
|
+
raise StructuredOutputError(f"{name}_must_be_string")
|
|
153
|
+
normalized = value.strip()
|
|
154
|
+
if required and not normalized:
|
|
155
|
+
raise StructuredOutputError(f"{name}_required")
|
|
156
|
+
if len(normalized) > MAX_TEXT:
|
|
157
|
+
raise StructuredOutputError(f"{name}_too_long")
|
|
158
|
+
return normalized
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _string_tuple(value: Any, name: str) -> tuple[str, ...]:
|
|
162
|
+
if value is None:
|
|
163
|
+
return ()
|
|
164
|
+
if not isinstance(value, list):
|
|
165
|
+
raise StructuredOutputError(f"{name}_must_be_array")
|
|
166
|
+
if len(value) > MAX_ITEMS:
|
|
167
|
+
raise StructuredOutputError(f"{name}_too_many_items")
|
|
168
|
+
|
|
169
|
+
items: list[str] = []
|
|
170
|
+
for item in value:
|
|
171
|
+
normalized = _text(item, name)
|
|
172
|
+
if normalized is None: # defensive; required=True above
|
|
173
|
+
raise StructuredOutputError(f"{name}_required")
|
|
174
|
+
items.append(normalized)
|
|
175
|
+
if len(items) != len(set(items)):
|
|
176
|
+
raise StructuredOutputError(f"{name}_contains_duplicates")
|
|
177
|
+
return tuple(items)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reject_unknown(
|
|
181
|
+
obj: Mapping[str, Any], allowed: set[str], name: str
|
|
182
|
+
) -> None:
|
|
183
|
+
extra = set(obj) - allowed
|
|
184
|
+
if extra:
|
|
185
|
+
raise StructuredOutputError(
|
|
186
|
+
f"{name}_unknown_fields:{','.join(sorted(extra))}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
TASK_GRAPH_SCHEMA = {
|
|
191
|
+
"type": "object",
|
|
192
|
+
"additionalProperties": False,
|
|
193
|
+
"required": ["goal_id", "tasks"],
|
|
194
|
+
"properties": {
|
|
195
|
+
"goal_id": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT},
|
|
196
|
+
"tasks": {
|
|
197
|
+
"type": "array",
|
|
198
|
+
"minItems": 1,
|
|
199
|
+
"maxItems": MAX_TASKS,
|
|
200
|
+
"items": {
|
|
201
|
+
"type": "object",
|
|
202
|
+
"additionalProperties": False,
|
|
203
|
+
"required": ["task_id", "action", "resource_id"],
|
|
204
|
+
"properties": {
|
|
205
|
+
"task_id": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT},
|
|
206
|
+
"action": {"enum": [capability.value for capability in Capability]},
|
|
207
|
+
"resource_id": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT},
|
|
208
|
+
"object_id": {"type": ["string", "null"], "maxLength": MAX_TEXT},
|
|
209
|
+
"source": {"type": ["string", "null"], "maxLength": MAX_TEXT},
|
|
210
|
+
"destination": {"type": ["string", "null"], "maxLength": MAX_TEXT},
|
|
211
|
+
"preconditions": {"type": "array", "maxItems": MAX_ITEMS, "items": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT}, "uniqueItems": True},
|
|
212
|
+
"expected_effects": {"type": "array", "maxItems": MAX_ITEMS, "items": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT}, "uniqueItems": True},
|
|
213
|
+
"constraints": {"type": "array", "maxItems": MAX_ITEMS, "items": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT}, "uniqueItems": True},
|
|
214
|
+
"timeout_ms": {"type": "integer", "minimum": 1, "maximum": 300_000},
|
|
215
|
+
"depends_on": {"type": "array", "maxItems": MAX_ITEMS, "items": {"type": "string", "minLength": 1, "maxLength": MAX_TEXT}, "uniqueItems": True},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
CRITIQUE_SCHEMA = {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"additionalProperties": False,
|
|
226
|
+
"required": ["decision", "score", "issues"],
|
|
227
|
+
"properties": {
|
|
228
|
+
"decision": {"enum": [decision.value for decision in CriticDecision]},
|
|
229
|
+
"score": {"type": "number", "minimum": 0, "maximum": 1},
|
|
230
|
+
"issues": {
|
|
231
|
+
"type": "array",
|
|
232
|
+
"maxItems": 16,
|
|
233
|
+
"items": {"type": "string"},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
RECOVERY_SCHEMA = {
|
|
239
|
+
"type": "object",
|
|
240
|
+
"additionalProperties": False,
|
|
241
|
+
"required": ["decision", "reason"],
|
|
242
|
+
"properties": {
|
|
243
|
+
"decision": {"enum": [decision.value for decision in RecoveryDecision]},
|
|
244
|
+
"reason": {"type": "string"},
|
|
245
|
+
"retry_after_ms": {
|
|
246
|
+
"type": ["integer", "null"],
|
|
247
|
+
"minimum": 0,
|
|
248
|
+
"maximum": 60_000,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def parse_task_graph(
|
|
255
|
+
payload: Mapping[str, Any], *, expected_goal_id: str | None = None
|
|
256
|
+
) -> TaskGraph:
|
|
257
|
+
obj = _object(payload, "task_graph")
|
|
258
|
+
_reject_unknown(obj, {"goal_id", "tasks"}, "task_graph")
|
|
259
|
+
|
|
260
|
+
goal_id = _text(obj.get("goal_id"), "goal_id")
|
|
261
|
+
if goal_id is None: # defensive; required=True above
|
|
262
|
+
raise StructuredOutputError("goal_id_required")
|
|
263
|
+
if expected_goal_id is not None and goal_id != expected_goal_id:
|
|
264
|
+
raise StructuredOutputError("goal_id_mismatch")
|
|
265
|
+
|
|
266
|
+
raw_tasks = obj.get("tasks")
|
|
267
|
+
if not isinstance(raw_tasks, list):
|
|
268
|
+
raise StructuredOutputError("tasks_must_be_array")
|
|
269
|
+
if not raw_tasks:
|
|
270
|
+
raise StructuredOutputError("tasks_required")
|
|
271
|
+
if len(raw_tasks) > MAX_TASKS:
|
|
272
|
+
raise StructuredOutputError("too_many_tasks")
|
|
273
|
+
|
|
274
|
+
allowed = {
|
|
275
|
+
"task_id",
|
|
276
|
+
"action",
|
|
277
|
+
"resource_id",
|
|
278
|
+
"object_id",
|
|
279
|
+
"source",
|
|
280
|
+
"destination",
|
|
281
|
+
"preconditions",
|
|
282
|
+
"expected_effects",
|
|
283
|
+
"constraints",
|
|
284
|
+
"timeout_ms",
|
|
285
|
+
"depends_on",
|
|
286
|
+
}
|
|
287
|
+
tasks: list[TaskNode] = []
|
|
288
|
+
|
|
289
|
+
for index, raw in enumerate(raw_tasks):
|
|
290
|
+
item = _object(raw, f"task_{index}")
|
|
291
|
+
_reject_unknown(item, allowed, f"task_{index}")
|
|
292
|
+
|
|
293
|
+
action_text = _text(item.get("action"), f"task_{index}.action")
|
|
294
|
+
if action_text is None:
|
|
295
|
+
raise StructuredOutputError(f"task_{index}.action_required")
|
|
296
|
+
try:
|
|
297
|
+
action = Capability(action_text)
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
raise StructuredOutputError(
|
|
300
|
+
f"task_{index}.unsupported_action:{action_text}"
|
|
301
|
+
) from exc
|
|
302
|
+
|
|
303
|
+
timeout_ms = item.get("timeout_ms", 10_000)
|
|
304
|
+
if (
|
|
305
|
+
isinstance(timeout_ms, bool)
|
|
306
|
+
or not isinstance(timeout_ms, int)
|
|
307
|
+
or not 1 <= timeout_ms <= 300_000
|
|
308
|
+
):
|
|
309
|
+
raise StructuredOutputError(f"task_{index}.invalid_timeout")
|
|
310
|
+
|
|
311
|
+
resource_id = _text(
|
|
312
|
+
item.get("resource_id"), f"task_{index}.resource_id"
|
|
313
|
+
)
|
|
314
|
+
task_id = _text(item.get("task_id"), f"task_{index}.task_id")
|
|
315
|
+
if resource_id is None or task_id is None:
|
|
316
|
+
raise StructuredOutputError(f"task_{index}.required_field_missing")
|
|
317
|
+
|
|
318
|
+
contract = ActionContract(
|
|
319
|
+
action=action,
|
|
320
|
+
resource_id=resource_id,
|
|
321
|
+
object_id=_text(
|
|
322
|
+
item.get("object_id"), f"task_{index}.object_id", required=False
|
|
323
|
+
),
|
|
324
|
+
source=_text(
|
|
325
|
+
item.get("source"), f"task_{index}.source", required=False
|
|
326
|
+
),
|
|
327
|
+
destination=_text(
|
|
328
|
+
item.get("destination"),
|
|
329
|
+
f"task_{index}.destination",
|
|
330
|
+
required=False,
|
|
331
|
+
),
|
|
332
|
+
preconditions=_string_tuple(
|
|
333
|
+
item.get("preconditions"), f"task_{index}.preconditions"
|
|
334
|
+
),
|
|
335
|
+
expected_effects=_string_tuple(
|
|
336
|
+
item.get("expected_effects"),
|
|
337
|
+
f"task_{index}.expected_effects",
|
|
338
|
+
),
|
|
339
|
+
constraints=_string_tuple(
|
|
340
|
+
item.get("constraints"), f"task_{index}.constraints"
|
|
341
|
+
),
|
|
342
|
+
timeout_ms=timeout_ms,
|
|
343
|
+
)
|
|
344
|
+
tasks.append(
|
|
345
|
+
TaskNode(
|
|
346
|
+
task_id=task_id,
|
|
347
|
+
contract=contract,
|
|
348
|
+
depends_on=_string_tuple(
|
|
349
|
+
item.get("depends_on"), f"task_{index}.depends_on"
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
graph = TaskGraph(goal_id=goal_id, tasks=tuple(tasks))
|
|
355
|
+
errors = graph.validate_structure()
|
|
356
|
+
if errors:
|
|
357
|
+
raise StructuredOutputError("invalid_task_graph:" + ";".join(errors))
|
|
358
|
+
try:
|
|
359
|
+
graph.topological_order()
|
|
360
|
+
except ValueError as exc:
|
|
361
|
+
raise StructuredOutputError(str(exc)) from exc
|
|
362
|
+
return graph
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def parse_critique(payload: Mapping[str, Any]) -> Critique:
|
|
366
|
+
obj = _object(payload, "critique")
|
|
367
|
+
_reject_unknown(obj, {"decision", "score", "issues"}, "critique")
|
|
368
|
+
|
|
369
|
+
decision_text = _text(obj.get("decision"), "decision")
|
|
370
|
+
try:
|
|
371
|
+
decision = CriticDecision(decision_text)
|
|
372
|
+
except ValueError as exc:
|
|
373
|
+
raise StructuredOutputError("invalid_critic_decision") from exc
|
|
374
|
+
|
|
375
|
+
score = obj.get("score")
|
|
376
|
+
if (
|
|
377
|
+
isinstance(score, bool)
|
|
378
|
+
or not isinstance(score, (int, float))
|
|
379
|
+
or not isfinite(float(score))
|
|
380
|
+
or not 0 <= float(score) <= 1
|
|
381
|
+
):
|
|
382
|
+
raise StructuredOutputError("invalid_critic_score")
|
|
383
|
+
|
|
384
|
+
issues = _string_tuple(obj.get("issues"), "issues")
|
|
385
|
+
if len(issues) > 16:
|
|
386
|
+
raise StructuredOutputError("too_many_critic_issues")
|
|
387
|
+
return Critique(decision, float(score), issues)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def parse_recovery(payload: Mapping[str, Any]) -> RecoveryAdvice:
|
|
391
|
+
obj = _object(payload, "recovery")
|
|
392
|
+
_reject_unknown(obj, {"decision", "reason", "retry_after_ms"}, "recovery")
|
|
393
|
+
|
|
394
|
+
decision_text = _text(obj.get("decision"), "decision")
|
|
395
|
+
try:
|
|
396
|
+
decision = RecoveryDecision(decision_text)
|
|
397
|
+
except ValueError as exc:
|
|
398
|
+
raise StructuredOutputError("invalid_recovery_decision") from exc
|
|
399
|
+
|
|
400
|
+
reason = _text(obj.get("reason"), "reason")
|
|
401
|
+
if reason is None:
|
|
402
|
+
raise StructuredOutputError("reason_required")
|
|
403
|
+
|
|
404
|
+
retry_after_ms = obj.get("retry_after_ms")
|
|
405
|
+
if retry_after_ms is not None and (
|
|
406
|
+
isinstance(retry_after_ms, bool)
|
|
407
|
+
or not isinstance(retry_after_ms, int)
|
|
408
|
+
or not 0 <= retry_after_ms <= 60_000
|
|
409
|
+
):
|
|
410
|
+
raise StructuredOutputError("invalid_retry_after_ms")
|
|
411
|
+
if decision is RecoveryDecision.WAIT and retry_after_ms is None:
|
|
412
|
+
raise StructuredOutputError("wait_requires_retry_after_ms")
|
|
413
|
+
|
|
414
|
+
return RecoveryAdvice(decision, reason, retry_after_ms)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def canonical_json(value: Any) -> str:
|
|
418
|
+
return json.dumps(
|
|
419
|
+
json_safe(value),
|
|
420
|
+
sort_keys=True,
|
|
421
|
+
separators=(",", ":"),
|
|
422
|
+
ensure_ascii=False,
|
|
423
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .agent import (
|
|
9
|
+
AgentRuntime,
|
|
10
|
+
CriticAgent,
|
|
11
|
+
ModelRouter,
|
|
12
|
+
PlannerAgent,
|
|
13
|
+
ProviderPool,
|
|
14
|
+
RequirementInterpreterAgent,
|
|
15
|
+
RoutingPolicy,
|
|
16
|
+
RoutingStrategy,
|
|
17
|
+
)
|
|
18
|
+
from .engineering_agent import SmartEngineeringAgent
|
|
19
|
+
from .provider_adapters import PROVIDER_NAMES, create_provider
|
|
20
|
+
from .provider_qualification import qualify_live_provider
|
|
21
|
+
from .providers import ModelRegistry, ModelSpec, QualificationStatus
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _add_live_request_args(parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
parser.add_argument("text", help="Natural-language engineering request")
|
|
26
|
+
parser.add_argument("--provider", choices=PROVIDER_NAMES, required=True)
|
|
27
|
+
parser.add_argument("--model", required=True)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--follow-up",
|
|
30
|
+
action="append",
|
|
31
|
+
default=[],
|
|
32
|
+
help="Additional user information; repeat as needed",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--timeout", type=float, default=30.0)
|
|
35
|
+
parser.add_argument("--max-attempts", type=int, default=2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(prog="devagent-physical-ai")
|
|
40
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
|
|
42
|
+
doctor = sub.add_parser(
|
|
43
|
+
"doctor",
|
|
44
|
+
help="Check provider SDK and credential availability without a network call",
|
|
45
|
+
)
|
|
46
|
+
doctor.add_argument("--provider", choices=PROVIDER_NAMES, required=True)
|
|
47
|
+
|
|
48
|
+
qualify = sub.add_parser(
|
|
49
|
+
"qualify",
|
|
50
|
+
help="Run live Interpreter+Planner+Critic+Recovery qualification (uses provider API credits)",
|
|
51
|
+
)
|
|
52
|
+
qualify.add_argument("--provider", choices=PROVIDER_NAMES, required=True)
|
|
53
|
+
qualify.add_argument("--model", required=True)
|
|
54
|
+
qualify.add_argument("--timeout", type=float, default=30.0)
|
|
55
|
+
qualify.add_argument("--max-attempts", type=int, default=2)
|
|
56
|
+
qualify.add_argument("--max-revisions", type=int, default=2)
|
|
57
|
+
|
|
58
|
+
request = sub.add_parser(
|
|
59
|
+
"request",
|
|
60
|
+
help="Interpret natural language and deterministically decide whether more information is required",
|
|
61
|
+
)
|
|
62
|
+
_add_live_request_args(request)
|
|
63
|
+
|
|
64
|
+
engineer = sub.add_parser(
|
|
65
|
+
"engineer",
|
|
66
|
+
help="Natural language -> validated request -> AI plan/critic -> deterministic model smoke",
|
|
67
|
+
)
|
|
68
|
+
_add_live_request_args(engineer)
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _module_available(module: str) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
return importlib.util.find_spec(module) is not None
|
|
75
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def provider_doctor(provider: str) -> dict:
|
|
80
|
+
if provider == "openai":
|
|
81
|
+
module, envs = "openai", ("OPENAI_API_KEY",)
|
|
82
|
+
elif provider == "anthropic":
|
|
83
|
+
module, envs = "anthropic", ("ANTHROPIC_API_KEY",)
|
|
84
|
+
else:
|
|
85
|
+
module, envs = "google.genai", ("GEMINI_API_KEY", "GOOGLE_API_KEY")
|
|
86
|
+
sdk = _module_available(module)
|
|
87
|
+
credential = any(bool(os.getenv(name, "").strip()) for name in envs)
|
|
88
|
+
return {
|
|
89
|
+
"provider": provider,
|
|
90
|
+
"sdk_installed": sdk,
|
|
91
|
+
"credential_configured": credential,
|
|
92
|
+
"ready_for_live_qualification": sdk and credential,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _live_runtime(provider_name: str, model: str, *, timeout: float, max_attempts: int):
|
|
97
|
+
provider = create_provider(provider_name)
|
|
98
|
+
registry = ModelRegistry()
|
|
99
|
+
registry.register(
|
|
100
|
+
ModelSpec(
|
|
101
|
+
provider=provider_name,
|
|
102
|
+
model=model,
|
|
103
|
+
status=QualificationStatus.SIMULATION_ONLY,
|
|
104
|
+
supports_structured_output=True,
|
|
105
|
+
supports_tools=False,
|
|
106
|
+
quality_score=0.5,
|
|
107
|
+
latency_score=0.5,
|
|
108
|
+
cost_score=0.5,
|
|
109
|
+
roles=frozenset({"interpreter", "planner", "critic", "recovery"}),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
pool = ProviderPool()
|
|
113
|
+
pool.register(provider)
|
|
114
|
+
runtime = AgentRuntime(ModelRouter(registry), pool)
|
|
115
|
+
policy = RoutingPolicy(
|
|
116
|
+
strategy=RoutingStrategy.FIXED,
|
|
117
|
+
fixed_provider=provider_name,
|
|
118
|
+
fixed_model=model,
|
|
119
|
+
max_attempts=max_attempts,
|
|
120
|
+
timeout_s=timeout,
|
|
121
|
+
require_qualified=False,
|
|
122
|
+
)
|
|
123
|
+
return provider, runtime, policy
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _request(args: argparse.Namespace) -> int:
|
|
127
|
+
_, runtime, policy = _live_runtime(
|
|
128
|
+
args.provider,
|
|
129
|
+
args.model,
|
|
130
|
+
timeout=args.timeout,
|
|
131
|
+
max_attempts=args.max_attempts,
|
|
132
|
+
)
|
|
133
|
+
interpreted = RequirementInterpreterAgent(runtime).interpret(
|
|
134
|
+
args.text,
|
|
135
|
+
policy,
|
|
136
|
+
follow_ups=args.follow_up,
|
|
137
|
+
)
|
|
138
|
+
payload = interpreted.to_dict()
|
|
139
|
+
payload["provider_mode"] = "simulation_only_ephemeral_route"
|
|
140
|
+
payload["real_execution_allowed"] = False
|
|
141
|
+
print(json.dumps(payload, indent=2))
|
|
142
|
+
if interpreted.validated is not None:
|
|
143
|
+
return 0
|
|
144
|
+
if interpreted.rejection_code:
|
|
145
|
+
return 4
|
|
146
|
+
return 3
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _engineer(args: argparse.Namespace) -> int:
|
|
150
|
+
_, runtime, policy = _live_runtime(
|
|
151
|
+
args.provider,
|
|
152
|
+
args.model,
|
|
153
|
+
timeout=args.timeout,
|
|
154
|
+
max_attempts=args.max_attempts,
|
|
155
|
+
)
|
|
156
|
+
interpreter = RequirementInterpreterAgent(runtime)
|
|
157
|
+
result = SmartEngineeringAgent(
|
|
158
|
+
interpreter,
|
|
159
|
+
PlannerAgent(runtime),
|
|
160
|
+
CriticAgent(runtime),
|
|
161
|
+
).run(
|
|
162
|
+
args.text,
|
|
163
|
+
interpreter_policy=policy,
|
|
164
|
+
planner_policy=policy,
|
|
165
|
+
critic_policy=policy,
|
|
166
|
+
follow_ups=args.follow_up,
|
|
167
|
+
)
|
|
168
|
+
payload = result.to_dict()
|
|
169
|
+
payload["provider_mode"] = "simulation_only_ephemeral_route"
|
|
170
|
+
payload["real_execution_allowed"] = False
|
|
171
|
+
print(json.dumps(payload, indent=2))
|
|
172
|
+
if result.ready_for_twin_simulation:
|
|
173
|
+
return 0
|
|
174
|
+
if result.interpretation.validated is None and not result.interpretation.rejection_code:
|
|
175
|
+
return 3
|
|
176
|
+
return 4
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _natural_language_failure(exc: BaseException) -> int:
|
|
180
|
+
print(
|
|
181
|
+
json.dumps(
|
|
182
|
+
{
|
|
183
|
+
"passed": False,
|
|
184
|
+
"failure_code": f"natural_language_request_failed:{type(exc).__name__}",
|
|
185
|
+
"physical_qualification": False,
|
|
186
|
+
"real_execution_allowed": False,
|
|
187
|
+
},
|
|
188
|
+
indent=2,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return 5
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
args = _parser().parse_args(argv)
|
|
196
|
+
if args.command == "doctor":
|
|
197
|
+
result = provider_doctor(args.provider)
|
|
198
|
+
print(json.dumps(result, indent=2))
|
|
199
|
+
return 0 if result["ready_for_live_qualification"] else 1
|
|
200
|
+
|
|
201
|
+
if args.command == "request":
|
|
202
|
+
try:
|
|
203
|
+
return _request(args)
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
return _natural_language_failure(exc)
|
|
206
|
+
|
|
207
|
+
if args.command == "engineer":
|
|
208
|
+
try:
|
|
209
|
+
return _engineer(args)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
return _natural_language_failure(exc)
|
|
212
|
+
|
|
213
|
+
provider = create_provider(args.provider)
|
|
214
|
+
report = qualify_live_provider(
|
|
215
|
+
provider,
|
|
216
|
+
model=args.model,
|
|
217
|
+
timeout_s=args.timeout,
|
|
218
|
+
max_attempts=args.max_attempts,
|
|
219
|
+
max_revision_rounds=args.max_revisions,
|
|
220
|
+
)
|
|
221
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
222
|
+
return 0 if report.passed else 2
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
raise SystemExit(main())
|