dual-agent-development 2.1.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.
- dual_agent/__init__.py +15 -0
- dual_agent/adapter_probe.py +356 -0
- dual_agent/candidate_adapter_contract.py +37 -0
- dual_agent/candidate_validation.py +211 -0
- dual_agent/capability_registry.py +166 -0
- dual_agent/claude_code_adapter.py +241 -0
- dual_agent/cli.py +71 -0
- dual_agent/codex_adapter.py +259 -0
- dual_agent/collaboration_handoff.py +49 -0
- dual_agent/collaboration_orchestrator.py +168 -0
- dual_agent/collaboration_packet.py +176 -0
- dual_agent/collaboration_session.py +265 -0
- dual_agent/collaboration_state.py +233 -0
- dual_agent/content_safety.py +66 -0
- dual_agent/discovery_bootstrap.py +180 -0
- dual_agent/dual_agent.py +164 -0
- dual_agent/dual_agent_selection.py +309 -0
- dual_agent/execution_engine.py +157 -0
- dual_agent/external_agent_adapter.py +16 -0
- dual_agent/external_runtime.py +118 -0
- dual_agent/fallback_policy.py +67 -0
- dual_agent/generic_runtime_health.py +65 -0
- dual_agent/handoff_context.py +84 -0
- dual_agent/host.py +198 -0
- dual_agent/invocation_plan.py +52 -0
- dual_agent/local_transport.py +94 -0
- dual_agent/logging_utils.py +17 -0
- dual_agent/loop_guard.py +106 -0
- dual_agent/mock_adapter.py +23 -0
- dual_agent/mode_gate.py +46 -0
- dual_agent/orchestrator.py +116 -0
- dual_agent/production_facade.py +151 -0
- dual_agent/real_validation_executor.py +508 -0
- dual_agent/remote_transport.py +137 -0
- dual_agent/role_candidates.py +61 -0
- dual_agent/runtime_adapter_registry.py +85 -0
- dual_agent/runtime_discovery.py +98 -0
- dual_agent/runtime_health.py +138 -0
- dual_agent/runtime_integration.py +73 -0
- dual_agent/runtime_pool.py +71 -0
- dual_agent/runtime_pool_construction.py +48 -0
- dual_agent/runtime_status.py +99 -0
- dual_agent/selection_plan_bridge.py +61 -0
- dual_agent/stage_runtime_selection.py +153 -0
- dual_agent/structured_packets.py +233 -0
- dual_agent/task_budget.py +108 -0
- dual_agent/task_classifier.py +36 -0
- dual_agent/tiny_agents_adapter.py +267 -0
- dual_agent/verification_collaboration.py +205 -0
- dual_agent/verified_orchestrator.py +71 -0
- dual_agent/verified_runtime_pool.py +105 -0
- dual_agent/verified_selection_bridge.py +149 -0
- dual_agent/verified_stage_selector.py +114 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/examples/offline_mock_run.py +163 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/SKILL.md +31 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/architect.md +15 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/coder.md +15 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/openai.yaml +4 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/reviewer.md +11 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/agents/tester.md +11 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/references/adapter-contract.md +99 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/references/workflow.md +102 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/architecture-packet.json +17 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/implementation-packet.json +10 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/review-packet.json +12 -0
- dual_agent_development-2.1.0.data/data/share/dual-agent/skill/templates/test-packet.json +12 -0
- dual_agent_development-2.1.0.dist-info/METADATA +745 -0
- dual_agent_development-2.1.0.dist-info/RECORD +72 -0
- dual_agent_development-2.1.0.dist-info/WHEEL +5 -0
- dual_agent_development-2.1.0.dist-info/entry_points.txt +2 -0
- dual_agent_development-2.1.0.dist-info/licenses/LICENSE +21 -0
- dual_agent_development-2.1.0.dist-info/top_level.txt +1 -0
dual_agent/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""dual_agent — the V2 collaboration engine package.
|
|
2
|
+
|
|
3
|
+
The engine modules import each other by flat top-level name (they grew up as
|
|
4
|
+
a path-based toolkit). This package exposes them for installation by placing
|
|
5
|
+
its own directory on ``sys.path`` so that both ``import dual_agent.cli`` and
|
|
6
|
+
the internal flat imports keep working unchanged. Pure standard library.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
12
|
+
if _HERE not in sys.path:
|
|
13
|
+
sys.path.insert(0, _HERE)
|
|
14
|
+
|
|
15
|
+
__version__ = "2.1.0"
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Provider-neutral discovery probes for the dual-agent Skill.
|
|
2
|
+
|
|
3
|
+
Discovery is existence/executability-only. It resolves a CLI executable on
|
|
4
|
+
PATH and runs a single bounded version probe (``--version`` / ``--help``). It
|
|
5
|
+
never reads secrets, never mutates global configuration, and never touches the
|
|
6
|
+
network. Results are reported through the frozen :class:`AdapterProbe` schema;
|
|
7
|
+
every function here returns a probe and never raises an uncaught exception.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Dict, Optional
|
|
18
|
+
|
|
19
|
+
#: Wall-clock budget for a single version probe (seconds).
|
|
20
|
+
DISCOVERY_TIMEOUT = 5.0
|
|
21
|
+
|
|
22
|
+
#: Known CLI entry points, in search order.
|
|
23
|
+
CLAUDE_CANDIDATES = ("claude", "claude.exe")
|
|
24
|
+
CODEX_CANDIDATES = ("codex", "codex.exe")
|
|
25
|
+
|
|
26
|
+
#: Probe flags tried in order; the first invocation that exits 0 wins.
|
|
27
|
+
PROBE_FLAGS = ("--version", "--help")
|
|
28
|
+
|
|
29
|
+
#: Stable status strings.
|
|
30
|
+
STATUS_AVAILABLE = "AVAILABLE"
|
|
31
|
+
STATUS_UNAVAILABLE = "UNAVAILABLE"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class AdapterProbe:
|
|
36
|
+
"""Controlled discovery result with a stable, frozen schema.
|
|
37
|
+
|
|
38
|
+
Only these fields are part of the stable schema. Consumers must reject any
|
|
39
|
+
extra key produced by a serialized result (treat provider output as
|
|
40
|
+
untrusted); :meth:`to_dict` emits exactly this schema.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
adapter_id: str
|
|
44
|
+
status: str
|
|
45
|
+
executable: Optional[str]
|
|
46
|
+
version: Optional[str]
|
|
47
|
+
reason: Optional[str]
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> Dict[str, object]:
|
|
50
|
+
return {
|
|
51
|
+
"adapter_id": self.adapter_id,
|
|
52
|
+
"status": self.status,
|
|
53
|
+
"executable": self.executable,
|
|
54
|
+
"version": self.version,
|
|
55
|
+
"reason": self.reason,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_minimal_env() -> Dict[str, str]:
|
|
60
|
+
"""Construct a minimal child environment with only well-defined variables.
|
|
61
|
+
|
|
62
|
+
The child does not inherit the parent environment wholesale. Only PATH,
|
|
63
|
+
HOME/USERPROFILE, and SYSTEMROOT (Windows) are copied when present. No
|
|
64
|
+
secret-bearing variables are ever forwarded for discovery.
|
|
65
|
+
"""
|
|
66
|
+
env: Dict[str, str] = {}
|
|
67
|
+
for key in ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT"):
|
|
68
|
+
value = os.environ.get(key)
|
|
69
|
+
if value:
|
|
70
|
+
env[key] = value
|
|
71
|
+
return env
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _is_usable_executable(path: str) -> bool:
|
|
75
|
+
"""True when the resolved path is an existing, executable regular file."""
|
|
76
|
+
if not path or not os.path.isfile(path):
|
|
77
|
+
return False
|
|
78
|
+
if os.name == "nt":
|
|
79
|
+
return True
|
|
80
|
+
return os.access(path, os.X_OK)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve(candidates: tuple[str, ...]) -> Optional[str]:
|
|
84
|
+
"""Resolve the first candidate present on PATH, else None."""
|
|
85
|
+
env = _build_minimal_env()
|
|
86
|
+
for name in candidates:
|
|
87
|
+
resolved = shutil.which(name, path=env.get("PATH"))
|
|
88
|
+
if resolved and _is_usable_executable(resolved):
|
|
89
|
+
return resolved
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _run_bounded_probe(executable: str) -> tuple[bool, str]:
|
|
94
|
+
"""Run ``--version`` then ``--help`` under a timeout; no shell.
|
|
95
|
+
|
|
96
|
+
Returns ``(ok, reason_or_version)``. On timeout or launch failure the child
|
|
97
|
+
tree is killed (Windows Job Object / POSIX process group) and the probe is
|
|
98
|
+
reported unavailable.
|
|
99
|
+
"""
|
|
100
|
+
env = _build_minimal_env()
|
|
101
|
+
starter_kwargs: dict[str, object] = {
|
|
102
|
+
"shell": False,
|
|
103
|
+
"env": env,
|
|
104
|
+
"stdout": subprocess.PIPE,
|
|
105
|
+
"stderr": subprocess.PIPE,
|
|
106
|
+
"text": True,
|
|
107
|
+
"cwd": os.getcwd(),
|
|
108
|
+
}
|
|
109
|
+
if os.name == "nt":
|
|
110
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
111
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
112
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
113
|
+
starter_kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP
|
|
114
|
+
starter_kwargs["startupinfo"] = startupinfo
|
|
115
|
+
job = _open_job_object()
|
|
116
|
+
else:
|
|
117
|
+
starter_kwargs["start_new_session"] = True
|
|
118
|
+
job = None
|
|
119
|
+
|
|
120
|
+
process = None
|
|
121
|
+
try:
|
|
122
|
+
deadline = time.monotonic() + DISCOVERY_TIMEOUT
|
|
123
|
+
pid: Optional[int] = None
|
|
124
|
+
for flag in PROBE_FLAGS:
|
|
125
|
+
remaining = deadline - time.monotonic()
|
|
126
|
+
if remaining <= 0:
|
|
127
|
+
return False, "probe timeout"
|
|
128
|
+
argv: list[str] = [executable, flag]
|
|
129
|
+
process = subprocess.Popen(argv, **starter_kwargs) # type: ignore[arg-type]
|
|
130
|
+
pid = process.pid
|
|
131
|
+
try:
|
|
132
|
+
_assign_to_job(process, job)
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
if getattr(process, "_adapter_probe_fake", False):
|
|
135
|
+
pass
|
|
136
|
+
else:
|
|
137
|
+
_kill_tree(process)
|
|
138
|
+
_reap_process(process)
|
|
139
|
+
return False, f"process-tree control failed: {exc}"
|
|
140
|
+
try:
|
|
141
|
+
communicate_timeout = (
|
|
142
|
+
DISCOVERY_TIMEOUT
|
|
143
|
+
if getattr(process, "_adapter_probe_fake", False)
|
|
144
|
+
else remaining
|
|
145
|
+
)
|
|
146
|
+
stdout, stderr = process.communicate(timeout=communicate_timeout)
|
|
147
|
+
except subprocess.TimeoutExpired:
|
|
148
|
+
_kill_tree(process)
|
|
149
|
+
_reap_process(process)
|
|
150
|
+
return False, "probe timeout"
|
|
151
|
+
if process.returncode == 0:
|
|
152
|
+
version = _parse_version(stdout) or _parse_version(stderr)
|
|
153
|
+
if version:
|
|
154
|
+
return True, version
|
|
155
|
+
return False, "no usable version output"
|
|
156
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
157
|
+
return False, f"probe failed: {exc}"
|
|
158
|
+
finally:
|
|
159
|
+
if pid is not None and process is not None and process.returncode is None:
|
|
160
|
+
_kill_tree(process)
|
|
161
|
+
_reap_process(process)
|
|
162
|
+
if job is not None:
|
|
163
|
+
_close_job_object(job)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _parse_version(text: object) -> Optional[str]:
|
|
167
|
+
"""Extract a version-looking token from untrusted CLI output, or None."""
|
|
168
|
+
if text is None:
|
|
169
|
+
return None
|
|
170
|
+
if isinstance(text, (bytes, bytearray)):
|
|
171
|
+
decoded = text.decode("utf-8", errors="replace")
|
|
172
|
+
elif isinstance(text, str):
|
|
173
|
+
decoded = text
|
|
174
|
+
else:
|
|
175
|
+
return None
|
|
176
|
+
normalized = " ".join(decoded.split())
|
|
177
|
+
match = re.fullmatch(
|
|
178
|
+
r"(?:(?:claude(?:\s+code)?|codex)\s+|v)?"
|
|
179
|
+
r"(\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?)",
|
|
180
|
+
normalized,
|
|
181
|
+
flags=re.IGNORECASE,
|
|
182
|
+
)
|
|
183
|
+
return match.group(1) if match else None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- Windows Job Object helpers (process-tree cancellation). ---------------
|
|
187
|
+
|
|
188
|
+
def _open_job_object():
|
|
189
|
+
"""Create a kill-on-close Job Object, or None on failure.
|
|
190
|
+
|
|
191
|
+
Uses ctypes so the probe stays on the standard library. The job is
|
|
192
|
+
configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that when the
|
|
193
|
+
orchestrator closes the job handle every descendant process is terminated.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
return _create_job_object()
|
|
197
|
+
except Exception:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _create_job_object():
|
|
202
|
+
import ctypes
|
|
203
|
+
from ctypes import wintypes
|
|
204
|
+
|
|
205
|
+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
206
|
+
_fields_ = [
|
|
207
|
+
("PerProcessUserTimeLimit", wintypes.LARGE_INTEGER),
|
|
208
|
+
("PerJobUserTimeLimit", wintypes.LARGE_INTEGER),
|
|
209
|
+
("LimitFlags", wintypes.DWORD),
|
|
210
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
211
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
212
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
213
|
+
("Affinity", ctypes.c_size_t),
|
|
214
|
+
("PriorityClass", wintypes.DWORD),
|
|
215
|
+
("SchedulingClass", wintypes.DWORD),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
219
|
+
_fields_ = [
|
|
220
|
+
("ReadOperationCount", ctypes.c_ulonglong),
|
|
221
|
+
("WriteOperationCount", ctypes.c_ulonglong),
|
|
222
|
+
("OtherOperationCount", ctypes.c_ulonglong),
|
|
223
|
+
("ReadTransferCount", ctypes.c_ulonglong),
|
|
224
|
+
("WriteTransferCount", ctypes.c_ulonglong),
|
|
225
|
+
("OtherTransferCount", ctypes.c_ulonglong),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
229
|
+
_fields_ = [
|
|
230
|
+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
231
|
+
("IoInfo", IO_COUNTERS),
|
|
232
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
233
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
234
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
235
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
239
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
240
|
+
job = kernel32.CreateJobObjectW(None, None)
|
|
241
|
+
if not job:
|
|
242
|
+
return None
|
|
243
|
+
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
244
|
+
info.BasicLimitInformation.LimitFlags = 0x2000
|
|
245
|
+
if not kernel32.SetInformationJobObject(
|
|
246
|
+
job, 9, ctypes.byref(info), ctypes.sizeof(info)
|
|
247
|
+
):
|
|
248
|
+
kernel32.CloseHandle(wintypes.HANDLE(job))
|
|
249
|
+
return None
|
|
250
|
+
return job
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _assign_to_job(process, job) -> None:
|
|
254
|
+
if job is None or os.name != "nt":
|
|
255
|
+
return
|
|
256
|
+
import ctypes
|
|
257
|
+
from ctypes import wintypes
|
|
258
|
+
|
|
259
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
260
|
+
PROCESS_SET_QUOTA = 0x0100
|
|
261
|
+
PROCESS_TERMINATE = 0x0001
|
|
262
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
263
|
+
access = PROCESS_SET_QUOTA | PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION
|
|
264
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
265
|
+
process_handle = kernel32.OpenProcess(access, False, process.pid)
|
|
266
|
+
if not process_handle:
|
|
267
|
+
raise OSError(ctypes.get_last_error(), "OpenProcess failed")
|
|
268
|
+
try:
|
|
269
|
+
if not kernel32.AssignProcessToJobObject(job, process_handle):
|
|
270
|
+
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject failed")
|
|
271
|
+
finally:
|
|
272
|
+
kernel32.CloseHandle(process_handle)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _reap_process(process) -> None:
|
|
276
|
+
try:
|
|
277
|
+
process.communicate(timeout=0.2)
|
|
278
|
+
except (OSError, subprocess.SubprocessError):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _kill_tree(process) -> None:
|
|
283
|
+
"""Terminate the child and its descendants."""
|
|
284
|
+
if os.name == "nt":
|
|
285
|
+
try:
|
|
286
|
+
process.kill()
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
os.killpg(process.pid, 9)
|
|
292
|
+
except Exception:
|
|
293
|
+
try:
|
|
294
|
+
process.kill()
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _close_job_object(job) -> None:
|
|
300
|
+
if job is None or os.name != "nt":
|
|
301
|
+
return
|
|
302
|
+
try:
|
|
303
|
+
import ctypes
|
|
304
|
+
from ctypes import wintypes
|
|
305
|
+
|
|
306
|
+
ctypes.WinDLL("kernel32", use_last_error=True).CloseHandle(wintypes.HANDLE(job))
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- Public discovery entry points. ----------------------------------------
|
|
312
|
+
|
|
313
|
+
def discover_claude() -> AdapterProbe:
|
|
314
|
+
return _discover("claude", CLAUDE_CANDIDATES)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def discover_codex() -> AdapterProbe:
|
|
318
|
+
# Codex's native dependency/executable is not verifiably repaired in this
|
|
319
|
+
# environment. Keep the adapter unavailable until a future, explicitly
|
|
320
|
+
# verified implementation replaces this boundary.
|
|
321
|
+
return AdapterProbe(
|
|
322
|
+
"codex",
|
|
323
|
+
STATUS_UNAVAILABLE,
|
|
324
|
+
None,
|
|
325
|
+
None,
|
|
326
|
+
"codex native dependency is not verified",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _discover(adapter_id: str, candidates: tuple[str, ...]) -> AdapterProbe:
|
|
331
|
+
try:
|
|
332
|
+
executable = _resolve(candidates)
|
|
333
|
+
if executable is None:
|
|
334
|
+
return AdapterProbe(
|
|
335
|
+
adapter_id, STATUS_UNAVAILABLE, None, None,
|
|
336
|
+
f"no executable for {adapter_id} found on PATH",
|
|
337
|
+
)
|
|
338
|
+
ok, detail = _run_bounded_probe(executable)
|
|
339
|
+
if not ok:
|
|
340
|
+
return AdapterProbe(
|
|
341
|
+
adapter_id, STATUS_UNAVAILABLE, executable, None, detail,
|
|
342
|
+
)
|
|
343
|
+
return AdapterProbe(adapter_id, STATUS_AVAILABLE, executable, detail, None)
|
|
344
|
+
except Exception as exc: # never raise out of discovery
|
|
345
|
+
return AdapterProbe(
|
|
346
|
+
adapter_id, STATUS_UNAVAILABLE, None, None, f"discovery failed: {exc}",
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def to_discovery_status(probe: AdapterProbe):
|
|
351
|
+
"""Map a probe status to the dual_agent DiscoveryStatus enum."""
|
|
352
|
+
from dual_agent import DiscoveryStatus
|
|
353
|
+
|
|
354
|
+
if probe.status == STATUS_AVAILABLE:
|
|
355
|
+
return DiscoveryStatus.AVAILABLE
|
|
356
|
+
return DiscoveryStatus.UNAVAILABLE
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Candidate Adapter Contract — runtime-neutral bridge.
|
|
2
|
+
|
|
3
|
+
Any adapter exposing the neutral CandidateAdapter surface (identity fields,
|
|
4
|
+
declared capabilities, an injected probe and a future invocation spec) can
|
|
5
|
+
be described as a CandidateRuntimeInstance. The bridge copies fields
|
|
6
|
+
verbatim: it never branches on runtime/provider/model values, never guesses
|
|
7
|
+
missing evidence, and never calls into a runtime.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Mapping, Protocol
|
|
12
|
+
|
|
13
|
+
from candidate_validation import CandidateRuntimeInstance
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CandidateAdapter(Protocol):
|
|
17
|
+
"""Minimal contract: identity dimensions stay independent fields."""
|
|
18
|
+
|
|
19
|
+
runtime_id: str
|
|
20
|
+
provider_id: str
|
|
21
|
+
model_id: str | None
|
|
22
|
+
config_fingerprint: str
|
|
23
|
+
capability_context: tuple
|
|
24
|
+
probe: Any
|
|
25
|
+
invocation_spec: Mapping[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def candidate_from_adapter(adapter: CandidateAdapter) -> CandidateRuntimeInstance:
|
|
29
|
+
return CandidateRuntimeInstance(
|
|
30
|
+
runtime_id=adapter.runtime_id,
|
|
31
|
+
provider_id=adapter.provider_id,
|
|
32
|
+
model_id=adapter.model_id,
|
|
33
|
+
config_fingerprint=adapter.config_fingerprint,
|
|
34
|
+
capability_context=tuple(adapter.capability_context),
|
|
35
|
+
probe=adapter.probe,
|
|
36
|
+
invocation_spec=adapter.invocation_spec,
|
|
37
|
+
)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Candidate Runtime Validation Skeleton — offline, runtime-neutral.
|
|
2
|
+
|
|
3
|
+
Implements the confirmed Gate design as pure data + orchestration: gate
|
|
4
|
+
models, verdict merge semantics and a runner that coordinates an injected
|
|
5
|
+
gate executor. Nothing here starts processes, calls runtimes, reads
|
|
6
|
+
credentials or touches the production orchestration stack.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum, IntEnum
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import Any, Callable, Mapping
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_SECRET_MARKERS = ("token", "secret", "api_key", "authorization", "bearer", "stdout", "stderr")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _assert_secret_free(value: Any, where: str) -> None:
|
|
20
|
+
if isinstance(value, str):
|
|
21
|
+
lowered = value.lower()
|
|
22
|
+
for marker in _SECRET_MARKERS:
|
|
23
|
+
if marker in lowered:
|
|
24
|
+
raise ValueError(f"{where} must not contain secret-shaped content")
|
|
25
|
+
elif isinstance(value, Mapping):
|
|
26
|
+
for key, item in value.items():
|
|
27
|
+
_assert_secret_free(str(key), where)
|
|
28
|
+
_assert_secret_free(item, where)
|
|
29
|
+
elif isinstance(value, (tuple, list, frozenset, set)):
|
|
30
|
+
for item in value:
|
|
31
|
+
_assert_secret_free(item, where)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValidationGate(IntEnum):
|
|
35
|
+
G1_DISCOVERY = 1
|
|
36
|
+
G2_AUTHENTICATION = 2
|
|
37
|
+
G3_PROVIDER = 3
|
|
38
|
+
G4_MODEL = 4
|
|
39
|
+
G5_MINIMAL_INVOCATION = 5
|
|
40
|
+
G6_EXIT_CODE = 6
|
|
41
|
+
G7_TIMEOUT = 7
|
|
42
|
+
G8_CANCEL = 8
|
|
43
|
+
G9_PROCESS_CLEANUP = 9
|
|
44
|
+
G10_INVOCATION_RESULT = 10
|
|
45
|
+
G11_STRUCTURED_PACKET = 11
|
|
46
|
+
G12_SECURITY = 12
|
|
47
|
+
G13_CONFIGURATION_INTEGRITY = 13
|
|
48
|
+
G14_CAPABILITY_EVIDENCE = 14
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GateVerdict(str, Enum):
|
|
52
|
+
PASS = "PASS"
|
|
53
|
+
BLOCKED = "BLOCKED"
|
|
54
|
+
FAILED = "FAILED"
|
|
55
|
+
NOT_RUN = "NOT_RUN"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CandidateValidationStatus(str, Enum):
|
|
59
|
+
VERIFIED = "VERIFIED"
|
|
60
|
+
BLOCKED = "BLOCKED"
|
|
61
|
+
FAILED = "FAILED"
|
|
62
|
+
NOT_VERIFIED = "NOT_VERIFIED"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CandidateRuntimeInstance:
|
|
67
|
+
runtime_id: str
|
|
68
|
+
provider_id: str
|
|
69
|
+
model_id: str
|
|
70
|
+
config_fingerprint: str
|
|
71
|
+
capability_context: tuple
|
|
72
|
+
probe: Any = field(repr=False, compare=False)
|
|
73
|
+
invocation_spec: Mapping[str, Any]
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
if not self.runtime_id or not self.provider_id:
|
|
77
|
+
raise ValueError("runtime_id and provider_id are required")
|
|
78
|
+
if not self.config_fingerprint:
|
|
79
|
+
raise ValueError("config_fingerprint is required")
|
|
80
|
+
_assert_secret_free(self.capability_context, "capability_context")
|
|
81
|
+
_assert_secret_free(self.invocation_spec, "invocation_spec")
|
|
82
|
+
# Freeze the mapping: the instance must not mutate through an
|
|
83
|
+
# externally held reference to the original dict.
|
|
84
|
+
object.__setattr__(
|
|
85
|
+
self, "invocation_spec",
|
|
86
|
+
MappingProxyType(dict(self.invocation_spec)),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def identity(self) -> tuple:
|
|
91
|
+
return (self.runtime_id, self.provider_id, self.model_id, self.config_fingerprint)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class GateResult:
|
|
96
|
+
gate: ValidationGate
|
|
97
|
+
verdict: GateVerdict
|
|
98
|
+
reason: str | None = None
|
|
99
|
+
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
100
|
+
# Explicit, structured capability evidence produced by this gate.
|
|
101
|
+
# Never inferred from `evidence` strings; empty when the gate produced none.
|
|
102
|
+
capabilities: tuple = ()
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
# Capabilities are set-semantics evidence: normalize so ordering and
|
|
106
|
+
# duplicates in executor input can never affect downstream results.
|
|
107
|
+
object.__setattr__(self, "capabilities", tuple(sorted(set(self.capabilities))))
|
|
108
|
+
_assert_secret_free(self.reason or "", "gate reason")
|
|
109
|
+
_assert_secret_free(self.evidence, "gate evidence")
|
|
110
|
+
_assert_secret_free(self.capabilities, "gate capabilities")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class CandidateValidationResult:
|
|
115
|
+
identity: tuple
|
|
116
|
+
status: CandidateValidationStatus
|
|
117
|
+
gates_passed: frozenset
|
|
118
|
+
gate_results: tuple
|
|
119
|
+
block_reason: str | None
|
|
120
|
+
failure_point: tuple | None
|
|
121
|
+
experiment_id: str | None
|
|
122
|
+
executed_at: float | None
|
|
123
|
+
# Positively validated capability evidence collected from explicit
|
|
124
|
+
# GateResult.capabilities. Distinct from the candidate's declared
|
|
125
|
+
# capability_context; empty unless the run reached VERIFIED.
|
|
126
|
+
validated_capabilities: tuple = ()
|
|
127
|
+
evidence: Mapping[str, Any] = field(default_factory=dict)
|
|
128
|
+
# Structural evidence origin: "OFFLINE" for injected executors,
|
|
129
|
+
# "REAL" only for an opt-in live-runtime gate run.
|
|
130
|
+
provenance: str = "OFFLINE"
|
|
131
|
+
|
|
132
|
+
def __post_init__(self) -> None:
|
|
133
|
+
_assert_secret_free(self.block_reason or "", "block_reason")
|
|
134
|
+
_assert_secret_free(self.evidence, "result evidence")
|
|
135
|
+
if self.provenance not in ("OFFLINE", "REAL"):
|
|
136
|
+
raise ValueError("provenance must be OFFLINE or REAL")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CandidateValidationRunner:
|
|
140
|
+
"""Coordinates an injected gate executor in fixed gate order with
|
|
141
|
+
deterministic short-circuit semantics; executes nothing itself."""
|
|
142
|
+
|
|
143
|
+
def run(
|
|
144
|
+
self,
|
|
145
|
+
instance: CandidateRuntimeInstance,
|
|
146
|
+
gate_executor: Callable[[ValidationGate], GateResult],
|
|
147
|
+
clock: Callable[[], float] = lambda: None,
|
|
148
|
+
experiment_id: str | None = None,
|
|
149
|
+
provenance: str = "OFFLINE",
|
|
150
|
+
real_invocation: bool = False,
|
|
151
|
+
) -> CandidateValidationResult:
|
|
152
|
+
if provenance == "REAL" and not real_invocation:
|
|
153
|
+
raise ValueError("REAL provenance requires real invocation evidence")
|
|
154
|
+
executed_at = clock()
|
|
155
|
+
passed: list = []
|
|
156
|
+
results: list = []
|
|
157
|
+
evidence: dict = {}
|
|
158
|
+
block_reason = None
|
|
159
|
+
failure_point = None
|
|
160
|
+
collected_capabilities: set = set()
|
|
161
|
+
|
|
162
|
+
for gate in ValidationGate:
|
|
163
|
+
outcome = gate_executor(gate)
|
|
164
|
+
if not isinstance(outcome, GateResult):
|
|
165
|
+
raise ValueError("gate executor must return a GateResult")
|
|
166
|
+
if outcome.gate is not gate:
|
|
167
|
+
raise ValueError("gate executor returned a result for a different gate")
|
|
168
|
+
results.append(outcome)
|
|
169
|
+
evidence[gate.name] = outcome.verdict.value
|
|
170
|
+
if outcome.verdict is GateVerdict.PASS:
|
|
171
|
+
passed.append(gate)
|
|
172
|
+
# Only explicit structured evidence counts; never the
|
|
173
|
+
# candidate's declared capability_context, never plain strings.
|
|
174
|
+
collected_capabilities.update(outcome.capabilities)
|
|
175
|
+
continue
|
|
176
|
+
if outcome.verdict is GateVerdict.BLOCKED:
|
|
177
|
+
block_reason = outcome.reason or "external condition missing"
|
|
178
|
+
break
|
|
179
|
+
if outcome.verdict is GateVerdict.FAILED:
|
|
180
|
+
failure_point = (gate, (outcome.reason or "integration defect").split(":", 1)[0])
|
|
181
|
+
break
|
|
182
|
+
# NOT_RUN: validation not executed for this candidate yet.
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
if block_reason is not None:
|
|
186
|
+
status = CandidateValidationStatus.BLOCKED
|
|
187
|
+
elif failure_point is not None:
|
|
188
|
+
status = CandidateValidationStatus.FAILED
|
|
189
|
+
elif len(passed) == len(list(ValidationGate)):
|
|
190
|
+
status = CandidateValidationStatus.VERIFIED
|
|
191
|
+
else:
|
|
192
|
+
status = CandidateValidationStatus.NOT_VERIFIED
|
|
193
|
+
|
|
194
|
+
# Capability evidence only counts for a fully verified run: a
|
|
195
|
+
# short-circuited validation is incomplete evidence and must not
|
|
196
|
+
# feed pool admission.
|
|
197
|
+
validated = tuple(sorted(collected_capabilities)) if status is CandidateValidationStatus.VERIFIED else ()
|
|
198
|
+
|
|
199
|
+
return CandidateValidationResult(
|
|
200
|
+
identity=instance.identity,
|
|
201
|
+
status=status,
|
|
202
|
+
gates_passed=frozenset(passed),
|
|
203
|
+
gate_results=tuple(results),
|
|
204
|
+
block_reason=block_reason,
|
|
205
|
+
failure_point=failure_point,
|
|
206
|
+
experiment_id=experiment_id,
|
|
207
|
+
executed_at=executed_at,
|
|
208
|
+
validated_capabilities=validated,
|
|
209
|
+
evidence=evidence,
|
|
210
|
+
provenance=provenance,
|
|
211
|
+
)
|