premanmcp 1.1.11 → 1.1.13

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.
@@ -0,0 +1,279 @@
1
+ """Opt-in evidence for ASSERT test-set generation; offline replay and bounded retry.
2
+
3
+ Enable through run_assert.py with PREMAN_EVAL_DEBUG_DIR. Captures stay local;
4
+ stdout/stderr contain summaries and paths, never request or response bodies.
5
+ Keep preman-mcp/bin/eval_generation_debug.py byte-identical to this file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ from datetime import datetime, timezone
13
+ from functools import wraps
14
+ import importlib.metadata
15
+ import json
16
+ import logging
17
+ import os
18
+ from pathlib import Path
19
+ import re
20
+ import runpy
21
+ import sys
22
+ import uuid
23
+
24
+ log = logging.getLogger("preman.eval_generation")
25
+ # Share names across structured fields and malformed/embedded JSON strings.
26
+ _SECRET_NAMES = (
27
+ r"(?:authorization|proxy[-_]authorization|[\w-]*api[-_]?key|"
28
+ r"[\w-]*password[\w-]*|[\w-]*secret[\w-]*|[\w-]*credential[\w-]*|"
29
+ r"[\w-]*token|cookie|set[-_]cookie)"
30
+ )
31
+ _SECRET_FIELD = re.compile(_SECRET_NAMES, re.I)
32
+ _TEXT_SECRET = re.compile(
33
+ r"""(\b""" + _SECRET_NAMES + r"""["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)""", re.I
34
+ )
35
+ _HEADER_SECRET = re.compile(
36
+ r"(?i)(\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*)[^\r\n]+"
37
+ )
38
+
39
+
40
+ def _diagnose(parsed, schema):
41
+ """Mirror the pinned stage's acceptance rules; reuse its case normalizer."""
42
+ from assert_ai.stages.test_set import normalize_generated_test_case
43
+
44
+ array = schema["properties"]["test_set"]
45
+ count = array.get("minItems", 1)
46
+ result = {"valid": False, "reason": "invalid_test_set_payload", "expected": count}
47
+ if not isinstance(parsed, dict) or not isinstance(parsed.get("test_set"), list):
48
+ return result
49
+ cases = parsed["test_set"]
50
+ result["received"] = len(cases)
51
+ if len(cases) < count:
52
+ return {**result, "reason": "too_few_cases"}
53
+ tool_source = "per_test_case" if "tools" in array["items"]["properties"] else "runtime"
54
+ for index, case in enumerate(cases[:count]):
55
+ try:
56
+ if not isinstance(case, dict):
57
+ raise ValueError("case is not an object")
58
+ normalize_generated_test_case(case, tool_source=tool_source, fixed_system_prompt=None)
59
+ except Exception as exc:
60
+ # A malformed tool can make the upstream normalizer raise AttributeError.
61
+ # That rejected reply is evidence too; never lose it during diagnosis.
62
+ return {**result, "reason": "invalid_case", "case_index": index, "error_type": type(exc).__name__}
63
+ return {**result, "valid": True, "reason": "accepted"}
64
+
65
+
66
+ def _redact(value):
67
+ """Remove credential fields/patterns and literal credentials from the environment."""
68
+ from assert_ai.core.session import _sanitize_response_text
69
+
70
+ secrets = sorted({
71
+ value for key, value in os.environ.items()
72
+ if len(value) >= 6 and re.search(r"KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL", key, re.I)
73
+ }, key=len, reverse=True)
74
+ changed = False
75
+
76
+ def visit(item):
77
+ nonlocal changed
78
+ if isinstance(item, dict):
79
+ out = {}
80
+ for key, val in item.items():
81
+ if _SECRET_FIELD.fullmatch(key):
82
+ out[key] = "[REDACTED]"
83
+ changed = True
84
+ else:
85
+ out[key] = visit(val)
86
+ return out
87
+ if isinstance(item, list):
88
+ return [visit(val) for val in item]
89
+ if isinstance(item, str):
90
+ clean = item
91
+ # Provider content often contains serialized JSON, so dict-only
92
+ # redaction cannot see its password/api_key fields.
93
+ try:
94
+ embedded = json.loads(clean)
95
+ except (ValueError, TypeError):
96
+ embedded = None
97
+ if isinstance(embedded, (dict, list)):
98
+ sanitized = visit(embedded)
99
+ if sanitized != embedded:
100
+ clean = json.dumps(sanitized, ensure_ascii=False)
101
+ for secret in secrets:
102
+ clean = clean.replace(secret, "[REDACTED]")
103
+ clean = _TEXT_SECRET.sub(r'\1"[REDACTED]"', clean)
104
+ clean = _HEADER_SECRET.sub(r'\1[REDACTED]', clean)
105
+ clean = _sanitize_response_text(clean)
106
+ changed |= clean != item
107
+ return clean
108
+ return item
109
+
110
+ clean = visit(value)
111
+ return clean, changed
112
+
113
+
114
+ def _capture(directory, model, messages, schema_name, schema, options, response):
115
+ from assert_ai.core.model_client import to_jsonable
116
+
117
+ result = _diagnose(response.parsed, schema)
118
+ dist = importlib.metadata.distribution("assert-ai")
119
+ source = json.loads(dist.read_text("direct_url.json") or "{}")
120
+ evidence = {
121
+ "format": "preman.eval-generation/v1",
122
+ "captured_at": datetime.now(timezone.utc).isoformat(),
123
+ "runtime": {"assert_ai_version": dist.version, "assert_ai_commit": source.get("vcs_info", {}).get("commit_id")},
124
+ "request": {"model": model, "messages": messages, "schema_name": schema_name, "json_schema": schema, "options": options},
125
+ "response": {name: getattr(response, name, None) for name in (
126
+ "text", "content", "parsed", "finish_reason", "status", "incomplete_details",
127
+ "model", "response_id", "usage", "api_mode", "raw",
128
+ )},
129
+ "result": result,
130
+ }
131
+ _, request_redacted = _redact(to_jsonable(evidence["request"]))
132
+ evidence, was_redacted = _redact(to_jsonable(evidence))
133
+ evidence["request_redacted"] = request_redacted
134
+ evidence["redacted"] = was_redacted
135
+ directory = Path(directory).expanduser().resolve()
136
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
137
+ status = "valid" if result["valid"] else "invalid"
138
+ path = directory / f"{status}-{uuid.uuid4().hex}.json"
139
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
140
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
141
+ json.dump(evidence, stream, ensure_ascii=False, indent=2)
142
+ stream.write("\n")
143
+ label = evidence["request"]["options"].get("call_label") if options is not None else None
144
+ (log.info if result["valid"] else log.warning)(
145
+ "generation status=%s batch=%s reason=%s capture=%s", status, label, result["reason"], path,
146
+ )
147
+ return path
148
+
149
+
150
+ def install():
151
+ """Observe only test-set calls, without retrying or changing their responses."""
152
+ if not os.environ.get("PREMAN_EVAL_DEBUG_DIR"):
153
+ return
154
+ from assert_ai.stages import test_set
155
+
156
+ original = test_set.generate_structured
157
+ if getattr(original, "_preman_debug_capture", False):
158
+ return
159
+
160
+ @wraps(original)
161
+ async def observed(model, messages, *, schema_name, json_schema, options=None):
162
+ response = await original(model, messages, schema_name=schema_name, json_schema=json_schema, options=options)
163
+ directory = os.environ.get("PREMAN_EVAL_DEBUG_DIR")
164
+ if directory:
165
+ try:
166
+ _capture(directory, model, messages, schema_name, json_schema, options, response)
167
+ except Exception as exc:
168
+ # Evidence storage must never turn an accepted reply into a lost batch.
169
+ # Exception text can contain body data; log only its type.
170
+ log.warning("generation capture_failed error_type=%s", type(exc).__name__)
171
+ return response
172
+
173
+ observed._preman_debug_capture = True
174
+ test_set.generate_structured = observed
175
+
176
+
177
+ def replay(capture):
178
+ """Reparse saved text and apply generation validation, with no model calls."""
179
+ from assert_ai.core.model_client import _maybe_parse_json
180
+
181
+ parsed = _maybe_parse_json(capture["response"]["text"] or "")
182
+ return {
183
+ **_diagnose(parsed, capture["request"]["json_schema"]),
184
+ "parser_matches_capture": parsed == capture["response"]["parsed"],
185
+ "redacted": capture["redacted"],
186
+ "finish_reason": capture["response"].get("finish_reason"),
187
+ }
188
+
189
+
190
+ async def _retry(capture, attempts):
191
+ """Repeat a request sequentially; one provider attempt, no error-body logs."""
192
+ from assert_ai.core import model_client
193
+ from assert_ai.core.model_client import GenerateOptions
194
+ from assert_ai.stages import test_set
195
+
196
+ if capture.get("request_redacted", capture["redacted"]):
197
+ raise ValueError("Capture was redacted; refusing to send an altered request.")
198
+ request = capture["request"]
199
+ options = GenerateOptions(**(request["options"] or {}))
200
+ options.timeout_s = min(options.timeout_s or 120, 120)
201
+ directory = os.environ.get("PREMAN_EVAL_DEBUG_DIR", "artifacts/eval-generation-debug")
202
+ generate = test_set.generate_structured
203
+ # Other harness observers use wraps(), which copies this marker too.
204
+ # Remove the entire marked chain so this command captures exactly once.
205
+ while getattr(generate, "_preman_debug_capture", False):
206
+ generate = generate.__wrapped__
207
+
208
+ async def once(call_fn, **kwargs):
209
+ return await call_fn()
210
+
211
+ for attempt in range(1, attempts + 1):
212
+ original_retries = model_client._with_retries
213
+ disabled_level = logging.root.manager.disable
214
+ try:
215
+ # This standalone diagnostic command is sequential. Disable the
216
+ # upstream loop only during its request: it otherwise retries six
217
+ # times and logs provider exceptions, which can echo private bodies.
218
+ model_client._with_retries = once
219
+ logging.disable(logging.CRITICAL)
220
+ response = await asyncio.wait_for(generate(
221
+ request["model"], request["messages"], schema_name=request["schema_name"],
222
+ json_schema=request["json_schema"], options=options,
223
+ ), timeout=120)
224
+ except Exception as exc:
225
+ print(f"Live request failed ({type(exc).__name__}); no invalid payload reproduced.", file=sys.stderr)
226
+ return 2
227
+ finally:
228
+ model_client._with_retries = original_retries
229
+ logging.disable(disabled_level)
230
+ try:
231
+ path = _capture(directory, request["model"], request["messages"], request["schema_name"], request["json_schema"], options, response)
232
+ except Exception as exc:
233
+ print(f"Capture failed ({type(exc).__name__}); stopping the probe.", file=sys.stderr)
234
+ return 2
235
+ result = _diagnose(response.parsed, request["json_schema"])
236
+ print(json.dumps({"attempt": attempt, "capture": str(path), **result}), flush=True)
237
+ if not result["valid"]:
238
+ return 1
239
+ print("No invalid payload reproduced within the attempt limit.", flush=True)
240
+ return 0
241
+
242
+
243
+ def main(argv=None):
244
+ parser = argparse.ArgumentParser(description=__doc__)
245
+ sub = parser.add_subparsers(dest="command", required=True)
246
+ for command in ("inspect", "replay", "retry"):
247
+ child = sub.add_parser(command)
248
+ child.add_argument("capture", type=Path)
249
+ if command == "retry":
250
+ child.add_argument("--attempts", type=int, default=1)
251
+ child.add_argument("--output-dir", type=Path)
252
+ args = parser.parse_args(argv)
253
+ if args.command == "retry" and not 1 <= args.attempts <= 20:
254
+ parser.error("--attempts must be from 1 to 20")
255
+ try:
256
+ capture = json.loads(args.capture.read_text(encoding="utf-8"))
257
+ if capture.get("format") != "preman.eval-generation/v1":
258
+ raise ValueError("Unrecognized generation capture format")
259
+ if args.command == "inspect":
260
+ print(json.dumps(capture, indent=2, ensure_ascii=False))
261
+ return 0
262
+ if args.command == "replay":
263
+ result = replay(capture)
264
+ print(json.dumps(result, indent=2))
265
+ return 0 if result["valid"] else 1
266
+ directory = args.output_dir or args.capture.parent
267
+ os.environ["PREMAN_EVAL_DEBUG_DIR"] = str(directory.resolve())
268
+ # Reuse the same provider workarounds as a normal generation run.
269
+ parent = Path(__file__).resolve().parent
270
+ harness = parent / ("run_assert.py" if (parent / "run_assert.py").exists() else "eval_harness.py")
271
+ runpy.run_path(str(harness))
272
+ logging.basicConfig(level=logging.INFO)
273
+ return asyncio.run(_retry(capture, args.attempts))
274
+ except (OSError, ValueError, KeyError, TypeError) as exc:
275
+ parser.error(str(exc))
276
+
277
+
278
+ if __name__ == "__main__":
279
+ sys.exit(main())
@@ -0,0 +1,284 @@
1
+ """Publish usable ASSERT cases and a separate, bounded generation error report.
2
+
3
+ Keep the npm copy byte-identical. This adapter observes the pinned stage's actual
4
+ job results, preserving its validation, retries, case IDs, and incomplete-cache
5
+ signal. It does not synthesize missing cases or repeat any model requests.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from contextvars import ContextVar
10
+ from functools import wraps
11
+ import hashlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+ import tempfile
18
+
19
+ from eval_generation_contract import generation_spec_sha256
20
+
21
+ from eval_generation_debug import _diagnose, _redact
22
+
23
+ log = logging.getLogger('preman.eval_generation')
24
+ _suite_root = ContextVar('preman_generation_suite', default=None)
25
+ _spec_hash = ContextVar('preman_generation_spec_hash', default=None)
26
+ _active = ContextVar('preman_generation', default=None)
27
+ _kind = ContextVar('preman_generation_kind', default=None)
28
+ _job = ContextVar('preman_generation_job', default=None)
29
+ _MAX_FAILURES = 100
30
+
31
+
32
+ def _failure(job, result):
33
+ """Use the stage's error verdict, with only safe response diagnostics."""
34
+ response = job.get('response')
35
+ reason = 'generation_error'
36
+ message = f"Generation failed ({type(result['error']).__name__})."
37
+ detail = {}
38
+ if response is not None:
39
+ diagnostic = _diagnose(response.parsed, job['schema'])
40
+ reason = diagnostic['reason'] if not diagnostic['valid'] else 'invalid_case'
41
+ message = {
42
+ 'invalid_test_set_payload': 'The model did not return a valid test-case payload.',
43
+ 'too_few_cases': 'The model returned fewer cases than this job requested.',
44
+ 'invalid_case': 'The generated case did not pass validation.',
45
+ }.get(reason, message)
46
+ if response.finish_reason == 'length':
47
+ reason = 'output_token_limit'
48
+ message = 'The model reached its output token limit before returning valid cases.'
49
+ detail['finish_reason'] = response.finish_reason
50
+ # Redact before clipping so a cut credential cannot evade sanitization.
51
+ detail['response_text'] = _redact(response.text or '')[0][:8000]
52
+ usage = response.usage
53
+ if usage is not None:
54
+ detail['completion_tokens'] = usage.completion_tokens
55
+ raw = usage.raw if isinstance(usage.raw, dict) else {}
56
+ tokens = raw.get('completion_tokens_details') or raw.get('output_tokens_details') or {}
57
+ if isinstance(tokens, dict):
58
+ detail['reasoning_tokens'] = tokens.get('reasoning_tokens')
59
+ detail['max_tokens'] = job.get('max_tokens')
60
+ identity = _redact(job['identity'])[0]
61
+ identity['behavior'] = identity['behavior'][:256]
62
+ identity['dimensions'] = {str(k)[:128]: str(v)[:1024] for k, v in list(identity['dimensions'].items())[:32]}
63
+ return {**identity, 'reason': reason, 'message': message, **detail}
64
+
65
+
66
+ def _atomic_write(path, content):
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}-', dir=path.parent)
69
+ try:
70
+ with os.fdopen(fd, 'wb') as stream:
71
+ stream.write(content)
72
+ os.replace(temporary, path)
73
+ finally:
74
+ if os.path.exists(temporary):
75
+ os.unlink(temporary)
76
+
77
+
78
+ def _publish(state, result):
79
+ from assert_ai.core.artifact_cache import _is_local_edit
80
+
81
+ version_bank = Path(result['test_set_path'])
82
+ suite = Path(_suite_root.get() or version_bank.parent)
83
+ bank = version_bank.read_bytes()
84
+ jobs = sorted(state['jobs'], key=lambda job: (job['identity']['kind'], job['order']))
85
+ failed = [job for job in jobs if job.get('error') is not None]
86
+ generated = int(result['saved_count'])
87
+ report = {
88
+ 'format': 'preman.eval-generation-status/v1',
89
+ 'status': 'partial' if failed and generated else 'failed' if failed else 'complete',
90
+ 'expected_count': sum(job['identity']['case_count'] for job in jobs),
91
+ 'generated_count': generated,
92
+ 'failed_count': sum(job['identity']['case_count'] for job in failed),
93
+ 'failure_job_count': len(failed),
94
+ 'failures_truncated': len(failed) > _MAX_FAILURES,
95
+ 'failures': [job['failure'] for job in failed[:_MAX_FAILURES]],
96
+ 'bank_sha256': hashlib.sha256(bank).hexdigest(),
97
+ 'generation_spec_sha256': _spec_hash.get(),
98
+ }
99
+ try:
100
+ report['taxonomy_sha256'] = hashlib.sha256(Path(state['taxonomy_path']).read_bytes()).hexdigest()
101
+ except OSError:
102
+ # Without a matching rubric the API declines reuse; ready cases and
103
+ # generation errors must still remain visible.
104
+ report['taxonomy_sha256'] = None
105
+ payload = (json.dumps(report, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
106
+ _atomic_write(version_bank.parent / 'generation.json', payload)
107
+ if version_bank.resolve() != (suite / 'test_set.jsonl').resolve():
108
+ # Retain ASSERT's protection for edited/published banks. A partial
109
+ # bank has no complete-cache sidecar, so future generation cannot
110
+ # silently renumber its cases under the same historical identities.
111
+ if _is_local_edit(suite, 'test_set', suite / 'test_set.jsonl', version_bank):
112
+ log.warning('generation kept the existing bank and report; new output is at %s; use a new suite to publish different cases', version_bank)
113
+ return
114
+ _atomic_write(suite / 'test_set.jsonl', bank)
115
+ _atomic_write(suite / 'generation.json', payload)
116
+ log.info('generation status=%s generated=%d failed=%d report=%s', report['status'], generated, report['failed_count'], suite / 'generation.json')
117
+
118
+
119
+
120
+ def _select_published_partial(ctx):
121
+ """Evaluate the bank offered to users, even beside an older complete cache."""
122
+ suite = Path(ctx['suite_root'])
123
+ bank = suite / 'test_set.jsonl'
124
+ try:
125
+ report = json.loads((suite / 'generation.json').read_text(encoding='utf-8'))
126
+ if not isinstance(report, dict) or report.get('status') != 'partial':
127
+ return
128
+ if report.get('bank_sha256') != hashlib.sha256(bank.read_bytes()).hexdigest():
129
+ return
130
+ except (OSError, ValueError):
131
+ return
132
+ ctx['test_set_path'] = str(bank)
133
+ # This is a usable partial bank, not the old cache version or a newly
134
+ # finalized artifact. Do not attribute its cases to that older version.
135
+ ctx.setdefault('artifact_versions', {}).pop('test_set', None)
136
+ ctx.pop('test_set_artifact_dir', None)
137
+ ctx.pop('stratification_path', None)
138
+ log.info('generation evaluating published partial bank %s', bank)
139
+
140
+
141
+ def install():
142
+ """Adapt the pinned stage without modifying the installed ASSERT package."""
143
+ from assert_ai.stages import test_set
144
+ from assert_ai.core import artifact_cache
145
+ from assert_ai import config
146
+
147
+ original_context = config.load_runtime_context
148
+ if not getattr(original_context, '_preman_generation_status', False):
149
+ @wraps(original_context)
150
+ def load_context(raw, cfg_path, **kwargs):
151
+ # The runner applies CLI overrides before this call. Hash before
152
+ # ASSERT injects model defaults into raw's nested stage mappings.
153
+ fingerprint = generation_spec_sha256(raw, config_dir=Path(cfg_path).parent)
154
+ ctx = original_context(raw, cfg_path, **kwargs)
155
+ ctx['_preman_generation_spec_sha256'] = fingerprint
156
+ return ctx
157
+
158
+ load_context._preman_generation_status = True
159
+ config.load_runtime_context = load_context
160
+
161
+ original_activate = artifact_cache.activate_latest_artifacts
162
+ if not getattr(original_activate, '_preman_generation_status', False):
163
+ @wraps(original_activate)
164
+ def activate_latest(ctx):
165
+ original_activate(ctx)
166
+ _select_published_partial(ctx)
167
+
168
+ activate_latest._preman_generation_status = True
169
+ artifact_cache.activate_latest_artifacts = activate_latest
170
+ # runner imports this by name; support installation both before and after
171
+ # that import without importing runner (and loading dotenv) just to patch.
172
+ runner = sys.modules.get('assert_ai.runner')
173
+ if runner is not None:
174
+ runner.activate_latest_artifacts = artifact_cache.activate_latest_artifacts
175
+ runner.load_runtime_context = config.load_runtime_context
176
+
177
+ def patch(name, factory):
178
+ original = getattr(test_set, name)
179
+ if getattr(original, '_preman_generation_status', False):
180
+ return
181
+ wrapped = factory(original)
182
+ wrapped._preman_generation_status = True
183
+ setattr(test_set, name, wrapped)
184
+
185
+ def stage_wrapper(original):
186
+ @wraps(original)
187
+ async def run(ctx, raw_cfg):
188
+ token = _suite_root.set(ctx['suite_root'])
189
+ spec_token = _spec_hash.set(ctx.get('_preman_generation_spec_sha256'))
190
+ try:
191
+ return await original(ctx, raw_cfg)
192
+ finally:
193
+ _spec_hash.reset(spec_token)
194
+ _suite_root.reset(token)
195
+ return run
196
+
197
+ def orchestration_wrapper(original):
198
+ @wraps(original)
199
+ async def run_test_set(**kwargs):
200
+ state = {'jobs': [], 'taxonomy_path': kwargs['taxonomy_path']}
201
+ token = _active.set(state)
202
+ try:
203
+ result = await original(**kwargs)
204
+ _publish(state, result)
205
+ if not result['saved_count'] and result['errored_count']:
206
+ raise ValueError('No test cases were generated; see generation.json for failed jobs')
207
+ return result
208
+ finally:
209
+ _active.reset(token)
210
+ return run_test_set
211
+
212
+ def records_wrapper(original):
213
+ @wraps(original)
214
+ async def generate_records(**kwargs):
215
+ token = _kind.set(kwargs['kind'])
216
+ try:
217
+ return await original(**kwargs)
218
+ except test_set.LLMAuthError:
219
+ raise
220
+ except Exception as exc:
221
+ state = _active.get()
222
+ jobs = [j for j in state['jobs'] if j['identity']['kind'] == kwargs['kind']] if state else []
223
+ # ASSERT raises the first batch error when a whole kind fails.
224
+ # Let the other kind finish, then decide failure for the bank.
225
+ # Planning/configuration errors do not satisfy this condition.
226
+ if jobs and all(j.get('error') is not None for j in jobs) and any(j['error'] is exc for j in jobs):
227
+ return {'records': [], 'errored_count': len(jobs)}
228
+ raise
229
+ finally:
230
+ _kind.reset(token)
231
+ return generate_records
232
+
233
+ def gather_wrapper(original):
234
+ @wraps(original)
235
+ async def gather(items, *, limit, worker):
236
+ state, kind = _active.get(), _kind.get()
237
+ if state is None or kind is None:
238
+ return await original(items, limit=limit, worker=worker)
239
+
240
+ async def observed(item):
241
+ behavior = str(item.behavior.get('name') or '')
242
+ dimensions = {'behavior': behavior}
243
+ dimensions.update({name: str(level['name']) for name, level in (item.tuple_spec or {}).items()})
244
+ job = {
245
+ 'order': item.order,
246
+ 'identity': {
247
+ 'id': f'{kind}-generation-{item.order:06d}', 'kind': kind,
248
+ 'behavior': behavior, 'dimensions': dimensions, 'case_count': item.count,
249
+ },
250
+ }
251
+ state['jobs'].append(job)
252
+ token = _job.set(job)
253
+ try:
254
+ result = await worker(item)
255
+ if 'error' in result:
256
+ job['error'] = result['error']
257
+ job['failure'] = _failure(job, result)
258
+ return result
259
+ finally:
260
+ # Keep bounded diagnostics only, not entire provider bodies.
261
+ job.pop('response', None)
262
+ job.pop('schema', None)
263
+ _job.reset(token)
264
+ return await original(items, limit=limit, worker=observed)
265
+ return gather
266
+
267
+ def response_wrapper(original):
268
+ @wraps(original)
269
+ async def generate_structured(*args, **kwargs):
270
+ job = _job.get()
271
+ if job is not None:
272
+ job['schema'] = kwargs['json_schema']
273
+ job['max_tokens'] = getattr(kwargs.get('options'), 'max_tokens', None)
274
+ response = await original(*args, **kwargs)
275
+ if job is not None:
276
+ job['response'] = response
277
+ return response
278
+ return generate_structured
279
+
280
+ patch('run', stage_wrapper)
281
+ patch('run_test_set', orchestration_wrapper)
282
+ patch('_generate_records', records_wrapper)
283
+ patch('gather_limited', gather_wrapper)
284
+ patch('generate_structured', response_wrapper)