b3-prosaic-runtime 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. b3_prosaic_runtime-0.8.1.dist-info/METADATA +650 -0
  2. b3_prosaic_runtime-0.8.1.dist-info/RECORD +46 -0
  3. b3_prosaic_runtime-0.8.1.dist-info/WHEEL +5 -0
  4. b3_prosaic_runtime-0.8.1.dist-info/entry_points.txt +2 -0
  5. b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE +202 -0
  6. b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE-MIT +21 -0
  7. b3_prosaic_runtime-0.8.1.dist-info/licenses/NOTICE.md +16 -0
  8. b3_prosaic_runtime-0.8.1.dist-info/top_level.txt +1 -0
  9. prosaic_runtime/__init__.py +27 -0
  10. prosaic_runtime/accounting.py +256 -0
  11. prosaic_runtime/accounting_capture.py +275 -0
  12. prosaic_runtime/admission.py +57 -0
  13. prosaic_runtime/anthropic.py +205 -0
  14. prosaic_runtime/anthropic_stream.py +179 -0
  15. prosaic_runtime/anthropic_usage.py +96 -0
  16. prosaic_runtime/artifacts.py +56 -0
  17. prosaic_runtime/budgets.py +67 -0
  18. prosaic_runtime/cli.py +153 -0
  19. prosaic_runtime/cli_tools.py +313 -0
  20. prosaic_runtime/config.py +127 -0
  21. prosaic_runtime/conformance.py +314 -0
  22. prosaic_runtime/console.py +37 -0
  23. prosaic_runtime/diagnostics.py +132 -0
  24. prosaic_runtime/events.py +56 -0
  25. prosaic_runtime/execution.py +442 -0
  26. prosaic_runtime/http_bounds.py +66 -0
  27. prosaic_runtime/openai_compatible.py +2373 -0
  28. prosaic_runtime/openai_compatible_compaction.py +219 -0
  29. prosaic_runtime/openai_compatible_filters.py +147 -0
  30. prosaic_runtime/openai_compatible_progress.py +405 -0
  31. prosaic_runtime/openai_compatible_transcript.py +79 -0
  32. prosaic_runtime/operation_context.py +39 -0
  33. prosaic_runtime/policy.py +50 -0
  34. prosaic_runtime/provider_types.py +19 -0
  35. prosaic_runtime/runtime.py +562 -0
  36. prosaic_runtime/sandbox.py +179 -0
  37. prosaic_runtime/smoke_prose/pilot.md +7 -0
  38. prosaic_runtime/smoke_prose/subagents/native-summarizer.md +10 -0
  39. prosaic_runtime/smoke_prose/subagents/reviewer.md +22 -0
  40. prosaic_runtime/smoke_prose/subagents/summarizer.md +11 -0
  41. prosaic_runtime/structured_tools.py +193 -0
  42. prosaic_runtime/telemetry.py +128 -0
  43. prosaic_runtime/tool_effects.py +112 -0
  44. prosaic_runtime/tool_registry.py +36 -0
  45. prosaic_runtime/tools.py +261 -0
  46. prosaic_runtime/types.py +23 -0
@@ -0,0 +1,179 @@
1
+ """Strict native SSE assembly; tool blocks leave this module only when complete."""
2
+ import time
3
+
4
+ from .accounting_capture import strict_json
5
+ from .anthropic import parse_message
6
+ from .anthropic_usage import UsageSnapshots
7
+ from .events import check_cancelled, emit
8
+ from .openai_compatible import _http_status, _raw_response_headers
9
+ from .types import Result
10
+ from .http_bounds import LimitExceeded, set_response_timeout
11
+
12
+
13
+ class StreamState:
14
+ def __init__(self, on_text):
15
+ self.message = None
16
+ self.blocks = []
17
+ self.active = None
18
+ self.arguments = ''
19
+ self.delta_seen = False
20
+ self.stopped = False
21
+ self.on_text = on_text
22
+ self.usage = UsageSnapshots()
23
+
24
+ def push(self, event):
25
+ if not isinstance(event, dict) or not isinstance(event.get('type'), str):
26
+ raise ValueError('invalid stream event')
27
+ kind = event['type']
28
+ if self.stopped:
29
+ raise ValueError('event after message_stop')
30
+ if kind == 'ping':
31
+ return
32
+ if kind == 'message_start':
33
+ message = event.get('message')
34
+ if (self.message is not None or not isinstance(message, dict) or
35
+ message.get('type') != 'message' or message.get('role') != 'assistant' or
36
+ message.get('content') != [] or message.get('stop_reason') is not None or
37
+ not isinstance(message.get('id'), str) or not message['id'] or
38
+ not isinstance(message.get('model'), str) or not message['model']):
39
+ raise ValueError('invalid message_start')
40
+ self.message = {**message, 'content': self.blocks}
41
+ self.usage.update(message.get('usage'))
42
+ return
43
+ if self.message is None:
44
+ raise ValueError('missing message_start')
45
+ if kind == 'content_block_start':
46
+ index, block = event.get('index'), event.get('content_block')
47
+ if (self.delta_seen or self.active is not None or type(index) is not int or
48
+ index != len(self.blocks) or not isinstance(block, dict)):
49
+ raise ValueError('invalid block start')
50
+ if block.get('type') == 'text' and isinstance(block.get('text'), str):
51
+ if block['text']:
52
+ self.on_text(block['text'])
53
+ elif block.get('type') == 'tool_use' and type(block.get('input')) is dict:
54
+ if block['input']:
55
+ raise ValueError('stream tool input must start empty')
56
+ else:
57
+ raise ValueError('unsupported content block')
58
+ self.blocks.append(dict(block))
59
+ self.active, self.arguments = index, ''
60
+ return
61
+ if kind in {'content_block_delta', 'content_block_stop'}:
62
+ index = event.get('index')
63
+ if type(index) is not int or self.active is None or index != self.active or self.delta_seen:
64
+ raise ValueError('inactive block')
65
+ block = self.blocks[index]
66
+ if kind == 'content_block_stop':
67
+ if block['type'] == 'tool_use' and self.arguments:
68
+ value = strict_json(self.arguments)
69
+ if type(value) is not dict:
70
+ raise ValueError('tool input must be an object')
71
+ block['input'] = value
72
+ self.active, self.arguments = None, ''
73
+ return
74
+ delta = event.get('delta')
75
+ if not isinstance(delta, dict):
76
+ raise ValueError('invalid delta')
77
+ if (block['type'] == 'text' and delta.get('type') == 'text_delta' and
78
+ isinstance(delta.get('text'), str)):
79
+ block['text'] += delta['text']
80
+ if delta['text']:
81
+ self.on_text(delta['text'])
82
+ elif (block['type'] == 'tool_use' and delta.get('type') == 'input_json_delta' and
83
+ isinstance(delta.get('partial_json'), str)):
84
+ self.arguments += delta['partial_json']
85
+ else:
86
+ raise ValueError('unsupported block delta')
87
+ return
88
+ if kind == 'message_delta':
89
+ delta = event.get('delta')
90
+ if self.active is not None or not isinstance(delta, dict):
91
+ raise ValueError('invalid message delta')
92
+ if set(delta) - {'stop_reason', 'stop_sequence'}:
93
+ raise ValueError('unsupported message delta')
94
+ previous = self.message.get('stop_reason')
95
+ stop = delta.get('stop_reason')
96
+ if previous is not None and stop is not None and previous != stop:
97
+ raise ValueError('conflicting stop reason')
98
+ self.message.update(delta)
99
+ usage = event.get('usage')
100
+ if usage is not None:
101
+ if not isinstance(usage, dict) or not isinstance(self.message.get('usage', {}), dict):
102
+ raise ValueError('invalid usage')
103
+ self.usage.update(usage)
104
+ self.message['usage'] = self.usage.raw
105
+ self.delta_seen = True
106
+ return
107
+ if kind == 'message_stop':
108
+ if self.active is not None or not self.delta_seen or self.message.get('stop_reason') is None:
109
+ raise ValueError('incomplete message')
110
+ self.stopped = True
111
+ return
112
+ # Unknown consequential events cannot be assumed to preserve execution semantics.
113
+ raise ValueError('unsupported stream event')
114
+
115
+
116
+ def _emit_text(text):
117
+ emit('text_delta', text=text)
118
+
119
+
120
+ def read_anthropic_turn(response, deadline, *, on_text=_emit_text):
121
+ state = StreamState(on_text)
122
+ data, event_name = [], None
123
+ status, headers = _http_status(response), _raw_response_headers(response)
124
+
125
+ def flush():
126
+ nonlocal data, event_name
127
+ if data:
128
+ event = strict_json('\n'.join(data))
129
+ if event_name is not None and (not isinstance(event, dict) or event.get('type') != event_name):
130
+ raise ValueError('SSE event name mismatch')
131
+ state.push(event)
132
+ elif event_name is not None:
133
+ raise ValueError('SSE event missing data')
134
+ data, event_name = [], None
135
+
136
+ try:
137
+ while True:
138
+ check_cancelled()
139
+ remaining = deadline - time.monotonic()
140
+ if remaining <= 0:
141
+ raise TimeoutError('stream deadline')
142
+ # Reset the socket timeout to the remaining invocation budget before each read.
143
+ set_response_timeout(response, remaining)
144
+ raw = response.readline()
145
+ if not raw:
146
+ if data or event_name is not None:
147
+ raise ValueError('unterminated SSE event')
148
+ break
149
+ line = raw.decode('utf-8', errors='strict').rstrip('\r\n')
150
+ if not line:
151
+ flush()
152
+ elif line.startswith(':'):
153
+ continue
154
+ else:
155
+ field, separator, value = line.partition(':')
156
+ if value.startswith(' '):
157
+ value = value[1:]
158
+ if field == 'event':
159
+ if event_name is not None:
160
+ raise ValueError('duplicate SSE event name')
161
+ event_name = value
162
+ elif field == 'data':
163
+ data.append(value if separator else '')
164
+ if not state.stopped:
165
+ raise ValueError('missing message_stop')
166
+ turn = parse_message(state.message, streamed=True, http_status=status, headers=headers,
167
+ usage_conflict=state.usage.conflict)
168
+ if not isinstance(turn, Result):
169
+ turn.previewed = True
170
+ return turn
171
+ except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
172
+ # Size-limit exceptions are host guards, not malformed provider messages.
173
+ if isinstance(exc, LimitExceeded):
174
+ raise
175
+ message = state.message or {}
176
+ return Result(1, '', 'Malformed or unsupported Anthropic stream', metadata={
177
+ 'provider': 'anthropic', 'provider_error_code': 'malformed_response', 'streamed': True,
178
+ 'http_status': status, 'raw_response_metadata': {
179
+ k: message[k] for k in ('id', 'model', 'usage', 'stop_reason') if k in message}})
@@ -0,0 +1,96 @@
1
+ """Anthropic token evidence, with cache-write costs explicitly unsupported."""
2
+ from .accounting import _quantity, normalize_usage
3
+
4
+ TOKEN_FIELDS = ('input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens')
5
+
6
+
7
+ def _zero_details(value):
8
+ if value is None:
9
+ return True
10
+ if type(value) is dict:
11
+ return all(_zero_details(v) for v in value.values())
12
+ return type(value) is int and value == 0
13
+
14
+
15
+ def normalize_anthropic_usage(raw):
16
+ result = normalize_usage(None)
17
+ if raw is None:
18
+ return result
19
+ if type(raw) is not dict:
20
+ return {**result, 'status': 'untrusted'}
21
+ for key in TOKEN_FIELDS:
22
+ if key in raw and not _quantity(raw[key]):
23
+ return {**result, 'status': 'untrusted'}
24
+ result['output_tokens'] = raw.get('output_tokens')
25
+ result['cached_input_tokens'] = raw.get('cache_read_input_tokens')
26
+ if 'input_tokens' in raw:
27
+ inp = sum(raw.get(k, 0) for k in ('input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'))
28
+ if not _quantity(inp):
29
+ return {**result, 'status': 'untrusted'}
30
+ result['input_tokens'] = inp
31
+ if result['input_tokens'] is not None and result['output_tokens'] is not None:
32
+ total = result['input_tokens'] + result['output_tokens']
33
+ if not _quantity(total):
34
+ return {**result, 'status': 'untrusted'}
35
+ result.update(total_tokens=total, status='reported')
36
+ # Cache-write pricing cannot be represented by the existing rate-card contract.
37
+ unsupported = bool(raw.get('cache_creation_input_tokens', 0))
38
+ if raw.get('service_tier') not in (None, 'standard') or raw.get('inference_geo') not in (None, 'global'):
39
+ unsupported = True
40
+ for key, value in raw.items():
41
+ if key not in TOKEN_FIELDS and key not in {'service_tier', 'inference_geo'} and not _zero_details(value):
42
+ unsupported = True
43
+ if unsupported:
44
+ result['status'] = 'unsupported'
45
+ return result
46
+
47
+
48
+ class UsageSnapshots:
49
+ """Merge cumulative event snapshots; decreases invalidate accounting evidence."""
50
+ def __init__(self):
51
+ self.raw = None
52
+ self.conflict = False
53
+
54
+ def update(self, value):
55
+ if value is None:
56
+ return
57
+ if type(value) is not dict:
58
+ self.conflict = True
59
+ return
60
+ previous = self.raw or {}
61
+ if _snapshot_conflict(previous, value):
62
+ self.conflict = True
63
+ for key in TOKEN_FIELDS:
64
+ if key in value:
65
+ new, old = value[key], previous.get(key)
66
+ if not _quantity(new) or (old is not None and (not _quantity(old) or new < old)):
67
+ self.conflict = True
68
+ self.raw = _merge_snapshots(previous, value)
69
+
70
+ def normalized(self):
71
+ usage = normalize_anthropic_usage(self.raw)
72
+ return {**usage, 'status': 'untrusted'} if self.conflict else usage
73
+
74
+
75
+ def _snapshot_conflict(previous, current):
76
+ for key in previous.keys() & current.keys():
77
+ old, new = previous[key], current[key]
78
+ if type(old) is dict and type(new) is dict:
79
+ if _snapshot_conflict(old, new):
80
+ return True
81
+ elif _quantity(old) and _quantity(new):
82
+ if new < old:
83
+ return True
84
+ elif old != new:
85
+ return True
86
+ return False
87
+
88
+
89
+ def _merge_snapshots(previous, current):
90
+ merged = dict(previous)
91
+ for key, value in current.items():
92
+ if type(value) is dict and type(previous.get(key)) is dict:
93
+ merged[key] = _merge_snapshots(previous[key], value)
94
+ else:
95
+ merged[key] = value
96
+ return merged
@@ -0,0 +1,56 @@
1
+ """Use Prosaic's inspection contract as the sole agent-definition parser."""
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from collections.abc import Mapping
5
+ import hashlib
6
+ import json
7
+ import subprocess
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class ProsaicArtifact:
12
+ id: str
13
+ type: str
14
+ frontmatter: dict
15
+ body: str
16
+ resources: tuple[dict, ...] = ()
17
+
18
+ @classmethod
19
+ def from_inspection(cls, data: Mapping):
20
+ if data.get("type") not in {"subagent", "command"}:
21
+ raise ValueError("execution requires a Prosaic subagent or command artifact")
22
+ if not isinstance(data.get("id"), str) or not data["id"]:
23
+ raise ValueError("inspection artifact id is required")
24
+ if not isinstance(data.get("frontmatter"), dict) or not isinstance(data.get("body"), str):
25
+ raise ValueError("invalid Prosaic inspection output")
26
+ resources = data.get("resources", [])
27
+ if not isinstance(resources, list):
28
+ raise ValueError("resources must be a list")
29
+ for item in resources:
30
+ if not isinstance(item, dict) or not isinstance(item.get("relPath"), str) or not isinstance(item.get("content"), str):
31
+ raise ValueError("invalid bundled resource")
32
+ path = Path(item["relPath"])
33
+ if path.is_absolute() or ".." in path.parts:
34
+ raise ValueError("resource path escapes bundle")
35
+ return cls(data["id"], data["type"], dict(data["frontmatter"]), data["body"], tuple(resources))
36
+
37
+ def render(self, arguments: str = "") -> str:
38
+ body = self.body.replace("{{args}}", arguments) if "{{args}}" in self.body else self.body + ("\n\n## Arguments\n" + arguments if arguments else "")
39
+ for resource in self.resources:
40
+ body += f"\n\n## Bundled resource: {resource['relPath']}\n{resource['content']}"
41
+ return body
42
+
43
+ @property
44
+ def digest(self):
45
+ raw = json.dumps({"id": self.id, "type": self.type, "frontmatter": self.frontmatter, "body": self.body, "resources": self.resources}, sort_keys=True).encode()
46
+ return hashlib.sha256(raw).hexdigest()
47
+
48
+
49
+ def inspect_artifact(artifact_id: str, source: Path, *, executable="prosaic", timeout_s=30) -> ProsaicArtifact:
50
+ result = subprocess.run(
51
+ [executable, "inspect", artifact_id, "--source", str(source.resolve())],
52
+ cwd=source.resolve().parent, capture_output=True, text=True, timeout=timeout_s, check=False,
53
+ )
54
+ if result.returncode:
55
+ raise ValueError(f"Prosaic inspection failed: {result.stderr.strip()}")
56
+ return ProsaicArtifact.from_inspection(json.loads(result.stdout))
@@ -0,0 +1,67 @@
1
+ """Invocation-local allowances; counts are dispatch attempts, not estimates."""
2
+
3
+
4
+ class InvocationBudgetExceeded(Exception):
5
+ REASONS = frozenset({'provider_request_limit', 'tool_call_limit', 'token_limit', 'usage_unknown'})
6
+
7
+ def __init__(self, reason):
8
+ if reason not in self.REASONS:
9
+ raise ValueError('unknown invocation budget reason')
10
+ self.reason = reason
11
+ super().__init__(reason)
12
+
13
+
14
+ class InvocationBudget:
15
+ def __init__(self, policy):
16
+ self.policy = policy
17
+ self.provider_requests = self.tool_calls = self.reported_tokens = 0
18
+ self.usage_complete = True
19
+
20
+ @property
21
+ def enabled(self):
22
+ return any(value is not None for value in (self.policy.max_provider_requests,
23
+ self.policy.max_tool_calls, self.policy.max_reported_tokens))
24
+
25
+ def metadata(self):
26
+ return {'provider_requests': self.provider_requests, 'tool_calls': self.tool_calls,
27
+ 'reported_tokens': self.reported_tokens, 'usage_complete': self.usage_complete}
28
+
29
+ def _check_reported_allowance(self):
30
+ self.validate_terminal_usage()
31
+ if (self.policy.max_reported_tokens is not None and
32
+ self.reported_tokens >= self.policy.max_reported_tokens):
33
+ raise InvocationBudgetExceeded('token_limit')
34
+
35
+ def _reserve(self, name, cap, reason):
36
+ if cap is not None and getattr(self, name) >= cap:
37
+ raise InvocationBudgetExceeded(reason)
38
+ setattr(self, name, getattr(self, name) + 1)
39
+
40
+ def _check_provider(self):
41
+ self._check_reported_allowance()
42
+ cap = self.policy.max_provider_requests
43
+ if cap is not None and self.provider_requests >= cap:
44
+ raise InvocationBudgetExceeded('provider_request_limit')
45
+
46
+ def before_provider(self):
47
+ self._check_provider()
48
+ self._reserve('provider_requests', self.policy.max_provider_requests, 'provider_request_limit')
49
+
50
+ def before_tool(self):
51
+ self._check_reported_allowance()
52
+ self._reserve('tool_calls', self.policy.max_tool_calls, 'tool_call_limit')
53
+
54
+ def record_usage(self, usage):
55
+ if type(usage) is not int or usage < 0:
56
+ self.usage_complete = False
57
+ else:
58
+ self.reported_tokens += usage
59
+ self.validate_terminal_usage()
60
+
61
+ def validate_terminal_usage(self):
62
+ cap = self.policy.max_reported_tokens
63
+ if cap is not None:
64
+ if not self.usage_complete:
65
+ raise InvocationBudgetExceeded('usage_unknown')
66
+ if self.reported_tokens > cap:
67
+ raise InvocationBudgetExceeded('token_limit')
prosaic_runtime/cli.py ADDED
@@ -0,0 +1,153 @@
1
+ """Small CLI; stdout is reserved for the requested machine output."""
2
+ import argparse
3
+ from dataclasses import asdict
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from .runtime import ProsaicRuntime
9
+ from .policy import RunPolicy
10
+ from .config import RuntimeConfig
11
+ from .console import Progress
12
+ from .diagnostics import doctor, smoke, failure_hint, conformance
13
+ from .accounting import ExecutionContext, RateCard
14
+
15
+
16
+ def main(argv=None):
17
+ argv = list(sys.argv[1:] if argv is None else argv)
18
+ mode = argv.pop(0) if argv and argv[0] in {"doctor", "smoke", "preflight", "conformance"} else "run"
19
+ parser = argparse.ArgumentParser(prog="prosaic-runtime")
20
+ if mode == "run":
21
+ parser.add_argument("artifact", help="Prosaic artifact identifier (also: doctor or smoke subcommands)")
22
+ if mode == 'preflight':
23
+ target = parser.add_mutually_exclusive_group(required=True)
24
+ target.add_argument('--agent', help='Prosaic artifact identifier to check without inference')
25
+ target.add_argument('--all-tools', action='store_true', help='check every registered/configured tool')
26
+ if mode == 'run':
27
+ parser.add_argument('--initial-tool', help='require this granted tool as the first native call')
28
+ for name in ('application_id', 'tenant_id', 'billing_account_id', 'actor_id', 'project_id', 'request_id', 'run_id', 'invocation_id', 'parent_invocation_id'):
29
+ parser.add_argument('--' + name.replace('_', '-'), help='trusted execution attribution; omitted IDs use defaults')
30
+ parser.add_argument('--accounting-dsn-env', help='environment variable holding the accounting PostgreSQL DSN; requires separately installed adapter')
31
+ parser.add_argument('--accounting-namespace', help='persistent deployment namespace')
32
+ parser.add_argument('--accounting-environment', help='isolated environment, e.g. production or sandbox')
33
+ parser.add_argument('--accounting-rate-card', help='explicit JSON rate card for internal estimates')
34
+ parser.add_argument('--accounting-provider', choices=('openai-compatible', 'anthropic'),
35
+ help='accounting provider identity; defaults to openai-compatible and must match the selected endpoint')
36
+ parser.add_argument("--source", default=".prosaic")
37
+ parser.add_argument("--config", help="YAML configuration (default: prosaic-runtime.yaml, then prosaic-runtime.yml)")
38
+ parser.add_argument("--arguments", default="")
39
+ parser.add_argument("--cwd", default=".")
40
+ parser.add_argument("--allow-tool", action="append", default=[])
41
+ parser.add_argument("--read-root", action="append", default=[])
42
+ parser.add_argument("--write-path", action="append", default=[])
43
+ parser.add_argument("--forbid-root", action="append", default=[])
44
+ parser.add_argument("--timeout", type=float, help="override YAML limits.timeout_s")
45
+ parser.add_argument("--max-tool-rounds", type=int, help="override YAML limits.max_tool_rounds")
46
+ output = parser.add_mutually_exclusive_group()
47
+ output.add_argument("--events", action="store_true", help="emit JSONL events followed by result")
48
+ output.add_argument("--output", choices=("json", "text"), default="json")
49
+ parser.add_argument("--quiet", action="store_true", help="suppress stderr progress (errors remain visible)")
50
+ if mode != "run":
51
+ parser.add_argument("--profile", help="endpoint profile; defaults to default_profile")
52
+ if mode == "doctor":
53
+ parser.add_argument("--inference", action="store_true", help="opt in to one model request (may incur charges)")
54
+ if mode == "smoke":
55
+ parser.add_argument("--live", action="store_true", required=True, help="explicitly authorize live model calls")
56
+ if mode == 'conformance':
57
+ parser.add_argument('--live', action='store_true', help='explicitly authorize bounded provider calls')
58
+ args = parser.parse_args(argv)
59
+ def emit(event):
60
+ print(json.dumps(event), flush=True)
61
+ try:
62
+ config = RuntimeConfig.load(args.config)
63
+ policy = RunPolicy(frozenset(args.allow_tool), tuple(args.read_root), tuple(args.write_path),
64
+ tuple(args.forbid_root),
65
+ args.timeout if args.timeout is not None else config.limits.timeout_s,
66
+ args.max_tool_rounds if args.max_tool_rounds is not None else config.limits.max_tool_rounds,
67
+ initial_tool=getattr(args, 'initial_tool', None))
68
+ with Progress(args.quiet or args.events) as progress:
69
+ sink = emit if args.events else progress
70
+ if mode == 'preflight':
71
+ runtime = ProsaicRuntime(config, source=args.source)
72
+ report = runtime.preflight(args.agent, all_tools=args.all_tools, cwd=args.cwd, policy=policy)
73
+ if args.output == 'text':
74
+ print(f"preflight: {'passed' if report['ok'] else 'failed'}")
75
+ for name, check in report['checks'].items():
76
+ print(f"{name}: {check['status']} — {check['message']}")
77
+ else:
78
+ emit(report)
79
+ return 0 if report['ok'] else 1
80
+ if mode != "run":
81
+ profile = args.profile or config.default_profile
82
+ if profile not in config.profiles:
83
+ raise ValueError(f"Unknown endpoint profile: {profile}")
84
+ if mode == 'conformance':
85
+ suite_policy = RunPolicy(timeout_s=args.timeout if args.timeout is not None else 60,
86
+ max_tool_rounds=args.max_tool_rounds if args.max_tool_rounds is not None else 2,
87
+ max_input_bytes=65536, max_provider_requests=12, max_tool_calls=4,
88
+ max_reported_tokens=32768)
89
+ report = conformance(config, profile, live=args.live, policy=suite_policy,
90
+ observer=emit if args.events else None)
91
+ if args.output == 'text':
92
+ print('conformance: ' + report['qualification'])
93
+ for case in report['cases']:
94
+ print(f"{case['id']}: {case['state']} — {case['reason']}")
95
+ else:
96
+ emit(report)
97
+ return 0 if not args.live or report['qualification'] == 'qualified' else 1
98
+ if mode == "doctor":
99
+ report = doctor(config, profile, inference=args.inference, timeout_s=policy.timeout_s, on_event=sink)
100
+ else:
101
+ report = smoke(config, profile, timeout_s=policy.timeout_s,
102
+ max_tool_rounds=policy.max_tool_rounds, on_event=sink)
103
+ if args.output == "text":
104
+ print(f"{mode}: {'passed' if report['ok'] else 'failed'}")
105
+ for name, check in report.get("checks", report.get("tests", {})).items():
106
+ print(f"{name}: {check['status']}" + (f" — {check['message']}" if check.get("message") else ""))
107
+ else:
108
+ emit(report)
109
+ return 0 if report["ok"] else 1
110
+ recorder = None
111
+ if args.accounting_dsn_env:
112
+ if not args.accounting_namespace or not args.accounting_environment:
113
+ raise ValueError('accounting requires explicit namespace and environment')
114
+ dsn = os.environ.get(args.accounting_dsn_env)
115
+ if not dsn:
116
+ raise ValueError('accounting DSN environment variable is not set')
117
+ try:
118
+ from prosaic_runtime_postgres import PostgresRecorder
119
+ except ImportError as exc:
120
+ raise ValueError('install b3-prosaic-runtime-postgres to enable durable accounting') from exc
121
+ rate_card = None
122
+ if args.accounting_rate_card:
123
+ with open(args.accounting_rate_card, encoding='utf-8') as handle:
124
+ rate_card = RateCard(**json.load(handle))
125
+ recorder = PostgresRecorder(dsn, namespace=args.accounting_namespace,
126
+ environment=args.accounting_environment, rate_card=rate_card,
127
+ provider_id=args.accounting_provider or 'openai-compatible')
128
+ elif args.accounting_namespace or args.accounting_environment or args.accounting_rate_card or args.accounting_provider:
129
+ raise ValueError('accounting options require --accounting-dsn-env')
130
+ names = ('application_id', 'tenant_id', 'billing_account_id', 'actor_id', 'project_id', 'request_id', 'run_id', 'invocation_id', 'parent_invocation_id')
131
+ values = {name: getattr(args, name) for name in names if getattr(args, name) is not None}
132
+ runtime = ProsaicRuntime(config, source=args.source, accounting=recorder)
133
+ result = runtime.run(args.artifact, args.arguments, cwd=args.cwd, policy=policy, on_event=sink,
134
+ **({'context': ExecutionContext(**values)} if values else {}))
135
+ if args.output == "text":
136
+ if result.stdout:
137
+ print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
138
+ if result.exit_code:
139
+ print(failure_hint(result), file=sys.stderr)
140
+ return 0 if result.exit_code == 0 else 1
141
+ if result.exit_code:
142
+ print(failure_hint(result), file=sys.stderr)
143
+ emit({"event": "result", **asdict(result)})
144
+ return 0 if result.exit_code == 0 else 1
145
+ except KeyboardInterrupt:
146
+ return 130
147
+ except (ValueError, TypeError, KeyError, OSError, subprocess.TimeoutExpired) as exc:
148
+ print(json.dumps({"error": str(exc)}), file=sys.stderr)
149
+ return 2
150
+
151
+
152
+ if __name__ == "__main__":
153
+ raise SystemExit(main())