pdl-taskmaster 2.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.
- pdl_taskmaster/__init__.py +6 -0
- pdl_taskmaster/contracts/AUTHORITY_MAP.json +19 -0
- pdl_taskmaster/contracts/CALIBRATION.json +7 -0
- pdl_taskmaster/contracts/CONTRACT_MANIFEST.json +231 -0
- pdl_taskmaster/contracts/EXECUTION_CONTRACT.json +615 -0
- pdl_taskmaster/contracts/IMPLEMENTATION_BOUNDARY.json +32 -0
- pdl_taskmaster/contracts/VERIFICATION_CONTRACT.json +379 -0
- pdl_taskmaster/contracts/standards/ARTIFACT_STANDARD.md +7 -0
- pdl_taskmaster/contracts/standards/AUTHORITY_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/CONFORMANCE_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/CONTEXT_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/EXECUTION_STANDARD.md +14 -0
- pdl_taskmaster/contracts/standards/HANDOFF_STANDARD.md +21 -0
- pdl_taskmaster/contracts/standards/INSTALLATION_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/PDL_STANDARD.md +19 -0
- pdl_taskmaster/contracts/standards/PROMPT_STANDARD.md +13 -0
- pdl_taskmaster/contracts/standards/PROTOCOL_STANDARD.md +17 -0
- pdl_taskmaster/contracts/standards/RESPONSE_PLAN_STANDARD.md +24 -0
- pdl_taskmaster/contracts/standards/RESULT_STANDARD.md +35 -0
- pdl_taskmaster/contracts/standards/REVIEW_STANDARD.md +31 -0
- pdl_taskmaster/contracts/standards/SEMANTIC_INPUT_STANDARD.md +15 -0
- pdl_taskmaster/contracts/standards/TASK_SEMANTICS_STANDARD.md +9 -0
- pdl_taskmaster/controller/__init__.py +0 -0
- pdl_taskmaster/controller/mechanical_controller.py +542 -0
- pdl_taskmaster/controller/schemas/activation_decision.schema.json +34 -0
- pdl_taskmaster/controller/schemas/artifact_body.schema.json +14 -0
- pdl_taskmaster/controller/schemas/artifact_review_facts.schema.json +91 -0
- pdl_taskmaster/controller/schemas/bootstrap_analysis.schema.json +60 -0
- pdl_taskmaster/controller/schemas/execution_draft.schema.json +47 -0
- pdl_taskmaster/controller/schemas/execution_input_decision.schema.json +43 -0
- pdl_taskmaster/controller/schemas/execution_outcome.schema.json +201 -0
- pdl_taskmaster/controller/schemas/plan_artifact.schema.json +14 -0
- pdl_taskmaster/controller/schemas/prompt_artifact.schema.json +63 -0
- pdl_taskmaster/controller/schemas/protocol_discussion.schema.json +8 -0
- pdl_taskmaster/controller/schemas/result_ir_repair.schema.json +140 -0
- pdl_taskmaster/eval/__init__.py +1 -0
- pdl_taskmaster/eval/build_adversarial_battery.py +351 -0
- pdl_taskmaster/eval/compare_eval_runs.py +251 -0
- pdl_taskmaster/eval/control_api_call.py +428 -0
- pdl_taskmaster/eval/export_rlcd_dataset.py +799 -0
- pdl_taskmaster/eval/fidelity_scan.py +268 -0
- pdl_taskmaster/eval/gen_fidelity_battery.py +457 -0
- pdl_taskmaster/eval/leak_scan.py +260 -0
- pdl_taskmaster/eval/rerecord_fixtures.py +139 -0
- pdl_taskmaster/eval/run_canary_shape_probe.py +229 -0
- pdl_taskmaster/eval/run_dual_gate_probe.py +404 -0
- pdl_taskmaster/eval/run_qualified_batch.py +841 -0
- pdl_taskmaster/eval/summarize_run.py +194 -0
- pdl_taskmaster/host/__init__.py +1 -0
- pdl_taskmaster/host/app.py +164 -0
- pdl_taskmaster/host/cli.py +100 -0
- pdl_taskmaster/host/repl.py +866 -0
- pdl_taskmaster/observation/__init__.py +1 -0
- pdl_taskmaster/observation/cli.py +47 -0
- pdl_taskmaster/observation/observed_session.py +219 -0
- pdl_taskmaster/observation/parsers.py +66 -0
- pdl_taskmaster/observation/records.py +58 -0
- pdl_taskmaster/observation/session.py +3 -0
- pdl_taskmaster/observation/sink.py +3 -0
- pdl_taskmaster/observation/sinks.py +20 -0
- pdl_taskmaster/providers/README.md +66 -0
- pdl_taskmaster/providers/__init__.py +1 -0
- pdl_taskmaster/providers/api_worker.py +411 -0
- pdl_taskmaster/providers/base.py +26 -0
- pdl_taskmaster/providers/codex_worker.py +328 -0
- pdl_taskmaster/providers/fixtures.py +157 -0
- pdl_taskmaster/providers/live_stub.py +66 -0
- pdl_taskmaster/providers/recorded.py +120 -0
- pdl_taskmaster/py.typed +1 -0
- pdl_taskmaster/runtime/__init__.py +0 -0
- pdl_taskmaster/runtime/context_compiler.py +151 -0
- pdl_taskmaster/runtime/model_classification.py +290 -0
- pdl_taskmaster/runtime/normative_store.py +157 -0
- pdl_taskmaster/runtime/operation_bridge.py +336 -0
- pdl_taskmaster/runtime/presentation.py +18 -0
- pdl_taskmaster/runtime/quarantine.py +91 -0
- pdl_taskmaster/runtime/result_ir.py +401 -0
- pdl_taskmaster/runtime/semantic_observer.py +40 -0
- pdl_taskmaster/runtime/session_engine.py +1168 -0
- pdl_taskmaster/runtime/standard_registry.py +55 -0
- pdl_taskmaster/runtime/wire_payloads.py +462 -0
- pdl_taskmaster/runtime/worker-bootstrap.txt +4 -0
- pdl_taskmaster/runtime/workspace.py +602 -0
- pdl_taskmaster/tracking/__init__.py +1 -0
- pdl_taskmaster/tracking/log_live_session.py +98 -0
- pdl_taskmaster/tracking/mlflow_sink.py +58 -0
- pdl_taskmaster/verify/verify_repl_baseline.py +299 -0
- pdl_taskmaster-2.4.0.dist-info/METADATA +155 -0
- pdl_taskmaster-2.4.0.dist-info/RECORD +92 -0
- pdl_taskmaster-2.4.0.dist-info/WHEEL +4 -0
- pdl_taskmaster-2.4.0.dist-info/entry_points.txt +2 -0
- pdl_taskmaster-2.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pdl_taskmaster.providers.base import TransportError, WorkerResult
|
|
15
|
+
|
|
16
|
+
_JSON_ONLY_SUFFIX = (
|
|
17
|
+
"\n\nReturn only a JSON object. Do not include markdown fences, commentary, or extra text."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_SEMANTIC_READ_OPERATIONS = {"BOOTSTRAP_ANALYSIS", "INTERPRET_ACTIVATION"}
|
|
22
|
+
|
|
23
|
+
DEFAULT_PROVIDER_PINNING: dict[str, Any] = {
|
|
24
|
+
"order": ["Google"],
|
|
25
|
+
"allow_fallbacks": False,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
DEFAULT_SAFETY_SETTINGS: list[dict[str, str]] = [
|
|
29
|
+
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
|
30
|
+
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
|
31
|
+
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
|
32
|
+
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ApiWorker:
|
|
37
|
+
"""LIVE SEMANTIC WORKER backed by a direct Responses-API HTTP call.
|
|
38
|
+
|
|
39
|
+
This is a drop-in replacement for CodexWorker that talks straight to an
|
|
40
|
+
OpenAI-compatible `/responses` endpoint (e.g. OpenRouter) instead of
|
|
41
|
+
shelling out to `codex exec`. It sends exactly the same request.prompt
|
|
42
|
+
every other worker receives -- no tool definitions, no sandbox, no
|
|
43
|
+
agentic system prompt -- so the paid overhead is the actual projection
|
|
44
|
+
content, not a coding-agent's scaffold.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
model: str = "z-ai/glm-4.7",
|
|
51
|
+
repo_root: str | Path,
|
|
52
|
+
base_url: str = "https://openrouter.ai/api/v1",
|
|
53
|
+
api_key_env: str = "OPENROUTER_API_KEY",
|
|
54
|
+
api_key_command: list[str] | None = None,
|
|
55
|
+
timeout: float = 600.0,
|
|
56
|
+
capture_tokens: bool = True,
|
|
57
|
+
reasoning_effort: str | None = None,
|
|
58
|
+
reasoning_by_operation: dict[str, str] | None = None,
|
|
59
|
+
model_by_operation: dict[str, str] | None = None,
|
|
60
|
+
reorder_keys_for_cache: bool = False,
|
|
61
|
+
structured_output: bool = False,
|
|
62
|
+
provider_pinning: dict[str, Any] | None = None,
|
|
63
|
+
safety_settings: list[dict[str, str]] | None = None,
|
|
64
|
+
on_progress: Any = None,
|
|
65
|
+
):
|
|
66
|
+
self.model = model
|
|
67
|
+
self.base_url = base_url.rstrip("/")
|
|
68
|
+
self.api_key_env = api_key_env
|
|
69
|
+
self.timeout = timeout
|
|
70
|
+
self.capture_tokens = capture_tokens
|
|
71
|
+
self.reasoning_effort = reasoning_effort
|
|
72
|
+
if reasoning_by_operation:
|
|
73
|
+
self.reasoning_by_operation = dict(reasoning_by_operation)
|
|
74
|
+
elif reasoning_effort is None:
|
|
75
|
+
from pdl_taskmaster.runtime.model_classification import get_proportional_reasoning_mapping
|
|
76
|
+
self.reasoning_by_operation = get_proportional_reasoning_mapping(self.model)
|
|
77
|
+
else:
|
|
78
|
+
self.reasoning_by_operation = {}
|
|
79
|
+
self.model_by_operation = dict(model_by_operation or {})
|
|
80
|
+
self.reorder_keys_for_cache = reorder_keys_for_cache
|
|
81
|
+
self.structured_output = structured_output
|
|
82
|
+
self.provider_pinning = provider_pinning if provider_pinning is not None else dict(DEFAULT_PROVIDER_PINNING)
|
|
83
|
+
self.safety_settings = safety_settings if safety_settings is not None else list(DEFAULT_SAFETY_SETTINGS)
|
|
84
|
+
self.on_progress = on_progress
|
|
85
|
+
self.worker_profile = "api"
|
|
86
|
+
self.api_key_command = api_key_command or self._default_api_key_command(api_key_env)
|
|
87
|
+
bootstrap_path = Path(__file__).resolve().parents[1] / "runtime" / "worker-bootstrap.txt"
|
|
88
|
+
if not bootstrap_path.is_file():
|
|
89
|
+
bootstrap_path = Path(repo_root) / "src" / "pdl_taskmaster" / "runtime" / "worker-bootstrap.txt"
|
|
90
|
+
if not bootstrap_path.is_file():
|
|
91
|
+
bootstrap_path = Path(repo_root) / "scripts" / "runtime" / "worker-bootstrap.txt"
|
|
92
|
+
self._bootstrap = bootstrap_path.read_text(encoding="utf-8")
|
|
93
|
+
self._bootstrap_prefix = self._bootstrap.rstrip() + "\n\n"
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _default_api_key_command(env_name: str) -> list[str] | None:
|
|
97
|
+
if os.environ.get(env_name):
|
|
98
|
+
return None
|
|
99
|
+
if sys.platform != "win32":
|
|
100
|
+
return None
|
|
101
|
+
# On Windows, fall back to reading Machine then User scope via PowerShell
|
|
102
|
+
# to pick up variables defined outside the current process environment.
|
|
103
|
+
script = (
|
|
104
|
+
f"$v=[Environment]::GetEnvironmentVariable('{env_name}','Machine'); "
|
|
105
|
+
f"if ([string]::IsNullOrWhiteSpace($v)) {{ $v=[Environment]::GetEnvironmentVariable('{env_name}','User') }}; "
|
|
106
|
+
f"if ([string]::IsNullOrWhiteSpace($v)) {{ Write-Error '{env_name} not found in Machine or User environment'; exit 1 }}; "
|
|
107
|
+
"[Console]::Out.Write($v)"
|
|
108
|
+
)
|
|
109
|
+
return ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script]
|
|
110
|
+
|
|
111
|
+
def _resolve_api_key(self) -> str:
|
|
112
|
+
if self.api_key_command is None:
|
|
113
|
+
key = (os.environ.get(self.api_key_env) or "").strip()
|
|
114
|
+
if not key:
|
|
115
|
+
raise TransportError(f"could not resolve {self.api_key_env}: variable is unset or empty")
|
|
116
|
+
return key
|
|
117
|
+
try:
|
|
118
|
+
proc = subprocess.run(
|
|
119
|
+
self.api_key_command,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
errors="replace",
|
|
124
|
+
timeout=15,
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
raise TransportError(f"could not run api_key_command: {exc}") from exc
|
|
128
|
+
key = (proc.stdout or "").strip()
|
|
129
|
+
if proc.returncode != 0 or not key:
|
|
130
|
+
detail = (proc.stderr or "").strip() or "empty key"
|
|
131
|
+
raise TransportError(f"could not resolve {self.api_key_env}: {detail}")
|
|
132
|
+
return key
|
|
133
|
+
|
|
134
|
+
def _reasoning_for(self, operation: str | None) -> str | int | None:
|
|
135
|
+
"""Per-operation effort wins over the global default; 'none' disables reasoning."""
|
|
136
|
+
if operation is not None and operation in self.reasoning_by_operation:
|
|
137
|
+
return self.reasoning_by_operation[operation]
|
|
138
|
+
return self.reasoning_effort
|
|
139
|
+
|
|
140
|
+
def _model_for(self, operation: str | None) -> str:
|
|
141
|
+
"""Per-operation model wins over the global default."""
|
|
142
|
+
if operation is not None and operation in self.model_by_operation:
|
|
143
|
+
return self.model_by_operation[operation]
|
|
144
|
+
return self.model
|
|
145
|
+
|
|
146
|
+
def _split_prompt(self, prompt: str) -> tuple[str, str]:
|
|
147
|
+
"""Split a rendered request.prompt back into (instructions, input).
|
|
148
|
+
|
|
149
|
+
request.prompt is always bootstrap.rstrip() + "\\n\\n" + <document>.
|
|
150
|
+
If the current bootstrap text doesn't match (e.g. it changed since
|
|
151
|
+
this worker was constructed), fall back to sending the whole prompt
|
|
152
|
+
as input with no separate instructions -- still correct, just
|
|
153
|
+
without the system/user split.
|
|
154
|
+
"""
|
|
155
|
+
if prompt.startswith(self._bootstrap_prefix):
|
|
156
|
+
return self._bootstrap.rstrip(), prompt[len(self._bootstrap_prefix):]
|
|
157
|
+
return "", prompt
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _reorder_for_cache(document_json: str) -> str:
|
|
161
|
+
"""Reorder the projection document for provider prefix caching.
|
|
162
|
+
|
|
163
|
+
Stable/shared content first (output_schema, clause list), volatile
|
|
164
|
+
content last (bound values, operation id). Parsed content is identical;
|
|
165
|
+
only key order changes. Same-shape operations (e.g. the two REVIEW
|
|
166
|
+
calls) then hold a byte-identical prompt prefix, which is what
|
|
167
|
+
provider prefix caches key on. No-op on non-JSON input.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
doc = json.loads(document_json)
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
return document_json
|
|
173
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("operation_inputs"), dict):
|
|
174
|
+
return document_json
|
|
175
|
+
inputs = doc["operation_inputs"]
|
|
176
|
+
ordered: dict[str, Any] = {}
|
|
177
|
+
for key in ("output_schema", "artifact_kind", "output_kind"):
|
|
178
|
+
if key in doc:
|
|
179
|
+
ordered[key] = doc[key]
|
|
180
|
+
new_inputs: dict[str, Any] = {}
|
|
181
|
+
for key in ("APPLICABLE_STANDARD_CLAUSES", "HIGHER_PRIORITY_CONSTRAINTS"):
|
|
182
|
+
if key in inputs:
|
|
183
|
+
new_inputs[key] = inputs[key]
|
|
184
|
+
for key, value in inputs.items():
|
|
185
|
+
if key not in new_inputs:
|
|
186
|
+
new_inputs[key] = value
|
|
187
|
+
ordered["operation_inputs"] = new_inputs
|
|
188
|
+
for key, value in doc.items():
|
|
189
|
+
if key not in ordered and key != "operation":
|
|
190
|
+
ordered[key] = value
|
|
191
|
+
ordered["operation"] = doc["operation"]
|
|
192
|
+
return json.dumps(ordered, ensure_ascii=False, separators=(",", ":"))
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _sanitize_schema_for_grammar(schema: Any) -> Any:
|
|
196
|
+
"""Sanitize schema for grammar engines (Vertex, Venice, Outlines, vLLM).
|
|
197
|
+
|
|
198
|
+
Cleans unsupported keywords like $schema, minLength, etc., converts 'const'
|
|
199
|
+
to single-item 'enum', while strictly preserving oneOf branch structures
|
|
200
|
+
and required field invariants.
|
|
201
|
+
"""
|
|
202
|
+
if not isinstance(schema, dict):
|
|
203
|
+
return schema
|
|
204
|
+
|
|
205
|
+
def _clean_node(node: Any) -> Any:
|
|
206
|
+
if not isinstance(node, dict):
|
|
207
|
+
return node
|
|
208
|
+
res = dict(node)
|
|
209
|
+
for key_to_drop in ("$schema", "minLength", "maxLength", "minItems", "maxItems", "uniqueItems"):
|
|
210
|
+
res.pop(key_to_drop, None)
|
|
211
|
+
if "const" in res:
|
|
212
|
+
res["enum"] = [res.pop("const")]
|
|
213
|
+
if "type" not in res:
|
|
214
|
+
res["type"] = "string"
|
|
215
|
+
if "properties" in res and isinstance(res["properties"], dict):
|
|
216
|
+
clean_props: dict[str, Any] = {}
|
|
217
|
+
for k, v in res["properties"].items():
|
|
218
|
+
clean_props[k] = _clean_node(v)
|
|
219
|
+
res["properties"] = clean_props
|
|
220
|
+
if "items" in res:
|
|
221
|
+
res["items"] = _clean_node(res["items"])
|
|
222
|
+
if "oneOf" in res and isinstance(res["oneOf"], list):
|
|
223
|
+
# Vertex/Gemini grammar enforcement accepts anyOf but rejects
|
|
224
|
+
# oneOf; all oneOf branches here are distinguished by a const
|
|
225
|
+
# 'kind' discriminator, so anyOf is semantically equivalent.
|
|
226
|
+
res.pop("oneOf", None)
|
|
227
|
+
res["anyOf"] = [_clean_node(b) for b in node["oneOf"]]
|
|
228
|
+
if "anyOf" in res and isinstance(res["anyOf"], list):
|
|
229
|
+
res["anyOf"] = [_clean_node(b) for b in res["anyOf"]]
|
|
230
|
+
if "allOf" in res and isinstance(res["allOf"], list):
|
|
231
|
+
res["allOf"] = [_clean_node(b) for b in res["allOf"]]
|
|
232
|
+
return res
|
|
233
|
+
|
|
234
|
+
return _clean_node(schema)
|
|
235
|
+
|
|
236
|
+
def _send_json_with_retries(self, req: urllib.request.Request) -> dict[str, Any]:
|
|
237
|
+
"""POST with exponential-backoff retries; returns the parsed response.
|
|
238
|
+
|
|
239
|
+
Retries transient transport conditions: 429/5xx, URLError, timeouts.
|
|
240
|
+
Non-retryable HTTP errors (4xx besides 429) raise immediately.
|
|
241
|
+
"""
|
|
242
|
+
raw = None
|
|
243
|
+
for attempt in range(5):
|
|
244
|
+
try:
|
|
245
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
246
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
247
|
+
break
|
|
248
|
+
except urllib.error.HTTPError as exc:
|
|
249
|
+
if exc.code in (429, 502, 503, 504) and attempt < 4:
|
|
250
|
+
time.sleep(3.0 * (2 ** attempt))
|
|
251
|
+
continue
|
|
252
|
+
detail = exc.read().decode("utf-8", errors="replace")[:2000]
|
|
253
|
+
raise TransportError(f"api worker HTTP {exc.code}: {detail}") from exc
|
|
254
|
+
except urllib.error.URLError as exc:
|
|
255
|
+
if attempt < 4:
|
|
256
|
+
time.sleep(3.0 * (2 ** attempt))
|
|
257
|
+
continue
|
|
258
|
+
raise TransportError(f"api worker transport error: {exc.reason}") from exc
|
|
259
|
+
except (TimeoutError, socket.timeout) as exc:
|
|
260
|
+
if attempt < 4:
|
|
261
|
+
time.sleep(3.0 * (2 ** attempt))
|
|
262
|
+
continue
|
|
263
|
+
raise TransportError(f"api worker timed out after {self.timeout}s") from exc
|
|
264
|
+
if raw is None:
|
|
265
|
+
raise TransportError("api worker failed after retries")
|
|
266
|
+
try:
|
|
267
|
+
return json.loads(raw)
|
|
268
|
+
except json.JSONDecodeError as exc:
|
|
269
|
+
raise TransportError(f"api worker returned non-JSON response: {raw[:500]}") from exc
|
|
270
|
+
|
|
271
|
+
def call(self, request: Any) -> WorkerResult:
|
|
272
|
+
instructions, input_text = self._split_prompt(request.prompt)
|
|
273
|
+
if self.reorder_keys_for_cache:
|
|
274
|
+
input_text = self._reorder_for_cache(input_text.lstrip())
|
|
275
|
+
input_text = input_text.rstrip() + _JSON_ONLY_SUFFIX
|
|
276
|
+
|
|
277
|
+
body: dict[str, Any] = {"model": self._model_for(getattr(request, "operation", None)), "input": input_text}
|
|
278
|
+
# ADR-0009 finding: schema enforcement on the semantic-read boundary
|
|
279
|
+
# (BOOTSTRAP_ANALYSIS) degrades interpretation quality — a lazy
|
|
280
|
+
# structured summary classifies substantive tasks as instruction-free,
|
|
281
|
+
# losing the entire task upstream of every gate. Structured output is
|
|
282
|
+
# for ops whose SHAPE is the contract (drafts, executions, reviews);
|
|
283
|
+
# the semantic read must stay free-text.
|
|
284
|
+
operation_name = getattr(request, "operation", None)
|
|
285
|
+
schema_enforced = self.structured_output and operation_name not in _SEMANTIC_READ_OPERATIONS
|
|
286
|
+
if instructions:
|
|
287
|
+
body["instructions"] = instructions
|
|
288
|
+
effort = self._reasoning_for(getattr(request, "operation", None))
|
|
289
|
+
if effort == "none":
|
|
290
|
+
body["reasoning"] = {"enabled": False}
|
|
291
|
+
elif effort is not None:
|
|
292
|
+
if isinstance(effort, int) or (isinstance(effort, str) and effort.isdigit()):
|
|
293
|
+
body["reasoning"] = {"max_tokens": int(effort)}
|
|
294
|
+
else:
|
|
295
|
+
body["reasoning"] = {"effort": effort}
|
|
296
|
+
|
|
297
|
+
if self.provider_pinning:
|
|
298
|
+
body["provider"] = self.provider_pinning
|
|
299
|
+
if self.safety_settings:
|
|
300
|
+
body["safety_settings"] = self.safety_settings
|
|
301
|
+
|
|
302
|
+
if schema_enforced:
|
|
303
|
+
manifest = getattr(request, "manifest", None) or {}
|
|
304
|
+
output_kind = manifest.get("output_kind", "json_object")
|
|
305
|
+
from pdl_taskmaster.runtime.wire_payloads import get_operation_pydantic_schema
|
|
306
|
+
schema = get_operation_pydantic_schema(operation_name)
|
|
307
|
+
if not isinstance(schema, dict):
|
|
308
|
+
projection = getattr(request, "projection", None)
|
|
309
|
+
if projection is not None and isinstance(getattr(projection, "document", None), dict):
|
|
310
|
+
schema = projection.document.get("output_schema")
|
|
311
|
+
if not isinstance(schema, dict) and isinstance(manifest.get("output_schema"), dict):
|
|
312
|
+
schema = manifest["output_schema"]
|
|
313
|
+
elif not isinstance(schema, dict) and isinstance(manifest.get("output_schema"), str):
|
|
314
|
+
schema_path = self.repo_root / manifest["output_schema"]
|
|
315
|
+
if schema_path.is_file():
|
|
316
|
+
try:
|
|
317
|
+
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
318
|
+
except Exception:
|
|
319
|
+
schema = None
|
|
320
|
+
if schema and isinstance(schema, dict):
|
|
321
|
+
body["text"] = {
|
|
322
|
+
"format": {
|
|
323
|
+
"type": "json_schema",
|
|
324
|
+
"name": output_kind,
|
|
325
|
+
"schema": self._sanitize_schema_for_grammar(schema),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
api_key = self._resolve_api_key()
|
|
330
|
+
req = urllib.request.Request(
|
|
331
|
+
f"{self.base_url}/responses",
|
|
332
|
+
data=json.dumps(body).encode("utf-8"),
|
|
333
|
+
headers={
|
|
334
|
+
"Authorization": f"Bearer {api_key}",
|
|
335
|
+
"Content-Type": "application/json",
|
|
336
|
+
},
|
|
337
|
+
method="POST",
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
started = time.perf_counter()
|
|
341
|
+
data = self._send_json_with_retries(req)
|
|
342
|
+
latency_ms = (time.perf_counter() - started) * 1000.0
|
|
343
|
+
|
|
344
|
+
if self.on_progress is not None:
|
|
345
|
+
try:
|
|
346
|
+
self.on_progress(f"response received in {latency_ms:.0f}ms")
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
if data.get("error"):
|
|
351
|
+
raise TransportError(f"api worker reported an error: {data['error']}")
|
|
352
|
+
status = data.get("status")
|
|
353
|
+
if status not in (None, "completed"):
|
|
354
|
+
raise TransportError(f"api worker response status={status}: {data.get('incomplete_details')}")
|
|
355
|
+
|
|
356
|
+
text = self._extract_output_text(data)
|
|
357
|
+
if not text:
|
|
358
|
+
# Empty output with status=completed is a stochastic upstream fault
|
|
359
|
+
# (output tokens consumed, message dropped — observed on shared-pool
|
|
360
|
+
# aggregators). It is a transport condition, not model behavior:
|
|
361
|
+
# retry with the same exponential-backoff treatment as 429/5xx.
|
|
362
|
+
for retry in range(3):
|
|
363
|
+
time.sleep(3.0 * (2 ** retry))
|
|
364
|
+
data = self._send_json_with_retries(req)
|
|
365
|
+
text = self._extract_output_text(data)
|
|
366
|
+
if text:
|
|
367
|
+
break
|
|
368
|
+
if not text:
|
|
369
|
+
raise TransportError("api worker returned no output_text content after empty-output retries")
|
|
370
|
+
|
|
371
|
+
usage_raw = data.get("usage") or {}
|
|
372
|
+
usage: dict[str, Any] = {}
|
|
373
|
+
for key in ("input_tokens", "output_tokens", "total_tokens"):
|
|
374
|
+
if isinstance(usage_raw.get(key), (int, float)):
|
|
375
|
+
usage[key] = usage_raw[key]
|
|
376
|
+
cached = (usage_raw.get("input_tokens_details") or {}).get("cached_tokens")
|
|
377
|
+
if isinstance(cached, (int, float)):
|
|
378
|
+
usage["cached_tokens"] = cached
|
|
379
|
+
reasoning = (usage_raw.get("output_tokens_details") or {}).get("reasoning_tokens")
|
|
380
|
+
if isinstance(reasoning, (int, float)):
|
|
381
|
+
usage["reasoning_tokens"] = reasoning
|
|
382
|
+
|
|
383
|
+
metadata: dict[str, Any] = {
|
|
384
|
+
"worker": "api",
|
|
385
|
+
"model": self.model,
|
|
386
|
+
"observed_model": data.get("model"),
|
|
387
|
+
"mode": "live-api",
|
|
388
|
+
"reasoning_effort": effort if effort is not None else "provider_default",
|
|
389
|
+
"operation": getattr(request, "operation", None),
|
|
390
|
+
"not_a_qualified_measurement_condition": True,
|
|
391
|
+
"base_url": self.base_url,
|
|
392
|
+
"response_id": data.get("id"),
|
|
393
|
+
"latency_ms": round(latency_ms, 3),
|
|
394
|
+
"token_telemetry": "enabled" if self.capture_tokens else "disabled",
|
|
395
|
+
}
|
|
396
|
+
if self.capture_tokens and usage:
|
|
397
|
+
metadata["usage"] = usage
|
|
398
|
+
metadata["usage_source"] = "responses_api"
|
|
399
|
+
metadata["usage_exact"] = True
|
|
400
|
+
return WorkerResult(text, metadata)
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _extract_output_text(data: dict[str, Any]) -> str:
|
|
404
|
+
chunks: list[str] = []
|
|
405
|
+
for item in data.get("output") or []:
|
|
406
|
+
if item.get("type") != "message":
|
|
407
|
+
continue
|
|
408
|
+
for part in item.get("content") or []:
|
|
409
|
+
if part.get("type") == "output_text" and isinstance(part.get("text"), str):
|
|
410
|
+
chunks.append(part["text"])
|
|
411
|
+
return "".join(chunks).strip()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class WorkerResult:
|
|
9
|
+
text: str
|
|
10
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportError(RuntimeError):
|
|
14
|
+
"""Worker/transport failure before PDL wire validity is evaluated."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkerAdapter(Protocol):
|
|
18
|
+
def call(self, request: Any) -> WorkerResult:
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_model_call(adapter: WorkerAdapter):
|
|
23
|
+
def model_call(request: Any) -> str:
|
|
24
|
+
return adapter.call(request).text
|
|
25
|
+
|
|
26
|
+
return model_call
|