mostlyright-data 0.9.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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,2743 @@
|
|
|
1
|
+
"""Provider-neutral, deterministic runtime for bounded data-agent proposals.
|
|
2
|
+
|
|
3
|
+
The runtime deliberately has no provider-package dependency. Provider adapters receive only a
|
|
4
|
+
strict, versioned request and a closed tool policy. They return untrusted structured data plus
|
|
5
|
+
integer accounting; this module validates both before returning anything to a caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from copy import deepcopy
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, date, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
AGENT_REQUEST_VERSION = "agent-request.v1"
|
|
24
|
+
AGENT_RESPONSE_VERSION = "agent-response.v2"
|
|
25
|
+
AGENT_EVIDENCE_VERSION = "agent-call-evidence.v1"
|
|
26
|
+
AGENT_RUNTIME_VERSION = "agent-runtime.v1"
|
|
27
|
+
REPLAY_CATALOG_VERSION = "agent-replay-catalog.v1"
|
|
28
|
+
TOOL_POLICY_VERSION = "agent-tools.v1"
|
|
29
|
+
SEMANTIC_REVIEW_INPUT_VERSION = "semantic-reviewer-input.v2"
|
|
30
|
+
SEMANTIC_REVIEW_OUTPUT_VERSION = "semantic-reviewer-output.v2"
|
|
31
|
+
MAX_ACCOUNTING_INTEGER = (1 << 63) - 1
|
|
32
|
+
|
|
33
|
+
# One role per (input, output) schema pair, except semantic_reviewer, which accepts two live
|
|
34
|
+
# input schemas rather than being split into two roles; the ROLE_PAYLOAD_SCHEMAS comment below
|
|
35
|
+
# is the detail.
|
|
36
|
+
AGENT_ROLES = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"question_framer",
|
|
39
|
+
"feasibility_analyst",
|
|
40
|
+
"data_scout",
|
|
41
|
+
"source_auditor",
|
|
42
|
+
"plan_proposer",
|
|
43
|
+
"semantic_reviewer",
|
|
44
|
+
"repair_proposer",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
# Every role uses an (input, output) schema pair derived from its own name. The semantic
|
|
48
|
+
# reviewer is the one exception: its output schema is v2, and its input accepts two live
|
|
49
|
+
# schemas. The v1 entry below is the identifier-only payload checked by _validate_role_input;
|
|
50
|
+
# SEMANTIC_REVIEW_INPUT_VERSION names the v2 payload, which additionally requires the full
|
|
51
|
+
# candidate evidence bundle and its digests. _validate_role_payload dispatches on whichever
|
|
52
|
+
# input version a request declares, so this table names only the v1 input.
|
|
53
|
+
ROLE_PAYLOAD_SCHEMAS: Mapping[str, tuple[str, str]] = {
|
|
54
|
+
role: (
|
|
55
|
+
f"{role.replace('_', '-')}-input.v1",
|
|
56
|
+
f"{role.replace('_', '-')}-output.v1",
|
|
57
|
+
)
|
|
58
|
+
for role in sorted(AGENT_ROLES)
|
|
59
|
+
}
|
|
60
|
+
ROLE_PAYLOAD_SCHEMAS = {
|
|
61
|
+
**ROLE_PAYLOAD_SCHEMAS,
|
|
62
|
+
"data_scout": (
|
|
63
|
+
"data-scout-input.v1",
|
|
64
|
+
"data-scout-output.v2",
|
|
65
|
+
),
|
|
66
|
+
"semantic_reviewer": (
|
|
67
|
+
"semantic-reviewer-input.v1",
|
|
68
|
+
SEMANTIC_REVIEW_OUTPUT_VERSION,
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
_OUTCOMES = frozenset(
|
|
72
|
+
{
|
|
73
|
+
"succeeded",
|
|
74
|
+
"budget_exhausted_before_call",
|
|
75
|
+
"budget_exhausted_after_call",
|
|
76
|
+
"provider_exception",
|
|
77
|
+
"malformed_response",
|
|
78
|
+
"timed_out",
|
|
79
|
+
"cancelled",
|
|
80
|
+
"tool_policy_denied",
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$")
|
|
84
|
+
_SECRET_HANDLE = re.compile(r"^shr_[A-Za-z0-9_-]{24,120}$")
|
|
85
|
+
_SCOPE_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
86
|
+
_SECRET_PURPOSES = frozenset({"provider_auth", "source_acquisition", "web_extraction"})
|
|
87
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
88
|
+
_WIRE_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
89
|
+
_UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
|
90
|
+
_FINDING_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,127}$")
|
|
91
|
+
_ARTIFACT_KIND = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
|
|
92
|
+
_SEMANTIC_VERSION = re.compile(
|
|
93
|
+
r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)"
|
|
94
|
+
r"(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$"
|
|
95
|
+
)
|
|
96
|
+
_CANONICAL_UTC = re.compile(
|
|
97
|
+
r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])"
|
|
98
|
+
r"T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]{1,6})?Z$"
|
|
99
|
+
)
|
|
100
|
+
_CANONICAL_DATE = re.compile(r"^[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$")
|
|
101
|
+
_ACCOUNTING_STATUSES = frozenset({"reported", "unavailable_reserved", "not_applicable"})
|
|
102
|
+
_PRE_DISPATCH_OUTCOMES = frozenset({"budget_exhausted_before_call", "tool_policy_denied"})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class AgentRuntimeError(RuntimeError):
|
|
106
|
+
"""Base class for typed runtime failures."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RuntimeContractError(AgentRuntimeError, ValueError):
|
|
110
|
+
"""A request, response, fixture, or provider accounting value is invalid."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ToolPolicyError(RuntimeContractError):
|
|
114
|
+
"""An agent request asks for a capability outside the closed policy."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ReplayError(AgentRuntimeError):
|
|
118
|
+
"""Base class for replay-catalog failures."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ReplayMissError(ReplayError):
|
|
122
|
+
"""No replay entry has the exact canonical request identity."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ReplayAmbiguityError(ReplayError):
|
|
126
|
+
"""More than one replay entry claims the same canonical request identity."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ReplayTamperError(ReplayError):
|
|
130
|
+
"""A replay member no longer matches its recorded hash."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class ReplayVersionError(ReplayError):
|
|
134
|
+
"""A replay catalog or member uses an unsupported schema/policy version."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ProviderTimeoutError(TimeoutError):
|
|
138
|
+
"""Provider adapter reports a deterministic timeout."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class TimingEvidence:
|
|
143
|
+
"""Integer-only timing metadata supplied by an injected monotonic clock."""
|
|
144
|
+
|
|
145
|
+
started_micros: int
|
|
146
|
+
finished_micros: int
|
|
147
|
+
elapsed_micros: int
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
_nonnegative_int(self.started_micros, "timing.started_micros")
|
|
151
|
+
_nonnegative_int(self.finished_micros, "timing.finished_micros")
|
|
152
|
+
_nonnegative_int(self.elapsed_micros, "timing.elapsed_micros")
|
|
153
|
+
if self.finished_micros < self.started_micros:
|
|
154
|
+
raise RuntimeContractError("timing.finished_micros cannot precede started_micros")
|
|
155
|
+
if self.elapsed_micros != self.finished_micros - self.started_micros:
|
|
156
|
+
raise RuntimeContractError("timing.elapsed_micros must equal finished minus started")
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, int]:
|
|
159
|
+
return {
|
|
160
|
+
"started_micros": self.started_micros,
|
|
161
|
+
"finished_micros": self.finished_micros,
|
|
162
|
+
"elapsed_micros": self.elapsed_micros,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class Usage:
|
|
168
|
+
"""Validated, integer-safe provider usage."""
|
|
169
|
+
|
|
170
|
+
input_tokens: int
|
|
171
|
+
output_tokens: int
|
|
172
|
+
cost_micros: int
|
|
173
|
+
cached_input_tokens: int = 0
|
|
174
|
+
reasoning_tokens: int = 0
|
|
175
|
+
accounting_status: str = "reported"
|
|
176
|
+
|
|
177
|
+
def __post_init__(self) -> None:
|
|
178
|
+
_accounting_int(self.input_tokens, "usage.input_tokens")
|
|
179
|
+
_accounting_int(self.output_tokens, "usage.output_tokens")
|
|
180
|
+
_accounting_int(self.cost_micros, "usage.cost_micros")
|
|
181
|
+
_accounting_int(self.cached_input_tokens, "usage.cached_input_tokens")
|
|
182
|
+
_accounting_int(self.reasoning_tokens, "usage.reasoning_tokens")
|
|
183
|
+
if self.accounting_status not in _ACCOUNTING_STATUSES:
|
|
184
|
+
raise RuntimeContractError("usage.accounting_status is unsupported")
|
|
185
|
+
if self.cached_input_tokens > self.input_tokens:
|
|
186
|
+
raise RuntimeContractError("usage.cached_input_tokens cannot exceed input_tokens")
|
|
187
|
+
if self.reasoning_tokens > self.output_tokens:
|
|
188
|
+
raise RuntimeContractError("usage.reasoning_tokens cannot exceed output_tokens")
|
|
189
|
+
_checked_add(self.input_tokens, self.output_tokens, "usage.total_tokens")
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def total_tokens(self) -> int:
|
|
193
|
+
return self.input_tokens + self.output_tokens
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def cost_usd_micros(self) -> int:
|
|
197
|
+
return self.cost_micros
|
|
198
|
+
|
|
199
|
+
def to_dict(self) -> dict[str, int]:
|
|
200
|
+
return {
|
|
201
|
+
"input_tokens": self.input_tokens,
|
|
202
|
+
"output_tokens": self.output_tokens,
|
|
203
|
+
"total_tokens": self.total_tokens,
|
|
204
|
+
"cost_usd_micros": self.cost_usd_micros,
|
|
205
|
+
"cached_input_tokens": self.cached_input_tokens,
|
|
206
|
+
"reasoning_tokens": self.reasoning_tokens,
|
|
207
|
+
"accounting_status": self.accounting_status,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass(frozen=True)
|
|
212
|
+
class SecretReference:
|
|
213
|
+
"""A server-issued, scoped handle; it can never contain a provider storage coordinate."""
|
|
214
|
+
|
|
215
|
+
name: str
|
|
216
|
+
handle_id: str
|
|
217
|
+
workspace_id: str
|
|
218
|
+
connector_id: str
|
|
219
|
+
attempt_id: str
|
|
220
|
+
purpose: str
|
|
221
|
+
|
|
222
|
+
def __post_init__(self) -> None:
|
|
223
|
+
_identifier(self.name, "secret_reference.name")
|
|
224
|
+
if not isinstance(self.handle_id, str) or not _SECRET_HANDLE.fullmatch(self.handle_id):
|
|
225
|
+
raise RuntimeContractError("secret_reference.handle_id must be a server-issued handle")
|
|
226
|
+
for field_name in ("workspace_id", "connector_id", "attempt_id"):
|
|
227
|
+
value = getattr(self, field_name)
|
|
228
|
+
if not isinstance(value, str) or not _SCOPE_IDENTIFIER.fullmatch(value):
|
|
229
|
+
raise RuntimeContractError(
|
|
230
|
+
f"secret_reference.{field_name} must be a canonical scoped identifier"
|
|
231
|
+
)
|
|
232
|
+
if self.purpose not in _SECRET_PURPOSES:
|
|
233
|
+
raise RuntimeContractError("secret_reference.purpose is unsupported")
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict[str, str]:
|
|
236
|
+
return {
|
|
237
|
+
"name": self.name,
|
|
238
|
+
"handle_id": self.handle_id,
|
|
239
|
+
"workspace_id": self.workspace_id,
|
|
240
|
+
"connector_id": self.connector_id,
|
|
241
|
+
"attempt_id": self.attempt_id,
|
|
242
|
+
"purpose": self.purpose,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_value(cls, value: Any, path: str) -> SecretReference:
|
|
247
|
+
data = _object(value, path)
|
|
248
|
+
_exact_fields(
|
|
249
|
+
data,
|
|
250
|
+
{
|
|
251
|
+
"name",
|
|
252
|
+
"handle_id",
|
|
253
|
+
"workspace_id",
|
|
254
|
+
"connector_id",
|
|
255
|
+
"attempt_id",
|
|
256
|
+
"purpose",
|
|
257
|
+
},
|
|
258
|
+
path,
|
|
259
|
+
)
|
|
260
|
+
return cls(
|
|
261
|
+
name=_required_string(data["name"], f"{path}.name", maximum=64),
|
|
262
|
+
handle_id=_required_string(data["handle_id"], f"{path}.handle_id", maximum=128),
|
|
263
|
+
workspace_id=_required_string(
|
|
264
|
+
data["workspace_id"], f"{path}.workspace_id", maximum=128
|
|
265
|
+
),
|
|
266
|
+
connector_id=_required_string(
|
|
267
|
+
data["connector_id"], f"{path}.connector_id", maximum=128
|
|
268
|
+
),
|
|
269
|
+
attempt_id=_required_string(data["attempt_id"], f"{path}.attempt_id", maximum=128),
|
|
270
|
+
purpose=_required_string(data["purpose"], f"{path}.purpose", maximum=64),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class ToolInvocation:
|
|
276
|
+
"""A bounded invocation of a capability registered by the trusted coordinator."""
|
|
277
|
+
|
|
278
|
+
name: str
|
|
279
|
+
arguments: Mapping[str, Any]
|
|
280
|
+
|
|
281
|
+
def __post_init__(self) -> None:
|
|
282
|
+
_identifier(self.name, "tool.name")
|
|
283
|
+
normalized = _strict_json_object(self.arguments, "tool.arguments")
|
|
284
|
+
object.__setattr__(self, "arguments", normalized)
|
|
285
|
+
|
|
286
|
+
def to_dict(self) -> dict[str, Any]:
|
|
287
|
+
return {"name": self.name, "arguments": deepcopy(dict(self.arguments))}
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def from_value(cls, value: Any, path: str) -> ToolInvocation:
|
|
291
|
+
data = _object(value, path)
|
|
292
|
+
_exact_fields(data, {"name", "arguments"}, path)
|
|
293
|
+
return cls(
|
|
294
|
+
name=_required_string(data["name"], f"{path}.name", maximum=64),
|
|
295
|
+
arguments=_object(data["arguments"], f"{path}.arguments"),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclass(frozen=True)
|
|
300
|
+
class ToolCallEvidence:
|
|
301
|
+
"""Exact digest binding for one sanctioned tool invocation."""
|
|
302
|
+
|
|
303
|
+
sequence: int
|
|
304
|
+
name: str
|
|
305
|
+
arguments_sha256: str
|
|
306
|
+
invocation_sha256: str
|
|
307
|
+
|
|
308
|
+
def __post_init__(self) -> None:
|
|
309
|
+
_positive_int(self.sequence, "tool_evidence.sequence")
|
|
310
|
+
_identifier(self.name, "tool_evidence.name")
|
|
311
|
+
_digest(self.arguments_sha256, "tool_evidence.arguments_sha256")
|
|
312
|
+
_digest(self.invocation_sha256, "tool_evidence.invocation_sha256")
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def from_invocation(cls, sequence: int, invocation: ToolInvocation) -> ToolCallEvidence:
|
|
316
|
+
arguments_sha256 = _sha256(_canonical_json_bytes(invocation.arguments))
|
|
317
|
+
invocation_sha256 = _sha256(_canonical_json_bytes(invocation.to_dict()))
|
|
318
|
+
return cls(
|
|
319
|
+
sequence=sequence,
|
|
320
|
+
name=invocation.name,
|
|
321
|
+
arguments_sha256=arguments_sha256,
|
|
322
|
+
invocation_sha256=invocation_sha256,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def to_dict(self) -> dict[str, Any]:
|
|
326
|
+
return {
|
|
327
|
+
"sequence": self.sequence,
|
|
328
|
+
"name": self.name,
|
|
329
|
+
"arguments_sha256": self.arguments_sha256,
|
|
330
|
+
"invocation_sha256": self.invocation_sha256,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass(frozen=True)
|
|
335
|
+
class ToolPolicy:
|
|
336
|
+
"""Closed V1 policy: registered search and result-reference browsing only."""
|
|
337
|
+
|
|
338
|
+
version: str = TOOL_POLICY_VERSION
|
|
339
|
+
max_search_query_chars: int = 500
|
|
340
|
+
max_search_results: int = 20
|
|
341
|
+
max_browse_chars: int = 100_000
|
|
342
|
+
|
|
343
|
+
def __post_init__(self) -> None:
|
|
344
|
+
if self.version != TOOL_POLICY_VERSION:
|
|
345
|
+
raise RuntimeContractError(f"tool policy version must be {TOOL_POLICY_VERSION!r}")
|
|
346
|
+
_positive_int(self.max_search_query_chars, "max_search_query_chars")
|
|
347
|
+
_positive_int(self.max_search_results, "max_search_results")
|
|
348
|
+
_positive_int(self.max_browse_chars, "max_browse_chars")
|
|
349
|
+
|
|
350
|
+
def validate(self, invocation: ToolInvocation, *, path: str = "tool") -> None:
|
|
351
|
+
try:
|
|
352
|
+
if invocation.name == "search":
|
|
353
|
+
self._validate_search(invocation.arguments, path)
|
|
354
|
+
return
|
|
355
|
+
if invocation.name == "browse":
|
|
356
|
+
self._validate_browse(invocation.arguments, path)
|
|
357
|
+
return
|
|
358
|
+
raise ToolPolicyError(
|
|
359
|
+
f"{path}.name is not sanctioned; only registered search and browse are available"
|
|
360
|
+
)
|
|
361
|
+
except ToolPolicyError:
|
|
362
|
+
raise
|
|
363
|
+
except RuntimeContractError as exc:
|
|
364
|
+
raise ToolPolicyError(str(exc)) from None
|
|
365
|
+
|
|
366
|
+
def _validate_search(self, arguments: Mapping[str, Any], path: str) -> None:
|
|
367
|
+
_exact_fields(arguments, {"query", "limit"}, f"{path}.arguments")
|
|
368
|
+
_required_string(
|
|
369
|
+
arguments["query"],
|
|
370
|
+
f"{path}.arguments.query",
|
|
371
|
+
maximum=self.max_search_query_chars,
|
|
372
|
+
)
|
|
373
|
+
limit = _positive_int(arguments["limit"], f"{path}.arguments.limit")
|
|
374
|
+
if limit > self.max_search_results:
|
|
375
|
+
raise ToolPolicyError(
|
|
376
|
+
f"{path}.arguments.limit exceeds the sanctioned search result limit"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _validate_browse(self, arguments: Mapping[str, Any], path: str) -> None:
|
|
380
|
+
required = {"result_ref", "max_chars"}
|
|
381
|
+
optional = {"cursor"}
|
|
382
|
+
_exact_fields(arguments, required, f"{path}.arguments", optional=optional)
|
|
383
|
+
result_ref = _required_string(
|
|
384
|
+
arguments["result_ref"], f"{path}.arguments.result_ref", maximum=256
|
|
385
|
+
)
|
|
386
|
+
if not result_ref.startswith("searchref://") or "://" in result_ref[12:]:
|
|
387
|
+
raise ToolPolicyError(
|
|
388
|
+
f"{path}.arguments.result_ref must be an opaque searchref:// reference"
|
|
389
|
+
)
|
|
390
|
+
max_chars = _positive_int(arguments["max_chars"], f"{path}.arguments.max_chars")
|
|
391
|
+
if max_chars > self.max_browse_chars:
|
|
392
|
+
raise ToolPolicyError(f"{path}.arguments.max_chars exceeds the browse limit")
|
|
393
|
+
if "cursor" in arguments:
|
|
394
|
+
_required_string(arguments["cursor"], f"{path}.arguments.cursor", maximum=256)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class AgentRequest:
|
|
399
|
+
"""Strict provider-neutral request envelope."""
|
|
400
|
+
|
|
401
|
+
role: str
|
|
402
|
+
prompt_template_version: str
|
|
403
|
+
payload_schema_version: str
|
|
404
|
+
payload: Mapping[str, Any]
|
|
405
|
+
tools: tuple[ToolInvocation, ...] = ()
|
|
406
|
+
secret_references: tuple[SecretReference, ...] = ()
|
|
407
|
+
input_token_budget: int = 1
|
|
408
|
+
max_output_tokens: int = 1
|
|
409
|
+
timeout_micros: int = 1
|
|
410
|
+
max_cost_micros: int = 0
|
|
411
|
+
schema_version: str = AGENT_REQUEST_VERSION
|
|
412
|
+
|
|
413
|
+
def __post_init__(self) -> None:
|
|
414
|
+
if self.schema_version != AGENT_REQUEST_VERSION:
|
|
415
|
+
raise RuntimeContractError(f"request.schema_version must be {AGENT_REQUEST_VERSION!r}")
|
|
416
|
+
if self.role not in AGENT_ROLES:
|
|
417
|
+
raise RuntimeContractError(f"request.role is unsupported: {self.role!r}")
|
|
418
|
+
_version(self.prompt_template_version, "request.prompt_template_version")
|
|
419
|
+
_version(self.payload_schema_version, "request.payload_schema_version")
|
|
420
|
+
object.__setattr__(self, "payload", _strict_json_object(self.payload, "request.payload"))
|
|
421
|
+
_validate_role_payload(
|
|
422
|
+
self.role,
|
|
423
|
+
self.payload_schema_version,
|
|
424
|
+
self.payload,
|
|
425
|
+
direction="input",
|
|
426
|
+
)
|
|
427
|
+
if not isinstance(self.tools, tuple):
|
|
428
|
+
raise RuntimeContractError("request.tools must be a tuple")
|
|
429
|
+
if not all(isinstance(item, ToolInvocation) for item in self.tools):
|
|
430
|
+
raise RuntimeContractError("request.tools contains an invalid invocation")
|
|
431
|
+
if not isinstance(self.secret_references, tuple):
|
|
432
|
+
raise RuntimeContractError("request.secret_references must be a tuple")
|
|
433
|
+
if not all(isinstance(item, SecretReference) for item in self.secret_references):
|
|
434
|
+
raise RuntimeContractError("request.secret_references contains an invalid reference")
|
|
435
|
+
_require_unique((item.name for item in self.secret_references), "secret reference names")
|
|
436
|
+
_require_unique((item.handle_id for item in self.secret_references), "secret handle IDs")
|
|
437
|
+
_positive_accounting_int(self.input_token_budget, "request.input_token_budget")
|
|
438
|
+
_positive_accounting_int(self.max_output_tokens, "request.max_output_tokens")
|
|
439
|
+
_positive_accounting_int(self.timeout_micros, "request.timeout_micros")
|
|
440
|
+
_accounting_int(self.max_cost_micros, "request.max_cost_micros")
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def canonical_bytes(self) -> bytes:
|
|
444
|
+
return _canonical_json_bytes(self.to_dict())
|
|
445
|
+
|
|
446
|
+
@property
|
|
447
|
+
def request_sha256(self) -> str:
|
|
448
|
+
return _sha256(self.canonical_bytes)
|
|
449
|
+
|
|
450
|
+
def to_dict(self) -> dict[str, Any]:
|
|
451
|
+
return {
|
|
452
|
+
"schema_version": self.schema_version,
|
|
453
|
+
"role": self.role,
|
|
454
|
+
"prompt_template_version": self.prompt_template_version,
|
|
455
|
+
"payload_schema_version": self.payload_schema_version,
|
|
456
|
+
"payload": deepcopy(dict(self.payload)),
|
|
457
|
+
"tools": [item.to_dict() for item in self.tools],
|
|
458
|
+
"secret_references": [item.to_dict() for item in self.secret_references],
|
|
459
|
+
"input_token_budget": self.input_token_budget,
|
|
460
|
+
"max_output_tokens": self.max_output_tokens,
|
|
461
|
+
"timeout_micros": self.timeout_micros,
|
|
462
|
+
"max_cost_micros": self.max_cost_micros,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@classmethod
|
|
466
|
+
def from_value(cls, value: Any) -> AgentRequest:
|
|
467
|
+
data = _object(value, "request")
|
|
468
|
+
_exact_fields(
|
|
469
|
+
data,
|
|
470
|
+
{
|
|
471
|
+
"schema_version",
|
|
472
|
+
"role",
|
|
473
|
+
"prompt_template_version",
|
|
474
|
+
"payload_schema_version",
|
|
475
|
+
"payload",
|
|
476
|
+
"tools",
|
|
477
|
+
"secret_references",
|
|
478
|
+
"input_token_budget",
|
|
479
|
+
"max_output_tokens",
|
|
480
|
+
"timeout_micros",
|
|
481
|
+
"max_cost_micros",
|
|
482
|
+
},
|
|
483
|
+
"request",
|
|
484
|
+
)
|
|
485
|
+
tools = _array(data["tools"], "request.tools", maximum=16)
|
|
486
|
+
secrets = _array(data["secret_references"], "request.secret_references", maximum=32)
|
|
487
|
+
return cls(
|
|
488
|
+
schema_version=_required_string(
|
|
489
|
+
data["schema_version"], "request.schema_version", maximum=64
|
|
490
|
+
),
|
|
491
|
+
role=_required_string(data["role"], "request.role", maximum=64),
|
|
492
|
+
prompt_template_version=_required_string(
|
|
493
|
+
data["prompt_template_version"],
|
|
494
|
+
"request.prompt_template_version",
|
|
495
|
+
maximum=128,
|
|
496
|
+
),
|
|
497
|
+
payload_schema_version=_required_string(
|
|
498
|
+
data["payload_schema_version"],
|
|
499
|
+
"request.payload_schema_version",
|
|
500
|
+
maximum=128,
|
|
501
|
+
),
|
|
502
|
+
payload=_object(data["payload"], "request.payload"),
|
|
503
|
+
tools=tuple(
|
|
504
|
+
ToolInvocation.from_value(item, f"request.tools[{index}]")
|
|
505
|
+
for index, item in enumerate(tools)
|
|
506
|
+
),
|
|
507
|
+
secret_references=tuple(
|
|
508
|
+
SecretReference.from_value(item, f"request.secret_references[{index}]")
|
|
509
|
+
for index, item in enumerate(secrets)
|
|
510
|
+
),
|
|
511
|
+
input_token_budget=data["input_token_budget"],
|
|
512
|
+
max_output_tokens=data["max_output_tokens"],
|
|
513
|
+
timeout_micros=data["timeout_micros"],
|
|
514
|
+
max_cost_micros=data["max_cost_micros"],
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@dataclass(frozen=True)
|
|
519
|
+
class AgentResponse:
|
|
520
|
+
"""Strict structured response envelope; role output remains an untrusted proposal."""
|
|
521
|
+
|
|
522
|
+
role: str
|
|
523
|
+
payload_schema_version: str
|
|
524
|
+
payload: Mapping[str, Any]
|
|
525
|
+
schema_version: str = AGENT_RESPONSE_VERSION
|
|
526
|
+
|
|
527
|
+
def __post_init__(self) -> None:
|
|
528
|
+
if self.schema_version != AGENT_RESPONSE_VERSION:
|
|
529
|
+
raise RuntimeContractError(
|
|
530
|
+
f"response.schema_version must be {AGENT_RESPONSE_VERSION!r}"
|
|
531
|
+
)
|
|
532
|
+
if self.role not in AGENT_ROLES:
|
|
533
|
+
raise RuntimeContractError(f"response.role is unsupported: {self.role!r}")
|
|
534
|
+
_version(self.payload_schema_version, "response.payload_schema_version")
|
|
535
|
+
object.__setattr__(self, "payload", _strict_json_object(self.payload, "response.payload"))
|
|
536
|
+
_validate_role_payload(
|
|
537
|
+
self.role,
|
|
538
|
+
self.payload_schema_version,
|
|
539
|
+
self.payload,
|
|
540
|
+
direction="output",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def canonical_bytes(self) -> bytes:
|
|
545
|
+
return _canonical_json_bytes(self.to_dict())
|
|
546
|
+
|
|
547
|
+
@property
|
|
548
|
+
def response_sha256(self) -> str:
|
|
549
|
+
return _sha256(self.canonical_bytes)
|
|
550
|
+
|
|
551
|
+
def to_dict(self) -> dict[str, Any]:
|
|
552
|
+
return {
|
|
553
|
+
"schema_version": self.schema_version,
|
|
554
|
+
"role": self.role,
|
|
555
|
+
"payload_schema_version": self.payload_schema_version,
|
|
556
|
+
"payload": deepcopy(dict(self.payload)),
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
@classmethod
|
|
560
|
+
def from_value(cls, value: Any) -> AgentResponse:
|
|
561
|
+
data = _object(value, "response")
|
|
562
|
+
_exact_fields(
|
|
563
|
+
data,
|
|
564
|
+
{"schema_version", "role", "payload_schema_version", "payload"},
|
|
565
|
+
"response",
|
|
566
|
+
)
|
|
567
|
+
return cls(
|
|
568
|
+
schema_version=_required_string(
|
|
569
|
+
data["schema_version"], "response.schema_version", maximum=64
|
|
570
|
+
),
|
|
571
|
+
role=_required_string(data["role"], "response.role", maximum=64),
|
|
572
|
+
payload_schema_version=_required_string(
|
|
573
|
+
data["payload_schema_version"],
|
|
574
|
+
"response.payload_schema_version",
|
|
575
|
+
maximum=128,
|
|
576
|
+
),
|
|
577
|
+
payload=_object(data["payload"], "response.payload"),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
@dataclass(frozen=True)
|
|
582
|
+
class ProviderResult:
|
|
583
|
+
"""Untrusted structured response and provider-reported integer usage."""
|
|
584
|
+
|
|
585
|
+
response: Any
|
|
586
|
+
input_tokens: Any
|
|
587
|
+
output_tokens: Any
|
|
588
|
+
cost_micros: Any
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@runtime_checkable
|
|
592
|
+
class ProviderAdapter(Protocol):
|
|
593
|
+
"""Minimal provider boundary implemented outside the deterministic core."""
|
|
594
|
+
|
|
595
|
+
@property
|
|
596
|
+
def provider(self) -> str: ...
|
|
597
|
+
|
|
598
|
+
@property
|
|
599
|
+
def model_identifier(self) -> str: ...
|
|
600
|
+
|
|
601
|
+
def invoke(self, request: AgentRequest, tool_policy: ToolPolicy) -> ProviderResult: ...
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@runtime_checkable
|
|
605
|
+
class AgentRuntime(Protocol):
|
|
606
|
+
"""Common protocol for live adapters and checked-in replay."""
|
|
607
|
+
|
|
608
|
+
def call(self, request: AgentRequest) -> AgentCallResult: ...
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@dataclass(frozen=True)
|
|
612
|
+
class BudgetLimits:
|
|
613
|
+
"""Per-call and aggregate run limits. All amounts are inclusive."""
|
|
614
|
+
|
|
615
|
+
max_calls: int
|
|
616
|
+
max_input_tokens_per_call: int
|
|
617
|
+
max_output_tokens_per_call: int
|
|
618
|
+
max_total_tokens_per_call: int
|
|
619
|
+
max_cost_micros_per_call: int
|
|
620
|
+
max_time_micros_per_call: int
|
|
621
|
+
max_input_tokens_per_run: int
|
|
622
|
+
max_output_tokens_per_run: int
|
|
623
|
+
max_total_tokens_per_run: int
|
|
624
|
+
max_cost_micros_per_run: int
|
|
625
|
+
max_time_micros_per_run: int
|
|
626
|
+
|
|
627
|
+
def __post_init__(self) -> None:
|
|
628
|
+
_positive_accounting_int(self.max_calls, "budget.max_calls")
|
|
629
|
+
for name, value in self.to_dict().items():
|
|
630
|
+
if name != "max_calls":
|
|
631
|
+
_accounting_int(value, f"budget.{name}")
|
|
632
|
+
if self.max_total_tokens_per_call < max(
|
|
633
|
+
self.max_input_tokens_per_call, self.max_output_tokens_per_call
|
|
634
|
+
):
|
|
635
|
+
raise RuntimeContractError(
|
|
636
|
+
"budget.max_total_tokens_per_call cannot be smaller than a component limit"
|
|
637
|
+
)
|
|
638
|
+
if self.max_total_tokens_per_run < max(
|
|
639
|
+
self.max_input_tokens_per_run, self.max_output_tokens_per_run
|
|
640
|
+
):
|
|
641
|
+
raise RuntimeContractError(
|
|
642
|
+
"budget.max_total_tokens_per_run cannot be smaller than a component limit"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def to_dict(self) -> dict[str, int]:
|
|
646
|
+
return {
|
|
647
|
+
"max_calls": self.max_calls,
|
|
648
|
+
"max_input_tokens_per_call": self.max_input_tokens_per_call,
|
|
649
|
+
"max_output_tokens_per_call": self.max_output_tokens_per_call,
|
|
650
|
+
"max_total_tokens_per_call": self.max_total_tokens_per_call,
|
|
651
|
+
"max_cost_micros_per_call": self.max_cost_micros_per_call,
|
|
652
|
+
"max_time_micros_per_call": self.max_time_micros_per_call,
|
|
653
|
+
"max_input_tokens_per_run": self.max_input_tokens_per_run,
|
|
654
|
+
"max_output_tokens_per_run": self.max_output_tokens_per_run,
|
|
655
|
+
"max_total_tokens_per_run": self.max_total_tokens_per_run,
|
|
656
|
+
"max_cost_micros_per_run": self.max_cost_micros_per_run,
|
|
657
|
+
"max_time_micros_per_run": self.max_time_micros_per_run,
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
@classmethod
|
|
661
|
+
def generous(cls) -> BudgetLimits:
|
|
662
|
+
"""Wide limits that back the default ledger when a runtime is built without a budget.
|
|
663
|
+
|
|
664
|
+
ProviderAgentRuntime uses these whenever its budget argument is None. Pass an explicit
|
|
665
|
+
BudgetLedger to enforce narrower limits.
|
|
666
|
+
"""
|
|
667
|
+
|
|
668
|
+
return cls(
|
|
669
|
+
max_calls=1_000,
|
|
670
|
+
max_input_tokens_per_call=1_000_000,
|
|
671
|
+
max_output_tokens_per_call=1_000_000,
|
|
672
|
+
max_total_tokens_per_call=2_000_000,
|
|
673
|
+
max_cost_micros_per_call=1_000_000_000,
|
|
674
|
+
max_time_micros_per_call=3_600_000_000,
|
|
675
|
+
max_input_tokens_per_run=100_000_000,
|
|
676
|
+
max_output_tokens_per_run=100_000_000,
|
|
677
|
+
max_total_tokens_per_run=200_000_000,
|
|
678
|
+
max_cost_micros_per_run=100_000_000_000,
|
|
679
|
+
max_time_micros_per_run=86_400_000_000,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@dataclass(frozen=True)
|
|
684
|
+
class BudgetSnapshot:
|
|
685
|
+
calls: int
|
|
686
|
+
input_tokens: int
|
|
687
|
+
output_tokens: int
|
|
688
|
+
cost_micros: int
|
|
689
|
+
time_micros: int
|
|
690
|
+
active_reservations: int
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def total_tokens(self) -> int:
|
|
694
|
+
return self.input_tokens + self.output_tokens
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@dataclass(frozen=True)
|
|
698
|
+
class _Reservation:
|
|
699
|
+
reservation_id: int
|
|
700
|
+
input_tokens: int
|
|
701
|
+
output_tokens: int
|
|
702
|
+
cost_micros: int
|
|
703
|
+
time_micros: int
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class BudgetLedger:
|
|
707
|
+
"""Concurrency-safe reservation and final accounting for a single run."""
|
|
708
|
+
|
|
709
|
+
def __init__(self, limits: BudgetLimits) -> None:
|
|
710
|
+
self._limits = limits
|
|
711
|
+
self._lock = threading.Lock()
|
|
712
|
+
self._next_id = 1
|
|
713
|
+
self._reservations: dict[int, _Reservation] = {}
|
|
714
|
+
self._calls = 0
|
|
715
|
+
self._input_tokens = 0
|
|
716
|
+
self._output_tokens = 0
|
|
717
|
+
self._cost_micros = 0
|
|
718
|
+
self._time_micros = 0
|
|
719
|
+
|
|
720
|
+
@property
|
|
721
|
+
def limits(self) -> BudgetLimits:
|
|
722
|
+
return self._limits
|
|
723
|
+
|
|
724
|
+
def snapshot(self) -> BudgetSnapshot:
|
|
725
|
+
with self._lock:
|
|
726
|
+
return BudgetSnapshot(
|
|
727
|
+
calls=self._calls,
|
|
728
|
+
input_tokens=self._input_tokens,
|
|
729
|
+
output_tokens=self._output_tokens,
|
|
730
|
+
cost_micros=self._cost_micros,
|
|
731
|
+
time_micros=self._time_micros,
|
|
732
|
+
active_reservations=len(self._reservations),
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def reserve(self, request: AgentRequest) -> _Reservation:
|
|
736
|
+
requested_total = _checked_add(
|
|
737
|
+
request.input_token_budget,
|
|
738
|
+
request.max_output_tokens,
|
|
739
|
+
"request reserved total tokens",
|
|
740
|
+
)
|
|
741
|
+
per_call_checks = (
|
|
742
|
+
(
|
|
743
|
+
request.input_token_budget,
|
|
744
|
+
self._limits.max_input_tokens_per_call,
|
|
745
|
+
"input_tokens_per_call",
|
|
746
|
+
),
|
|
747
|
+
(
|
|
748
|
+
request.max_output_tokens,
|
|
749
|
+
self._limits.max_output_tokens_per_call,
|
|
750
|
+
"output_tokens_per_call",
|
|
751
|
+
),
|
|
752
|
+
(
|
|
753
|
+
requested_total,
|
|
754
|
+
self._limits.max_total_tokens_per_call,
|
|
755
|
+
"total_tokens_per_call",
|
|
756
|
+
),
|
|
757
|
+
(
|
|
758
|
+
request.max_cost_micros,
|
|
759
|
+
self._limits.max_cost_micros_per_call,
|
|
760
|
+
"cost_micros_per_call",
|
|
761
|
+
),
|
|
762
|
+
(
|
|
763
|
+
request.timeout_micros,
|
|
764
|
+
self._limits.max_time_micros_per_call,
|
|
765
|
+
"time_micros_per_call",
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
for requested, limit, label in per_call_checks:
|
|
769
|
+
if requested > limit:
|
|
770
|
+
raise _ReservationDenied(f"budget {label} would be exceeded")
|
|
771
|
+
|
|
772
|
+
with self._lock:
|
|
773
|
+
reserved = self._reserved_totals_locked()
|
|
774
|
+
run_checks = (
|
|
775
|
+
(self._calls + reserved.calls + 1, self._limits.max_calls, "calls"),
|
|
776
|
+
(
|
|
777
|
+
self._input_tokens + reserved.input_tokens + request.input_token_budget,
|
|
778
|
+
self._limits.max_input_tokens_per_run,
|
|
779
|
+
"input_tokens_per_run",
|
|
780
|
+
),
|
|
781
|
+
(
|
|
782
|
+
self._output_tokens + reserved.output_tokens + request.max_output_tokens,
|
|
783
|
+
self._limits.max_output_tokens_per_run,
|
|
784
|
+
"output_tokens_per_run",
|
|
785
|
+
),
|
|
786
|
+
(
|
|
787
|
+
self._input_tokens
|
|
788
|
+
+ self._output_tokens
|
|
789
|
+
+ reserved.total_tokens
|
|
790
|
+
+ requested_total,
|
|
791
|
+
self._limits.max_total_tokens_per_run,
|
|
792
|
+
"total_tokens_per_run",
|
|
793
|
+
),
|
|
794
|
+
(
|
|
795
|
+
self._cost_micros + reserved.cost_micros + request.max_cost_micros,
|
|
796
|
+
self._limits.max_cost_micros_per_run,
|
|
797
|
+
"cost_micros_per_run",
|
|
798
|
+
),
|
|
799
|
+
(
|
|
800
|
+
self._time_micros + reserved.time_micros + request.timeout_micros,
|
|
801
|
+
self._limits.max_time_micros_per_run,
|
|
802
|
+
"time_micros_per_run",
|
|
803
|
+
),
|
|
804
|
+
)
|
|
805
|
+
for requested, limit, label in run_checks:
|
|
806
|
+
if requested > limit:
|
|
807
|
+
raise _ReservationDenied(f"budget {label} would be exceeded")
|
|
808
|
+
reservation = _Reservation(
|
|
809
|
+
reservation_id=self._next_id,
|
|
810
|
+
input_tokens=request.input_token_budget,
|
|
811
|
+
output_tokens=request.max_output_tokens,
|
|
812
|
+
cost_micros=request.max_cost_micros,
|
|
813
|
+
time_micros=request.timeout_micros,
|
|
814
|
+
)
|
|
815
|
+
self._next_id += 1
|
|
816
|
+
self._reservations[reservation.reservation_id] = reservation
|
|
817
|
+
return reservation
|
|
818
|
+
|
|
819
|
+
def finalize(
|
|
820
|
+
self,
|
|
821
|
+
reservation: _Reservation,
|
|
822
|
+
*,
|
|
823
|
+
input_tokens: Any,
|
|
824
|
+
output_tokens: Any,
|
|
825
|
+
cost_micros: Any,
|
|
826
|
+
time_micros: Any,
|
|
827
|
+
accounting_status: str = "reported",
|
|
828
|
+
) -> tuple[Usage, tuple[str, ...]]:
|
|
829
|
+
input_value, input_overflow = _provider_accounting(input_tokens, "input_tokens")
|
|
830
|
+
output_value, output_overflow = _provider_accounting(output_tokens, "output_tokens")
|
|
831
|
+
cost_value, cost_overflow = _provider_accounting(cost_micros, "cost_micros")
|
|
832
|
+
time_value, time_overflow = _provider_accounting(time_micros, "time_micros")
|
|
833
|
+
overflow_fields = tuple(
|
|
834
|
+
name
|
|
835
|
+
for name, overflow in (
|
|
836
|
+
("input_tokens", input_overflow),
|
|
837
|
+
("output_tokens", output_overflow),
|
|
838
|
+
("cost_micros", cost_overflow),
|
|
839
|
+
("time_micros", time_overflow),
|
|
840
|
+
)
|
|
841
|
+
if overflow
|
|
842
|
+
)
|
|
843
|
+
if input_value + output_value > MAX_ACCOUNTING_INTEGER:
|
|
844
|
+
overflow_fields = (*overflow_fields, "total_tokens")
|
|
845
|
+
total_value = min(MAX_ACCOUNTING_INTEGER, input_value + output_value)
|
|
846
|
+
usage = Usage(
|
|
847
|
+
input_tokens=min(input_value, total_value),
|
|
848
|
+
output_tokens=min(output_value, MAX_ACCOUNTING_INTEGER - min(input_value, total_value)),
|
|
849
|
+
cost_micros=cost_value,
|
|
850
|
+
accounting_status=accounting_status,
|
|
851
|
+
)
|
|
852
|
+
with self._lock:
|
|
853
|
+
current = self._reservations.pop(reservation.reservation_id, None)
|
|
854
|
+
if current != reservation:
|
|
855
|
+
raise RuntimeContractError("budget reservation was not active")
|
|
856
|
+
self._calls = min(MAX_ACCOUNTING_INTEGER, self._calls + 1)
|
|
857
|
+
self._input_tokens = min(
|
|
858
|
+
MAX_ACCOUNTING_INTEGER, self._input_tokens + usage.input_tokens
|
|
859
|
+
)
|
|
860
|
+
self._output_tokens = min(
|
|
861
|
+
MAX_ACCOUNTING_INTEGER, self._output_tokens + usage.output_tokens
|
|
862
|
+
)
|
|
863
|
+
self._cost_micros = min(MAX_ACCOUNTING_INTEGER, self._cost_micros + usage.cost_micros)
|
|
864
|
+
self._time_micros = min(MAX_ACCOUNTING_INTEGER, self._time_micros + time_value)
|
|
865
|
+
exceeded = list(f"{field}_overflow" for field in overflow_fields)
|
|
866
|
+
actual_total = min(MAX_ACCOUNTING_INTEGER, usage.input_tokens + usage.output_tokens)
|
|
867
|
+
after_checks = (
|
|
868
|
+
(
|
|
869
|
+
usage.input_tokens,
|
|
870
|
+
reservation.input_tokens,
|
|
871
|
+
"reserved_input_tokens",
|
|
872
|
+
),
|
|
873
|
+
(
|
|
874
|
+
usage.output_tokens,
|
|
875
|
+
reservation.output_tokens,
|
|
876
|
+
"reserved_output_tokens",
|
|
877
|
+
),
|
|
878
|
+
(
|
|
879
|
+
usage.cost_micros,
|
|
880
|
+
reservation.cost_micros,
|
|
881
|
+
"reserved_cost_micros",
|
|
882
|
+
),
|
|
883
|
+
(
|
|
884
|
+
time_value,
|
|
885
|
+
reservation.time_micros,
|
|
886
|
+
"reserved_time_micros",
|
|
887
|
+
),
|
|
888
|
+
(
|
|
889
|
+
usage.input_tokens,
|
|
890
|
+
self._limits.max_input_tokens_per_call,
|
|
891
|
+
"input_tokens_per_call",
|
|
892
|
+
),
|
|
893
|
+
(
|
|
894
|
+
usage.output_tokens,
|
|
895
|
+
self._limits.max_output_tokens_per_call,
|
|
896
|
+
"output_tokens_per_call",
|
|
897
|
+
),
|
|
898
|
+
(
|
|
899
|
+
actual_total,
|
|
900
|
+
self._limits.max_total_tokens_per_call,
|
|
901
|
+
"total_tokens_per_call",
|
|
902
|
+
),
|
|
903
|
+
(
|
|
904
|
+
usage.cost_micros,
|
|
905
|
+
self._limits.max_cost_micros_per_call,
|
|
906
|
+
"cost_micros_per_call",
|
|
907
|
+
),
|
|
908
|
+
(
|
|
909
|
+
time_value,
|
|
910
|
+
self._limits.max_time_micros_per_call,
|
|
911
|
+
"time_micros_per_call",
|
|
912
|
+
),
|
|
913
|
+
(self._calls, self._limits.max_calls, "calls"),
|
|
914
|
+
(
|
|
915
|
+
self._input_tokens,
|
|
916
|
+
self._limits.max_input_tokens_per_run,
|
|
917
|
+
"input_tokens_per_run",
|
|
918
|
+
),
|
|
919
|
+
(
|
|
920
|
+
self._output_tokens,
|
|
921
|
+
self._limits.max_output_tokens_per_run,
|
|
922
|
+
"output_tokens_per_run",
|
|
923
|
+
),
|
|
924
|
+
(
|
|
925
|
+
min(
|
|
926
|
+
MAX_ACCOUNTING_INTEGER,
|
|
927
|
+
self._input_tokens + self._output_tokens,
|
|
928
|
+
),
|
|
929
|
+
self._limits.max_total_tokens_per_run,
|
|
930
|
+
"total_tokens_per_run",
|
|
931
|
+
),
|
|
932
|
+
(
|
|
933
|
+
self._cost_micros,
|
|
934
|
+
self._limits.max_cost_micros_per_run,
|
|
935
|
+
"cost_micros_per_run",
|
|
936
|
+
),
|
|
937
|
+
(
|
|
938
|
+
self._time_micros,
|
|
939
|
+
self._limits.max_time_micros_per_run,
|
|
940
|
+
"time_micros_per_run",
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
for actual, limit, label in after_checks:
|
|
944
|
+
if actual > limit:
|
|
945
|
+
exceeded.append(label)
|
|
946
|
+
return usage, tuple(sorted(set(exceeded)))
|
|
947
|
+
|
|
948
|
+
def _reserved_totals_locked(self) -> BudgetSnapshot:
|
|
949
|
+
reservations = tuple(self._reservations.values())
|
|
950
|
+
return BudgetSnapshot(
|
|
951
|
+
calls=len(reservations),
|
|
952
|
+
input_tokens=sum(item.input_tokens for item in reservations),
|
|
953
|
+
output_tokens=sum(item.output_tokens for item in reservations),
|
|
954
|
+
cost_micros=sum(item.cost_micros for item in reservations),
|
|
955
|
+
time_micros=sum(item.time_micros for item in reservations),
|
|
956
|
+
active_reservations=len(reservations),
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
class _ReservationDenied(RuntimeError):
|
|
961
|
+
pass
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@dataclass(frozen=True)
|
|
965
|
+
class AgentCallEvidence:
|
|
966
|
+
"""Complete, canonical evidence for every terminal call outcome."""
|
|
967
|
+
|
|
968
|
+
runtime_version: str
|
|
969
|
+
request_contract_version: str
|
|
970
|
+
response_contract_version: str
|
|
971
|
+
replay_fixture_version: str | None
|
|
972
|
+
provider: str
|
|
973
|
+
model_identifier: str
|
|
974
|
+
provider_configuration_sha256: str
|
|
975
|
+
role: str
|
|
976
|
+
prompt_template_version: str
|
|
977
|
+
prompt_sha256: str
|
|
978
|
+
tool_policy_version: str
|
|
979
|
+
request_sha256: str
|
|
980
|
+
response_sha256: str | None
|
|
981
|
+
raw_response_sha256: str | None
|
|
982
|
+
secret_references: tuple[SecretReference, ...]
|
|
983
|
+
tool_evidence: tuple[ToolCallEvidence, ...]
|
|
984
|
+
outcome: str
|
|
985
|
+
usage: Usage
|
|
986
|
+
timing: TimingEvidence
|
|
987
|
+
deadline_micros: int
|
|
988
|
+
failure_code: str | None = None
|
|
989
|
+
schema_version: str = AGENT_EVIDENCE_VERSION
|
|
990
|
+
|
|
991
|
+
def __post_init__(self) -> None:
|
|
992
|
+
if self.schema_version != AGENT_EVIDENCE_VERSION:
|
|
993
|
+
raise RuntimeContractError(
|
|
994
|
+
f"evidence.schema_version must be {AGENT_EVIDENCE_VERSION!r}"
|
|
995
|
+
)
|
|
996
|
+
if self.runtime_version != AGENT_RUNTIME_VERSION:
|
|
997
|
+
raise RuntimeContractError(
|
|
998
|
+
f"evidence.runtime_version must be {AGENT_RUNTIME_VERSION!r}"
|
|
999
|
+
)
|
|
1000
|
+
if self.request_contract_version != AGENT_REQUEST_VERSION:
|
|
1001
|
+
raise RuntimeContractError(
|
|
1002
|
+
f"evidence.request_contract_version must be {AGENT_REQUEST_VERSION!r}"
|
|
1003
|
+
)
|
|
1004
|
+
if self.response_contract_version != AGENT_RESPONSE_VERSION:
|
|
1005
|
+
raise RuntimeContractError(
|
|
1006
|
+
f"evidence.response_contract_version must be {AGENT_RESPONSE_VERSION!r}"
|
|
1007
|
+
)
|
|
1008
|
+
if self.replay_fixture_version is not None:
|
|
1009
|
+
_version(self.replay_fixture_version, "evidence.replay_fixture_version")
|
|
1010
|
+
_identifier(self.provider, "evidence.provider")
|
|
1011
|
+
_required_string(self.model_identifier, "evidence.model_identifier", maximum=256)
|
|
1012
|
+
_digest(
|
|
1013
|
+
self.provider_configuration_sha256,
|
|
1014
|
+
"evidence.provider_configuration_sha256",
|
|
1015
|
+
)
|
|
1016
|
+
if self.role not in AGENT_ROLES:
|
|
1017
|
+
raise RuntimeContractError("evidence.role is unsupported")
|
|
1018
|
+
_version(self.prompt_template_version, "evidence.prompt_template_version")
|
|
1019
|
+
_digest(self.prompt_sha256, "evidence.prompt_sha256")
|
|
1020
|
+
_version(self.tool_policy_version, "evidence.tool_policy_version")
|
|
1021
|
+
_digest(self.request_sha256, "evidence.request_sha256")
|
|
1022
|
+
if self.response_sha256 is not None:
|
|
1023
|
+
_digest(self.response_sha256, "evidence.response_sha256")
|
|
1024
|
+
if self.raw_response_sha256 is not None:
|
|
1025
|
+
_digest(self.raw_response_sha256, "evidence.raw_response_sha256")
|
|
1026
|
+
if not isinstance(self.secret_references, tuple) or not all(
|
|
1027
|
+
isinstance(item, SecretReference) for item in self.secret_references
|
|
1028
|
+
):
|
|
1029
|
+
raise RuntimeContractError("evidence.secret_references must be scoped handles")
|
|
1030
|
+
if not isinstance(self.tool_evidence, tuple) or not all(
|
|
1031
|
+
isinstance(item, ToolCallEvidence) for item in self.tool_evidence
|
|
1032
|
+
):
|
|
1033
|
+
raise RuntimeContractError("evidence.tool_evidence must contain tool call evidence")
|
|
1034
|
+
_require_unique(
|
|
1035
|
+
(item.handle_id for item in self.secret_references),
|
|
1036
|
+
"evidence secret handle IDs",
|
|
1037
|
+
)
|
|
1038
|
+
expected_sequences = tuple(range(1, len(self.tool_evidence) + 1))
|
|
1039
|
+
if tuple(item.sequence for item in self.tool_evidence) != expected_sequences:
|
|
1040
|
+
raise RuntimeContractError("evidence.tool_evidence sequences must be contiguous")
|
|
1041
|
+
_nonnegative_int(self.deadline_micros, "evidence.deadline_micros")
|
|
1042
|
+
if self.deadline_micros < self.timing.started_micros:
|
|
1043
|
+
raise RuntimeContractError("evidence.deadline_micros precedes call start")
|
|
1044
|
+
if self.outcome not in _OUTCOMES:
|
|
1045
|
+
raise RuntimeContractError(f"evidence.outcome is unsupported: {self.outcome!r}")
|
|
1046
|
+
if self.outcome in _PRE_DISPATCH_OUTCOMES:
|
|
1047
|
+
if self.usage.accounting_status != "not_applicable":
|
|
1048
|
+
raise RuntimeContractError("pre-dispatch evidence usage must be not_applicable")
|
|
1049
|
+
if self.usage.input_tokens or self.usage.output_tokens or self.usage.cost_micros:
|
|
1050
|
+
raise RuntimeContractError("pre-dispatch evidence usage must be zero")
|
|
1051
|
+
elif self.usage.accounting_status == "not_applicable":
|
|
1052
|
+
raise RuntimeContractError("post-dispatch evidence usage cannot be not_applicable")
|
|
1053
|
+
if self.outcome == "succeeded" and self.usage.accounting_status != "reported":
|
|
1054
|
+
raise RuntimeContractError("successful evidence usage must be provider-reported")
|
|
1055
|
+
if self.outcome == "succeeded":
|
|
1056
|
+
if self.response_sha256 is None or self.failure_code is not None:
|
|
1057
|
+
raise RuntimeContractError("successful evidence needs a response and no failure")
|
|
1058
|
+
elif self.failure_code is None:
|
|
1059
|
+
raise RuntimeContractError("failed evidence needs a stable failure_code")
|
|
1060
|
+
if self.failure_code is not None:
|
|
1061
|
+
_identifier(self.failure_code, "evidence.failure_code")
|
|
1062
|
+
|
|
1063
|
+
@property
|
|
1064
|
+
def evidence_sha256(self) -> str:
|
|
1065
|
+
return _sha256(_canonical_json_bytes(self.to_dict()))
|
|
1066
|
+
|
|
1067
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1068
|
+
return {
|
|
1069
|
+
"schema_version": self.schema_version,
|
|
1070
|
+
"runtime_version": self.runtime_version,
|
|
1071
|
+
"request_contract_version": self.request_contract_version,
|
|
1072
|
+
"response_contract_version": self.response_contract_version,
|
|
1073
|
+
"replay_fixture_version": self.replay_fixture_version,
|
|
1074
|
+
"provider": self.provider,
|
|
1075
|
+
"model_identifier": self.model_identifier,
|
|
1076
|
+
"provider_configuration_sha256": self.provider_configuration_sha256,
|
|
1077
|
+
"role": self.role,
|
|
1078
|
+
"prompt_template_version": self.prompt_template_version,
|
|
1079
|
+
"prompt_sha256": self.prompt_sha256,
|
|
1080
|
+
"tool_policy_version": self.tool_policy_version,
|
|
1081
|
+
"request_sha256": self.request_sha256,
|
|
1082
|
+
"response_sha256": self.response_sha256,
|
|
1083
|
+
"raw_response_sha256": self.raw_response_sha256,
|
|
1084
|
+
"secret_references": [item.to_dict() for item in self.secret_references],
|
|
1085
|
+
"tool_evidence": [item.to_dict() for item in self.tool_evidence],
|
|
1086
|
+
"outcome": self.outcome,
|
|
1087
|
+
"usage": self.usage.to_dict(),
|
|
1088
|
+
"timing": self.timing.to_dict(),
|
|
1089
|
+
"deadline_micros": self.deadline_micros,
|
|
1090
|
+
"failure_code": self.failure_code,
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
@dataclass(frozen=True)
|
|
1095
|
+
class AgentCallResult:
|
|
1096
|
+
response: AgentResponse
|
|
1097
|
+
evidence: AgentCallEvidence
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
class AgentCallFailure(AgentRuntimeError):
|
|
1101
|
+
"""A typed terminal failure carrying complete deterministic evidence."""
|
|
1102
|
+
|
|
1103
|
+
def __init__(self, evidence: AgentCallEvidence) -> None:
|
|
1104
|
+
self.evidence = evidence
|
|
1105
|
+
super().__init__(f"agent call failed: {evidence.failure_code}")
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
class BudgetExceededBeforeCall(AgentCallFailure):
|
|
1109
|
+
"""The call was not sent because its reservation could not be made."""
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
class BudgetExceededAfterCall(AgentCallFailure):
|
|
1113
|
+
"""Provider work was accounted, but the actual result exceeded a limit."""
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
class ProviderCallFailed(AgentCallFailure):
|
|
1117
|
+
"""Provider raised an exception before yielding a structured result."""
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
class MalformedProviderResponse(AgentCallFailure):
|
|
1121
|
+
"""Provider output or accounting failed strict validation."""
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
class AgentCallTimedOut(AgentCallFailure):
|
|
1125
|
+
"""The adapter reported or consumed more than its bounded call time."""
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
class AgentCallCancelled(AgentCallFailure):
|
|
1129
|
+
"""The provider call was cancelled; the reservation was still finalized."""
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class ToolPolicyDenied(AgentCallFailure, ToolPolicyError):
|
|
1133
|
+
"""The invocation was rejected before provider execution with complete evidence."""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
class ProviderAgentRuntime:
|
|
1137
|
+
"""Deterministic coordinator around one provider adapter and one run ledger."""
|
|
1138
|
+
|
|
1139
|
+
def __init__(
|
|
1140
|
+
self,
|
|
1141
|
+
adapter: ProviderAdapter,
|
|
1142
|
+
*,
|
|
1143
|
+
budget: BudgetLedger | None = None,
|
|
1144
|
+
tool_policy: ToolPolicy | None = None,
|
|
1145
|
+
clock: Callable[[], int] | None = None,
|
|
1146
|
+
replay_fixture_version: str | None = None,
|
|
1147
|
+
) -> None:
|
|
1148
|
+
if not isinstance(adapter, ProviderAdapter):
|
|
1149
|
+
raise RuntimeContractError("adapter does not implement ProviderAdapter")
|
|
1150
|
+
_identifier(adapter.provider, "provider")
|
|
1151
|
+
_required_string(adapter.model_identifier, "model_identifier", maximum=256)
|
|
1152
|
+
self._adapter = adapter
|
|
1153
|
+
self._budget = budget or BudgetLedger(BudgetLimits.generous())
|
|
1154
|
+
self._tool_policy = tool_policy or ToolPolicy()
|
|
1155
|
+
self._clock = clock or _monotonic_micros
|
|
1156
|
+
if replay_fixture_version is not None:
|
|
1157
|
+
_version(replay_fixture_version, "replay_fixture_version")
|
|
1158
|
+
self._replay_fixture_version = replay_fixture_version
|
|
1159
|
+
|
|
1160
|
+
@property
|
|
1161
|
+
def budget(self) -> BudgetLedger:
|
|
1162
|
+
return self._budget
|
|
1163
|
+
|
|
1164
|
+
def call(self, request: AgentRequest) -> AgentCallResult:
|
|
1165
|
+
if not isinstance(request, AgentRequest):
|
|
1166
|
+
raise RuntimeContractError("call request must be an AgentRequest")
|
|
1167
|
+
started = self._now()
|
|
1168
|
+
try:
|
|
1169
|
+
for index, tool in enumerate(request.tools):
|
|
1170
|
+
self._tool_policy.validate(tool, path=f"request.tools[{index}]")
|
|
1171
|
+
except ToolPolicyError:
|
|
1172
|
+
finished = self._now()
|
|
1173
|
+
evidence = self._evidence(
|
|
1174
|
+
request,
|
|
1175
|
+
outcome="tool_policy_denied",
|
|
1176
|
+
started=started,
|
|
1177
|
+
finished=finished,
|
|
1178
|
+
usage=Usage(0, 0, 0, accounting_status="not_applicable"),
|
|
1179
|
+
failure_code="tool_policy_denied",
|
|
1180
|
+
)
|
|
1181
|
+
raise ToolPolicyDenied(evidence) from None
|
|
1182
|
+
try:
|
|
1183
|
+
reservation = self._budget.reserve(request)
|
|
1184
|
+
except _ReservationDenied as exc:
|
|
1185
|
+
finished = self._now()
|
|
1186
|
+
evidence = self._evidence(
|
|
1187
|
+
request,
|
|
1188
|
+
outcome="budget_exhausted_before_call",
|
|
1189
|
+
started=started,
|
|
1190
|
+
finished=finished,
|
|
1191
|
+
usage=Usage(0, 0, 0, accounting_status="not_applicable"),
|
|
1192
|
+
failure_code=_stable_failure_code(exc),
|
|
1193
|
+
)
|
|
1194
|
+
raise BudgetExceededBeforeCall(evidence) from None
|
|
1195
|
+
|
|
1196
|
+
try:
|
|
1197
|
+
result = self._adapter.invoke(request, self._tool_policy)
|
|
1198
|
+
except asyncio.CancelledError:
|
|
1199
|
+
evidence = self._finish_exception(
|
|
1200
|
+
request,
|
|
1201
|
+
reservation,
|
|
1202
|
+
started,
|
|
1203
|
+
outcome="cancelled",
|
|
1204
|
+
failure_code="provider_cancelled",
|
|
1205
|
+
)
|
|
1206
|
+
raise AgentCallCancelled(evidence) from None
|
|
1207
|
+
except (ProviderTimeoutError, TimeoutError):
|
|
1208
|
+
evidence = self._finish_exception(
|
|
1209
|
+
request,
|
|
1210
|
+
reservation,
|
|
1211
|
+
started,
|
|
1212
|
+
outcome="timed_out",
|
|
1213
|
+
failure_code="provider_timeout",
|
|
1214
|
+
)
|
|
1215
|
+
raise AgentCallTimedOut(evidence) from None
|
|
1216
|
+
except Exception:
|
|
1217
|
+
evidence = self._finish_exception(
|
|
1218
|
+
request,
|
|
1219
|
+
reservation,
|
|
1220
|
+
started,
|
|
1221
|
+
outcome="provider_exception",
|
|
1222
|
+
failure_code="provider_exception",
|
|
1223
|
+
)
|
|
1224
|
+
raise ProviderCallFailed(evidence) from None
|
|
1225
|
+
|
|
1226
|
+
finished = self._now()
|
|
1227
|
+
elapsed = self._elapsed(started, finished)
|
|
1228
|
+
if not isinstance(result, ProviderResult):
|
|
1229
|
+
usage, exceeded = self._budget.finalize(
|
|
1230
|
+
reservation,
|
|
1231
|
+
input_tokens=reservation.input_tokens,
|
|
1232
|
+
output_tokens=reservation.output_tokens,
|
|
1233
|
+
cost_micros=reservation.cost_micros,
|
|
1234
|
+
time_micros=elapsed,
|
|
1235
|
+
accounting_status="unavailable_reserved",
|
|
1236
|
+
)
|
|
1237
|
+
if exceeded:
|
|
1238
|
+
evidence = self._evidence(
|
|
1239
|
+
request,
|
|
1240
|
+
outcome="budget_exhausted_after_call",
|
|
1241
|
+
started=started,
|
|
1242
|
+
finished=finished,
|
|
1243
|
+
usage=usage,
|
|
1244
|
+
raw_response_sha256=_safe_response_hash(result),
|
|
1245
|
+
failure_code=_after_budget_code(exceeded),
|
|
1246
|
+
)
|
|
1247
|
+
raise BudgetExceededAfterCall(evidence)
|
|
1248
|
+
evidence = self._evidence(
|
|
1249
|
+
request,
|
|
1250
|
+
outcome="malformed_response",
|
|
1251
|
+
started=started,
|
|
1252
|
+
finished=finished,
|
|
1253
|
+
usage=usage,
|
|
1254
|
+
raw_response_sha256=_safe_response_hash(result),
|
|
1255
|
+
failure_code="invalid_provider_result",
|
|
1256
|
+
)
|
|
1257
|
+
raise MalformedProviderResponse(evidence)
|
|
1258
|
+
raw_response_hash = _safe_response_hash(result.response)
|
|
1259
|
+
try:
|
|
1260
|
+
usage, exceeded = self._budget.finalize(
|
|
1261
|
+
reservation,
|
|
1262
|
+
input_tokens=result.input_tokens,
|
|
1263
|
+
output_tokens=result.output_tokens,
|
|
1264
|
+
cost_micros=result.cost_micros,
|
|
1265
|
+
time_micros=elapsed,
|
|
1266
|
+
)
|
|
1267
|
+
except RuntimeContractError:
|
|
1268
|
+
# Invalid accounting cannot be trusted. Conservatively charge the full reservation.
|
|
1269
|
+
usage, exceeded = self._budget.finalize(
|
|
1270
|
+
reservation,
|
|
1271
|
+
input_tokens=reservation.input_tokens,
|
|
1272
|
+
output_tokens=reservation.output_tokens,
|
|
1273
|
+
cost_micros=reservation.cost_micros,
|
|
1274
|
+
time_micros=elapsed,
|
|
1275
|
+
accounting_status="unavailable_reserved",
|
|
1276
|
+
)
|
|
1277
|
+
if exceeded:
|
|
1278
|
+
evidence = self._evidence(
|
|
1279
|
+
request,
|
|
1280
|
+
outcome="budget_exhausted_after_call",
|
|
1281
|
+
started=started,
|
|
1282
|
+
finished=finished,
|
|
1283
|
+
usage=usage,
|
|
1284
|
+
raw_response_sha256=raw_response_hash,
|
|
1285
|
+
failure_code=_after_budget_code(exceeded),
|
|
1286
|
+
)
|
|
1287
|
+
raise BudgetExceededAfterCall(evidence) from None
|
|
1288
|
+
evidence = self._evidence(
|
|
1289
|
+
request,
|
|
1290
|
+
outcome="malformed_response",
|
|
1291
|
+
started=started,
|
|
1292
|
+
finished=finished,
|
|
1293
|
+
usage=usage,
|
|
1294
|
+
raw_response_sha256=raw_response_hash,
|
|
1295
|
+
failure_code="invalid_provider_accounting",
|
|
1296
|
+
)
|
|
1297
|
+
raise MalformedProviderResponse(evidence) from None
|
|
1298
|
+
|
|
1299
|
+
if elapsed > request.timeout_micros:
|
|
1300
|
+
exceeded = tuple(sorted(set((*exceeded, "request_timeout"))))
|
|
1301
|
+
if exceeded:
|
|
1302
|
+
evidence = self._evidence(
|
|
1303
|
+
request,
|
|
1304
|
+
outcome="budget_exhausted_after_call",
|
|
1305
|
+
started=started,
|
|
1306
|
+
finished=finished,
|
|
1307
|
+
usage=usage,
|
|
1308
|
+
raw_response_sha256=raw_response_hash,
|
|
1309
|
+
failure_code=_after_budget_code(exceeded),
|
|
1310
|
+
)
|
|
1311
|
+
if "request_timeout" in exceeded:
|
|
1312
|
+
raise AgentCallTimedOut(evidence)
|
|
1313
|
+
raise BudgetExceededAfterCall(evidence)
|
|
1314
|
+
|
|
1315
|
+
try:
|
|
1316
|
+
response = AgentResponse.from_value(result.response)
|
|
1317
|
+
if response.role != request.role:
|
|
1318
|
+
raise RuntimeContractError("response.role does not match request.role")
|
|
1319
|
+
_validate_request_response_semantics(request, response)
|
|
1320
|
+
except RuntimeContractError:
|
|
1321
|
+
evidence = self._evidence(
|
|
1322
|
+
request,
|
|
1323
|
+
outcome="malformed_response",
|
|
1324
|
+
started=started,
|
|
1325
|
+
finished=finished,
|
|
1326
|
+
usage=usage,
|
|
1327
|
+
raw_response_sha256=raw_response_hash,
|
|
1328
|
+
failure_code="malformed_structured_output",
|
|
1329
|
+
)
|
|
1330
|
+
raise MalformedProviderResponse(evidence) from None
|
|
1331
|
+
|
|
1332
|
+
evidence = self._evidence(
|
|
1333
|
+
request,
|
|
1334
|
+
outcome="succeeded",
|
|
1335
|
+
started=started,
|
|
1336
|
+
finished=finished,
|
|
1337
|
+
usage=usage,
|
|
1338
|
+
response_sha256=response.response_sha256,
|
|
1339
|
+
raw_response_sha256=raw_response_hash,
|
|
1340
|
+
)
|
|
1341
|
+
return AgentCallResult(response=response, evidence=evidence)
|
|
1342
|
+
|
|
1343
|
+
def _finish_exception(
|
|
1344
|
+
self,
|
|
1345
|
+
request: AgentRequest,
|
|
1346
|
+
reservation: _Reservation,
|
|
1347
|
+
started: int,
|
|
1348
|
+
*,
|
|
1349
|
+
outcome: str,
|
|
1350
|
+
failure_code: str,
|
|
1351
|
+
) -> AgentCallEvidence:
|
|
1352
|
+
finished = self._now()
|
|
1353
|
+
elapsed = self._elapsed(started, finished)
|
|
1354
|
+
usage, _ = self._budget.finalize(
|
|
1355
|
+
reservation,
|
|
1356
|
+
input_tokens=reservation.input_tokens,
|
|
1357
|
+
output_tokens=reservation.output_tokens,
|
|
1358
|
+
cost_micros=reservation.cost_micros,
|
|
1359
|
+
time_micros=elapsed,
|
|
1360
|
+
accounting_status="unavailable_reserved",
|
|
1361
|
+
)
|
|
1362
|
+
return self._evidence(
|
|
1363
|
+
request,
|
|
1364
|
+
outcome=outcome,
|
|
1365
|
+
started=started,
|
|
1366
|
+
finished=finished,
|
|
1367
|
+
usage=usage,
|
|
1368
|
+
failure_code=failure_code,
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
def _evidence(
|
|
1372
|
+
self,
|
|
1373
|
+
request: AgentRequest,
|
|
1374
|
+
*,
|
|
1375
|
+
outcome: str,
|
|
1376
|
+
started: int,
|
|
1377
|
+
finished: int,
|
|
1378
|
+
usage: Usage,
|
|
1379
|
+
failure_code: str | None = None,
|
|
1380
|
+
response_sha256: str | None = None,
|
|
1381
|
+
raw_response_sha256: str | None = None,
|
|
1382
|
+
) -> AgentCallEvidence:
|
|
1383
|
+
return AgentCallEvidence(
|
|
1384
|
+
runtime_version=AGENT_RUNTIME_VERSION,
|
|
1385
|
+
request_contract_version=AGENT_REQUEST_VERSION,
|
|
1386
|
+
response_contract_version=AGENT_RESPONSE_VERSION,
|
|
1387
|
+
replay_fixture_version=self._replay_fixture_version,
|
|
1388
|
+
provider=self._adapter.provider,
|
|
1389
|
+
model_identifier=self._adapter.model_identifier,
|
|
1390
|
+
provider_configuration_sha256=_provider_configuration_digest(self._adapter),
|
|
1391
|
+
role=request.role,
|
|
1392
|
+
prompt_template_version=request.prompt_template_version,
|
|
1393
|
+
prompt_sha256=_prompt_digest(request),
|
|
1394
|
+
tool_policy_version=self._tool_policy.version,
|
|
1395
|
+
request_sha256=request.request_sha256,
|
|
1396
|
+
response_sha256=response_sha256,
|
|
1397
|
+
raw_response_sha256=raw_response_sha256,
|
|
1398
|
+
secret_references=request.secret_references,
|
|
1399
|
+
tool_evidence=tuple(
|
|
1400
|
+
ToolCallEvidence.from_invocation(index, invocation)
|
|
1401
|
+
for index, invocation in enumerate(request.tools, start=1)
|
|
1402
|
+
),
|
|
1403
|
+
outcome=outcome,
|
|
1404
|
+
usage=usage,
|
|
1405
|
+
timing=TimingEvidence(
|
|
1406
|
+
started_micros=started,
|
|
1407
|
+
finished_micros=finished,
|
|
1408
|
+
elapsed_micros=self._elapsed(started, finished),
|
|
1409
|
+
),
|
|
1410
|
+
deadline_micros=_checked_add(
|
|
1411
|
+
started,
|
|
1412
|
+
request.timeout_micros,
|
|
1413
|
+
"evidence.deadline_micros",
|
|
1414
|
+
),
|
|
1415
|
+
failure_code=failure_code,
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
def _now(self) -> int:
|
|
1419
|
+
return _nonnegative_int(self._clock(), "clock result")
|
|
1420
|
+
|
|
1421
|
+
@staticmethod
|
|
1422
|
+
def _elapsed(started: int, finished: int) -> int:
|
|
1423
|
+
if finished < started:
|
|
1424
|
+
raise RuntimeContractError("injected clock moved backwards")
|
|
1425
|
+
return finished - started
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
@dataclass(frozen=True)
|
|
1429
|
+
class _ReplayEntry:
|
|
1430
|
+
request: AgentRequest
|
|
1431
|
+
response: Mapping[str, Any]
|
|
1432
|
+
response_sha256: str
|
|
1433
|
+
provider: str
|
|
1434
|
+
model_identifier: str
|
|
1435
|
+
usage: Usage
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
class _ReplayAdapter:
|
|
1439
|
+
def __init__(self, entries: Mapping[str, tuple[_ReplayEntry, ...]]) -> None:
|
|
1440
|
+
self._entries = entries
|
|
1441
|
+
self._local = threading.local()
|
|
1442
|
+
|
|
1443
|
+
@property
|
|
1444
|
+
def provider(self) -> str:
|
|
1445
|
+
return getattr(self._local, "provider", "recorded-replay")
|
|
1446
|
+
|
|
1447
|
+
@property
|
|
1448
|
+
def model_identifier(self) -> str:
|
|
1449
|
+
return getattr(self._local, "model_identifier", "fixture-catalog.v1")
|
|
1450
|
+
|
|
1451
|
+
def bind(self, entry: _ReplayEntry) -> None:
|
|
1452
|
+
"""Bind recorded provider identity before reservation or invocation."""
|
|
1453
|
+
|
|
1454
|
+
self._local.provider = entry.provider
|
|
1455
|
+
self._local.model_identifier = entry.model_identifier
|
|
1456
|
+
|
|
1457
|
+
def invoke(self, request: AgentRequest, tool_policy: ToolPolicy) -> ProviderResult:
|
|
1458
|
+
del tool_policy
|
|
1459
|
+
matches = self._entries.get(request.request_sha256, ())
|
|
1460
|
+
if not matches:
|
|
1461
|
+
raise ReplayMissError(
|
|
1462
|
+
f"no recorded replay for canonical request {request.request_sha256}"
|
|
1463
|
+
)
|
|
1464
|
+
if len(matches) != 1:
|
|
1465
|
+
raise ReplayAmbiguityError(
|
|
1466
|
+
f"multiple recorded replays for canonical request {request.request_sha256}"
|
|
1467
|
+
)
|
|
1468
|
+
entry = matches[0]
|
|
1469
|
+
if entry.request.canonical_bytes != request.canonical_bytes:
|
|
1470
|
+
raise ReplayTamperError("replay request hash collision or request fixture drift")
|
|
1471
|
+
self.bind(entry)
|
|
1472
|
+
return ProviderResult(
|
|
1473
|
+
response=deepcopy(dict(entry.response)),
|
|
1474
|
+
input_tokens=entry.usage.input_tokens,
|
|
1475
|
+
output_tokens=entry.usage.output_tokens,
|
|
1476
|
+
cost_micros=entry.usage.cost_micros,
|
|
1477
|
+
)
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
class ReplayAgentRuntime(ProviderAgentRuntime):
|
|
1481
|
+
"""Offline runtime backed only by duplicate-strict canonical checked-in fixtures.
|
|
1482
|
+
|
|
1483
|
+
Construct it with from_catalog (a checked-in catalog file) or from_recorded (one
|
|
1484
|
+
request/response pair already in memory). The __init__ entries mapping is the
|
|
1485
|
+
already-validated internal form those two classmethods build.
|
|
1486
|
+
"""
|
|
1487
|
+
|
|
1488
|
+
def __init__(
|
|
1489
|
+
self,
|
|
1490
|
+
entries: Mapping[str, tuple[_ReplayEntry, ...]],
|
|
1491
|
+
*,
|
|
1492
|
+
budget: BudgetLedger | None = None,
|
|
1493
|
+
tool_policy: ToolPolicy | None = None,
|
|
1494
|
+
clock: Callable[[], int] | None = None,
|
|
1495
|
+
) -> None:
|
|
1496
|
+
self._entries = entries
|
|
1497
|
+
self._replay_adapter = _ReplayAdapter(entries)
|
|
1498
|
+
super().__init__(
|
|
1499
|
+
self._replay_adapter,
|
|
1500
|
+
budget=budget,
|
|
1501
|
+
tool_policy=tool_policy,
|
|
1502
|
+
clock=clock,
|
|
1503
|
+
replay_fixture_version=REPLAY_CATALOG_VERSION,
|
|
1504
|
+
)
|
|
1505
|
+
|
|
1506
|
+
def call(self, request: AgentRequest) -> AgentCallResult:
|
|
1507
|
+
if not isinstance(request, AgentRequest):
|
|
1508
|
+
raise RuntimeContractError("call request must be an AgentRequest")
|
|
1509
|
+
matches = self._entries.get(request.request_sha256, ())
|
|
1510
|
+
if not matches:
|
|
1511
|
+
raise ReplayMissError(
|
|
1512
|
+
f"no recorded replay for canonical request {request.request_sha256}"
|
|
1513
|
+
)
|
|
1514
|
+
if len(matches) != 1:
|
|
1515
|
+
raise ReplayAmbiguityError(
|
|
1516
|
+
f"multiple recorded replays for canonical request {request.request_sha256}"
|
|
1517
|
+
)
|
|
1518
|
+
if matches[0].request.canonical_bytes != request.canonical_bytes:
|
|
1519
|
+
raise ReplayTamperError("replay request hash collision or request fixture drift")
|
|
1520
|
+
self._replay_adapter.bind(matches[0])
|
|
1521
|
+
return super().call(request)
|
|
1522
|
+
|
|
1523
|
+
@classmethod
|
|
1524
|
+
def from_recorded(
|
|
1525
|
+
cls,
|
|
1526
|
+
*,
|
|
1527
|
+
request: AgentRequest,
|
|
1528
|
+
response: AgentResponse,
|
|
1529
|
+
provider: str,
|
|
1530
|
+
model_identifier: str,
|
|
1531
|
+
input_tokens: int,
|
|
1532
|
+
output_tokens: int,
|
|
1533
|
+
cost_micros: int,
|
|
1534
|
+
budget: BudgetLedger | None = None,
|
|
1535
|
+
tool_policy: ToolPolicy | None = None,
|
|
1536
|
+
clock: Callable[[], int] | None = None,
|
|
1537
|
+
) -> ReplayAgentRuntime:
|
|
1538
|
+
"""Create one exact in-memory replay after strict request/response binding."""
|
|
1539
|
+
|
|
1540
|
+
if not isinstance(request, AgentRequest) or not isinstance(response, AgentResponse):
|
|
1541
|
+
raise RuntimeContractError("recorded replay requires typed request and response")
|
|
1542
|
+
_validate_request_response_semantics(request, response)
|
|
1543
|
+
normalized_provider = _required_string(provider, "provider", maximum=64)
|
|
1544
|
+
_identifier(normalized_provider, "provider")
|
|
1545
|
+
normalized_model = _required_string(
|
|
1546
|
+
model_identifier,
|
|
1547
|
+
"model_identifier",
|
|
1548
|
+
maximum=256,
|
|
1549
|
+
)
|
|
1550
|
+
usage = Usage(
|
|
1551
|
+
input_tokens=input_tokens,
|
|
1552
|
+
output_tokens=output_tokens,
|
|
1553
|
+
cost_micros=cost_micros,
|
|
1554
|
+
)
|
|
1555
|
+
entry = _ReplayEntry(
|
|
1556
|
+
request=request,
|
|
1557
|
+
response=response.to_dict(),
|
|
1558
|
+
response_sha256=response.response_sha256,
|
|
1559
|
+
provider=normalized_provider,
|
|
1560
|
+
model_identifier=normalized_model,
|
|
1561
|
+
usage=usage,
|
|
1562
|
+
)
|
|
1563
|
+
return cls(
|
|
1564
|
+
{request.request_sha256: (entry,)},
|
|
1565
|
+
budget=budget,
|
|
1566
|
+
tool_policy=tool_policy,
|
|
1567
|
+
clock=clock,
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
@classmethod
|
|
1571
|
+
def from_catalog(
|
|
1572
|
+
cls,
|
|
1573
|
+
catalog_path: Path | str,
|
|
1574
|
+
*,
|
|
1575
|
+
budget: BudgetLedger | None = None,
|
|
1576
|
+
tool_policy: ToolPolicy | None = None,
|
|
1577
|
+
clock: Callable[[], int] | None = None,
|
|
1578
|
+
) -> ReplayAgentRuntime:
|
|
1579
|
+
path = Path(catalog_path)
|
|
1580
|
+
catalog = _load_canonical_json_file(path, "replay catalog")
|
|
1581
|
+
data = _object(catalog, "catalog")
|
|
1582
|
+
_exact_fields(data, {"schema_version", "tool_policy_version", "entries"}, "catalog")
|
|
1583
|
+
if data["schema_version"] != REPLAY_CATALOG_VERSION:
|
|
1584
|
+
raise ReplayVersionError(f"catalog.schema_version must be {REPLAY_CATALOG_VERSION!r}")
|
|
1585
|
+
if data["tool_policy_version"] != TOOL_POLICY_VERSION:
|
|
1586
|
+
raise ReplayVersionError(f"catalog.tool_policy_version must be {TOOL_POLICY_VERSION!r}")
|
|
1587
|
+
raw_entries = _array(data["entries"], "catalog.entries", nonempty=True, maximum=10_000)
|
|
1588
|
+
entries: dict[str, list[_ReplayEntry]] = {}
|
|
1589
|
+
root = path.parent.resolve()
|
|
1590
|
+
for index, raw_entry in enumerate(raw_entries):
|
|
1591
|
+
entry_path = f"catalog.entries[{index}]"
|
|
1592
|
+
item = _object(raw_entry, entry_path)
|
|
1593
|
+
_exact_fields(
|
|
1594
|
+
item,
|
|
1595
|
+
{
|
|
1596
|
+
"request_file",
|
|
1597
|
+
"response_file",
|
|
1598
|
+
"request_sha256",
|
|
1599
|
+
"response_sha256",
|
|
1600
|
+
"provider",
|
|
1601
|
+
"model_identifier",
|
|
1602
|
+
"input_tokens",
|
|
1603
|
+
"output_tokens",
|
|
1604
|
+
"cost_micros",
|
|
1605
|
+
},
|
|
1606
|
+
entry_path,
|
|
1607
|
+
)
|
|
1608
|
+
request_file = _fixture_member(
|
|
1609
|
+
root,
|
|
1610
|
+
_required_string(item["request_file"], f"{entry_path}.request_file", maximum=256),
|
|
1611
|
+
)
|
|
1612
|
+
response_file = _fixture_member(
|
|
1613
|
+
root,
|
|
1614
|
+
_required_string(item["response_file"], f"{entry_path}.response_file", maximum=256),
|
|
1615
|
+
)
|
|
1616
|
+
request_value = _load_canonical_json_file(request_file, "replay request")
|
|
1617
|
+
response_value = _load_canonical_json_file(response_file, "replay response")
|
|
1618
|
+
try:
|
|
1619
|
+
request = AgentRequest.from_value(request_value)
|
|
1620
|
+
response = AgentResponse.from_value(response_value)
|
|
1621
|
+
except RuntimeContractError as exc:
|
|
1622
|
+
error_type = ReplayVersionError if "schema" in str(exc) else ReplayTamperError
|
|
1623
|
+
raise error_type(f"{entry_path} member contract drift: {exc}") from None
|
|
1624
|
+
if response.role != request.role:
|
|
1625
|
+
raise ReplayTamperError(f"{entry_path} response role does not match request")
|
|
1626
|
+
try:
|
|
1627
|
+
_validate_request_response_semantics(request, response)
|
|
1628
|
+
except RuntimeContractError as exc:
|
|
1629
|
+
raise ReplayTamperError(
|
|
1630
|
+
f"{entry_path} response cross-reference mismatch: {exc}"
|
|
1631
|
+
) from None
|
|
1632
|
+
expected_request = _digest(item["request_sha256"], f"{entry_path}.request_sha256")
|
|
1633
|
+
expected_response = _digest(item["response_sha256"], f"{entry_path}.response_sha256")
|
|
1634
|
+
if request.request_sha256 != expected_request:
|
|
1635
|
+
raise ReplayTamperError(f"{entry_path} request fixture hash mismatch")
|
|
1636
|
+
if response.response_sha256 != expected_response:
|
|
1637
|
+
raise ReplayTamperError(f"{entry_path} response fixture hash mismatch")
|
|
1638
|
+
replay_entry = _ReplayEntry(
|
|
1639
|
+
request=request,
|
|
1640
|
+
response=response.to_dict(),
|
|
1641
|
+
response_sha256=expected_response,
|
|
1642
|
+
provider=_required_string(item["provider"], f"{entry_path}.provider", maximum=64),
|
|
1643
|
+
model_identifier=_required_string(
|
|
1644
|
+
item["model_identifier"],
|
|
1645
|
+
f"{entry_path}.model_identifier",
|
|
1646
|
+
maximum=256,
|
|
1647
|
+
),
|
|
1648
|
+
usage=Usage(
|
|
1649
|
+
input_tokens=item["input_tokens"],
|
|
1650
|
+
output_tokens=item["output_tokens"],
|
|
1651
|
+
cost_micros=item["cost_micros"],
|
|
1652
|
+
),
|
|
1653
|
+
)
|
|
1654
|
+
_identifier(replay_entry.provider, f"{entry_path}.provider")
|
|
1655
|
+
entries.setdefault(expected_request, []).append(replay_entry)
|
|
1656
|
+
return cls(
|
|
1657
|
+
{key: tuple(value) for key, value in entries.items()},
|
|
1658
|
+
budget=budget,
|
|
1659
|
+
tool_policy=tool_policy,
|
|
1660
|
+
clock=clock,
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def _load_canonical_json_file(path: Path, label: str) -> Any:
|
|
1665
|
+
try:
|
|
1666
|
+
file_stat = path.lstat()
|
|
1667
|
+
if stat.S_ISLNK(file_stat.st_mode) or not stat.S_ISREG(file_stat.st_mode):
|
|
1668
|
+
raise ReplayTamperError(f"{label} must be a regular non-symlink file")
|
|
1669
|
+
raw = path.read_bytes()
|
|
1670
|
+
except ReplayTamperError:
|
|
1671
|
+
raise
|
|
1672
|
+
except OSError as exc:
|
|
1673
|
+
raise ReplayTamperError(f"cannot read {label}: {path.name}") from exc
|
|
1674
|
+
try:
|
|
1675
|
+
value = json.loads(
|
|
1676
|
+
raw.decode("utf-8"),
|
|
1677
|
+
object_pairs_hook=_reject_duplicate_pairs,
|
|
1678
|
+
parse_constant=_reject_json_constant,
|
|
1679
|
+
)
|
|
1680
|
+
except (UnicodeDecodeError, json.JSONDecodeError, RuntimeContractError) as exc:
|
|
1681
|
+
raise ReplayTamperError(f"{label} is not duplicate-strict UTF-8 JSON") from exc
|
|
1682
|
+
try:
|
|
1683
|
+
canonical = _canonical_json_bytes(value)
|
|
1684
|
+
except RuntimeContractError as exc:
|
|
1685
|
+
raise ReplayTamperError(f"{label} contains unsupported values") from exc
|
|
1686
|
+
if raw != canonical:
|
|
1687
|
+
raise ReplayTamperError(f"{label} is not canonical JSON")
|
|
1688
|
+
return value
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def _fixture_member(root: Path, relative: str) -> Path:
|
|
1692
|
+
if "\\" in relative or relative.startswith("/") or "//" in relative:
|
|
1693
|
+
raise ReplayTamperError("replay member path must be canonical relative POSIX text")
|
|
1694
|
+
parts = relative.split("/")
|
|
1695
|
+
if not parts or any(part in {"", ".", ".."} for part in parts):
|
|
1696
|
+
raise ReplayTamperError("replay member path must be canonical relative POSIX text")
|
|
1697
|
+
candidate = root.joinpath(*parts)
|
|
1698
|
+
if candidate.parent != root or candidate.is_symlink():
|
|
1699
|
+
raise ReplayTamperError("replay members must be siblings of the catalog")
|
|
1700
|
+
return candidate
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def _strict_json_object(value: Any, path: str) -> dict[str, Any]:
|
|
1704
|
+
data = _object(value, path)
|
|
1705
|
+
state = [0]
|
|
1706
|
+
normalized = _strict_json_value(data, path, depth=0, state=state)
|
|
1707
|
+
assert isinstance(normalized, dict)
|
|
1708
|
+
return normalized
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _validate_role_payload(
|
|
1712
|
+
role: str,
|
|
1713
|
+
schema_version: str,
|
|
1714
|
+
payload: Mapping[str, Any],
|
|
1715
|
+
*,
|
|
1716
|
+
direction: str,
|
|
1717
|
+
) -> None:
|
|
1718
|
+
if (
|
|
1719
|
+
role == "semantic_reviewer"
|
|
1720
|
+
and direction == "input"
|
|
1721
|
+
and schema_version == SEMANTIC_REVIEW_INPUT_VERSION
|
|
1722
|
+
):
|
|
1723
|
+
_validate_hosted_semantic_review_input(payload)
|
|
1724
|
+
return
|
|
1725
|
+
expected = ROLE_PAYLOAD_SCHEMAS[role][0 if direction == "input" else 1]
|
|
1726
|
+
if schema_version != expected:
|
|
1727
|
+
raise RuntimeContractError(f"{direction} payload schema for {role!r} must be {expected!r}")
|
|
1728
|
+
if direction == "input":
|
|
1729
|
+
_validate_role_input(role, payload)
|
|
1730
|
+
else:
|
|
1731
|
+
_validate_role_output(role, payload)
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def _validate_hosted_semantic_review_input(payload: Mapping[str, Any]) -> None:
|
|
1735
|
+
"""Check a semantic-reviewer payload declared as SEMANTIC_REVIEW_INPUT_VERSION."""
|
|
1736
|
+
|
|
1737
|
+
_exact_fields(payload, {"candidate_evidence", "requirements"}, "request.payload")
|
|
1738
|
+
candidate = _object(payload["candidate_evidence"], "request.payload.candidate_evidence")
|
|
1739
|
+
_exact_fields(
|
|
1740
|
+
candidate,
|
|
1741
|
+
{
|
|
1742
|
+
"candidate_id",
|
|
1743
|
+
"candidate_digest",
|
|
1744
|
+
"plan_digest",
|
|
1745
|
+
"artifact_scope_digest",
|
|
1746
|
+
"candidate_contract_version",
|
|
1747
|
+
"artifact_digests",
|
|
1748
|
+
"inspection",
|
|
1749
|
+
"inspection_digest",
|
|
1750
|
+
},
|
|
1751
|
+
"request.payload.candidate_evidence",
|
|
1752
|
+
)
|
|
1753
|
+
_required_string(
|
|
1754
|
+
candidate["candidate_id"],
|
|
1755
|
+
"request.payload.candidate_evidence.candidate_id",
|
|
1756
|
+
maximum=128,
|
|
1757
|
+
)
|
|
1758
|
+
for name in (
|
|
1759
|
+
"candidate_digest",
|
|
1760
|
+
"plan_digest",
|
|
1761
|
+
"artifact_scope_digest",
|
|
1762
|
+
"inspection_digest",
|
|
1763
|
+
):
|
|
1764
|
+
_digest(candidate[name], f"request.payload.candidate_evidence.{name}")
|
|
1765
|
+
candidate_contract_version = _required_string(
|
|
1766
|
+
candidate["candidate_contract_version"],
|
|
1767
|
+
"request.payload.candidate_evidence.candidate_contract_version",
|
|
1768
|
+
maximum=128,
|
|
1769
|
+
)
|
|
1770
|
+
if not _SEMANTIC_VERSION.fullmatch(candidate_contract_version):
|
|
1771
|
+
raise RuntimeContractError(
|
|
1772
|
+
"request.payload.candidate_evidence.candidate_contract_version "
|
|
1773
|
+
"must be a canonical semantic version"
|
|
1774
|
+
)
|
|
1775
|
+
artifact_digests = _object(
|
|
1776
|
+
candidate["artifact_digests"],
|
|
1777
|
+
"request.payload.candidate_evidence.artifact_digests",
|
|
1778
|
+
)
|
|
1779
|
+
expected_kinds = {
|
|
1780
|
+
"candidate_parquet",
|
|
1781
|
+
"table_preview",
|
|
1782
|
+
"profile",
|
|
1783
|
+
"quality_report",
|
|
1784
|
+
"lineage",
|
|
1785
|
+
"candidate_evidence",
|
|
1786
|
+
"raw_snapshot",
|
|
1787
|
+
"table_package",
|
|
1788
|
+
}
|
|
1789
|
+
_exact_fields(
|
|
1790
|
+
artifact_digests,
|
|
1791
|
+
expected_kinds,
|
|
1792
|
+
"request.payload.candidate_evidence.artifact_digests",
|
|
1793
|
+
)
|
|
1794
|
+
for kind in sorted(expected_kinds):
|
|
1795
|
+
_digest(
|
|
1796
|
+
artifact_digests[kind],
|
|
1797
|
+
f"request.payload.candidate_evidence.artifact_digests.{kind}",
|
|
1798
|
+
)
|
|
1799
|
+
inspection = _object(
|
|
1800
|
+
candidate["inspection"],
|
|
1801
|
+
"request.payload.candidate_evidence.inspection",
|
|
1802
|
+
)
|
|
1803
|
+
if _sha256(_canonical_json_bytes(inspection)) != candidate["inspection_digest"]:
|
|
1804
|
+
raise RuntimeContractError(
|
|
1805
|
+
"request.payload.candidate_evidence.inspection_digest mismatches inspection"
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
requirements = _object(payload["requirements"], "request.payload.requirements")
|
|
1809
|
+
_exact_fields(
|
|
1810
|
+
requirements,
|
|
1811
|
+
{
|
|
1812
|
+
"requirements_id",
|
|
1813
|
+
"output_grain",
|
|
1814
|
+
"validation_policy_digest",
|
|
1815
|
+
"release_policy_digest",
|
|
1816
|
+
"required_check_ids",
|
|
1817
|
+
},
|
|
1818
|
+
"request.payload.requirements",
|
|
1819
|
+
)
|
|
1820
|
+
_required_string(
|
|
1821
|
+
requirements["requirements_id"],
|
|
1822
|
+
"request.payload.requirements.requirements_id",
|
|
1823
|
+
maximum=128,
|
|
1824
|
+
)
|
|
1825
|
+
_string_array(
|
|
1826
|
+
requirements["output_grain"],
|
|
1827
|
+
"request.payload.requirements.output_grain",
|
|
1828
|
+
nonempty=True,
|
|
1829
|
+
maximum=64,
|
|
1830
|
+
)
|
|
1831
|
+
_digest(
|
|
1832
|
+
requirements["validation_policy_digest"],
|
|
1833
|
+
"request.payload.requirements.validation_policy_digest",
|
|
1834
|
+
)
|
|
1835
|
+
_digest(
|
|
1836
|
+
requirements["release_policy_digest"],
|
|
1837
|
+
"request.payload.requirements.release_policy_digest",
|
|
1838
|
+
)
|
|
1839
|
+
_string_array(
|
|
1840
|
+
requirements["required_check_ids"],
|
|
1841
|
+
"request.payload.requirements.required_check_ids",
|
|
1842
|
+
nonempty=True,
|
|
1843
|
+
maximum=256,
|
|
1844
|
+
)
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
def _validate_role_input(role: str, payload: Mapping[str, Any]) -> None:
|
|
1848
|
+
if role == "question_framer":
|
|
1849
|
+
_exact_fields(payload, {"question"}, "request.payload", optional={"context"})
|
|
1850
|
+
_required_string(payload["question"], "request.payload.question", maximum=10_000)
|
|
1851
|
+
if "context" in payload:
|
|
1852
|
+
_object(payload["context"], "request.payload.context")
|
|
1853
|
+
return
|
|
1854
|
+
input_fields = {
|
|
1855
|
+
"feasibility_analyst": ({"framed_question"}, {"context"}),
|
|
1856
|
+
"data_scout": ({"requirements"}, {"context"}),
|
|
1857
|
+
"source_auditor": ({"source", "requirements"}, set()),
|
|
1858
|
+
"plan_proposer": ({"requirements", "sources"}, set()),
|
|
1859
|
+
"semantic_reviewer": ({"candidate_evidence", "requirements"}, set()),
|
|
1860
|
+
"repair_proposer": ({"finding", "candidate_evidence"}, set()),
|
|
1861
|
+
}
|
|
1862
|
+
required, optional = input_fields[role]
|
|
1863
|
+
_exact_fields(payload, required, "request.payload", optional=optional)
|
|
1864
|
+
for name in required:
|
|
1865
|
+
value = payload[name]
|
|
1866
|
+
if name == "sources":
|
|
1867
|
+
sources = _array(value, f"request.payload.{name}", nonempty=True, maximum=256)
|
|
1868
|
+
for index, source in enumerate(sources):
|
|
1869
|
+
_identifier_object(
|
|
1870
|
+
source,
|
|
1871
|
+
f"request.payload.{name}[{index}]",
|
|
1872
|
+
"source_id",
|
|
1873
|
+
)
|
|
1874
|
+
else:
|
|
1875
|
+
identifier_field = {
|
|
1876
|
+
"framed_question": "question_id",
|
|
1877
|
+
"requirements": "requirements_id",
|
|
1878
|
+
"source": "source_id",
|
|
1879
|
+
"candidate_evidence": "candidate_id",
|
|
1880
|
+
"finding": "finding_id",
|
|
1881
|
+
}[name]
|
|
1882
|
+
_identifier_object(value, f"request.payload.{name}", identifier_field)
|
|
1883
|
+
if "context" in payload:
|
|
1884
|
+
_object(payload["context"], "request.payload.context")
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def _validate_request_response_semantics(
|
|
1888
|
+
request: AgentRequest,
|
|
1889
|
+
response: AgentResponse,
|
|
1890
|
+
) -> None:
|
|
1891
|
+
if response.role != request.role:
|
|
1892
|
+
raise RuntimeContractError("response.role does not match request.role")
|
|
1893
|
+
if request.role == "source_auditor":
|
|
1894
|
+
requested_source_id = request.payload["source"]["source_id"]
|
|
1895
|
+
if response.payload["source_id"] != requested_source_id:
|
|
1896
|
+
raise RuntimeContractError(
|
|
1897
|
+
"source_auditor response.source_id does not match request source_id"
|
|
1898
|
+
)
|
|
1899
|
+
elif request.role == "plan_proposer":
|
|
1900
|
+
requested_source_ids = [source["source_id"] for source in request.payload["sources"]]
|
|
1901
|
+
if response.payload["plan"]["source_ids"] != requested_source_ids:
|
|
1902
|
+
raise RuntimeContractError(
|
|
1903
|
+
"plan_proposer response source_ids do not exactly match request sources"
|
|
1904
|
+
)
|
|
1905
|
+
elif request.role == "repair_proposer":
|
|
1906
|
+
requested_finding_id = request.payload["finding"]["finding_id"]
|
|
1907
|
+
if response.payload["repair"]["finding_ids"] != [requested_finding_id]:
|
|
1908
|
+
raise RuntimeContractError(
|
|
1909
|
+
"repair_proposer response finding_ids do not exactly match request finding"
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _validate_role_output(role: str, payload: Mapping[str, Any]) -> None:
|
|
1914
|
+
if role == "question_framer":
|
|
1915
|
+
_exact_fields(
|
|
1916
|
+
payload,
|
|
1917
|
+
{
|
|
1918
|
+
"question",
|
|
1919
|
+
"population",
|
|
1920
|
+
"time_range",
|
|
1921
|
+
"output_grain",
|
|
1922
|
+
"success_criteria",
|
|
1923
|
+
},
|
|
1924
|
+
"response.payload",
|
|
1925
|
+
)
|
|
1926
|
+
_required_string(payload["question"], "response.payload.question", maximum=10_000)
|
|
1927
|
+
_required_string(payload["population"], "response.payload.population", maximum=10_000)
|
|
1928
|
+
time_range = _object(payload["time_range"], "response.payload.time_range")
|
|
1929
|
+
_exact_fields(time_range, {"start", "end"}, "response.payload.time_range")
|
|
1930
|
+
_half_open_dates(
|
|
1931
|
+
time_range["start"],
|
|
1932
|
+
time_range["end"],
|
|
1933
|
+
"response.payload.time_range",
|
|
1934
|
+
)
|
|
1935
|
+
_string_array(
|
|
1936
|
+
payload["output_grain"],
|
|
1937
|
+
"response.payload.output_grain",
|
|
1938
|
+
nonempty=True,
|
|
1939
|
+
maximum=64,
|
|
1940
|
+
)
|
|
1941
|
+
_string_array(
|
|
1942
|
+
payload["success_criteria"],
|
|
1943
|
+
"response.payload.success_criteria",
|
|
1944
|
+
nonempty=True,
|
|
1945
|
+
maximum=128,
|
|
1946
|
+
)
|
|
1947
|
+
return
|
|
1948
|
+
output_fields = {
|
|
1949
|
+
"feasibility_analyst": {"decision", "rationale"},
|
|
1950
|
+
"data_scout": {"candidates"},
|
|
1951
|
+
"source_auditor": {"source_id", "assessment"},
|
|
1952
|
+
"plan_proposer": {"plan"},
|
|
1953
|
+
"semantic_reviewer": {"findings"},
|
|
1954
|
+
"repair_proposer": {"repair"},
|
|
1955
|
+
}
|
|
1956
|
+
_exact_fields(payload, output_fields[role], "response.payload")
|
|
1957
|
+
if role == "feasibility_analyst":
|
|
1958
|
+
decision = _required_string(payload["decision"], "response.payload.decision", maximum=64)
|
|
1959
|
+
if decision not in {"feasible", "conditionally_feasible", "not_feasible"}:
|
|
1960
|
+
raise RuntimeContractError("response.payload.decision is unsupported")
|
|
1961
|
+
_string_array(
|
|
1962
|
+
payload["rationale"],
|
|
1963
|
+
"response.payload.rationale",
|
|
1964
|
+
nonempty=True,
|
|
1965
|
+
maximum=128,
|
|
1966
|
+
)
|
|
1967
|
+
elif role == "data_scout":
|
|
1968
|
+
candidates = _array(
|
|
1969
|
+
payload["candidates"],
|
|
1970
|
+
"response.payload.candidates",
|
|
1971
|
+
nonempty=True,
|
|
1972
|
+
maximum=256,
|
|
1973
|
+
)
|
|
1974
|
+
source_ids: list[str] = []
|
|
1975
|
+
for index, candidate in enumerate(candidates):
|
|
1976
|
+
source_ids.append(
|
|
1977
|
+
_validate_source_candidate(
|
|
1978
|
+
candidate,
|
|
1979
|
+
f"response.payload.candidates[{index}]",
|
|
1980
|
+
)
|
|
1981
|
+
)
|
|
1982
|
+
_require_unique(source_ids, "response.payload.candidates source IDs")
|
|
1983
|
+
elif role == "source_auditor":
|
|
1984
|
+
_identifier(payload["source_id"], "response.payload.source_id")
|
|
1985
|
+
_validate_source_assessment(
|
|
1986
|
+
payload["assessment"],
|
|
1987
|
+
"response.payload.assessment",
|
|
1988
|
+
)
|
|
1989
|
+
elif role == "plan_proposer":
|
|
1990
|
+
_validate_plan(payload["plan"], "response.payload.plan")
|
|
1991
|
+
elif role == "semantic_reviewer":
|
|
1992
|
+
findings = _array(payload["findings"], "response.payload.findings", maximum=1_024)
|
|
1993
|
+
finding_ids: list[str] = []
|
|
1994
|
+
for index, finding in enumerate(findings):
|
|
1995
|
+
finding_ids.append(
|
|
1996
|
+
_validate_semantic_finding(
|
|
1997
|
+
finding,
|
|
1998
|
+
f"response.payload.findings[{index}]",
|
|
1999
|
+
)
|
|
2000
|
+
)
|
|
2001
|
+
_require_unique(finding_ids, "response.payload.findings finding IDs")
|
|
2002
|
+
else:
|
|
2003
|
+
_validate_repair(payload["repair"], "response.payload.repair")
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _identifier_object(value: Any, path: str, field: str) -> Mapping[str, Any]:
|
|
2007
|
+
item = _object(value, path)
|
|
2008
|
+
_exact_fields(item, {field}, path)
|
|
2009
|
+
_required_string(item[field], f"{path}.{field}", maximum=128)
|
|
2010
|
+
return item
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def _half_open_dates(start: Any, end: Any, path: str) -> None:
|
|
2014
|
+
start_text = _required_string(start, f"{path}.start", maximum=10)
|
|
2015
|
+
end_text = _required_string(end, f"{path}.end", maximum=10)
|
|
2016
|
+
if not _CANONICAL_DATE.fullmatch(start_text) or not _CANONICAL_DATE.fullmatch(end_text):
|
|
2017
|
+
raise RuntimeContractError(f"{path} must contain canonical ISO dates")
|
|
2018
|
+
try:
|
|
2019
|
+
start_date = date.fromisoformat(start_text)
|
|
2020
|
+
end_date = date.fromisoformat(end_text)
|
|
2021
|
+
except ValueError as exc:
|
|
2022
|
+
raise RuntimeContractError(f"{path} must contain valid ISO dates") from exc
|
|
2023
|
+
if end_date <= start_date:
|
|
2024
|
+
raise RuntimeContractError(f"{path} must be a non-empty half-open range")
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def _canonical_utc_datetime(value: Any, path: str) -> datetime:
|
|
2028
|
+
text = _required_string(value, path, maximum=64)
|
|
2029
|
+
if not _CANONICAL_UTC.fullmatch(text):
|
|
2030
|
+
raise RuntimeContractError(f"{path} must be a canonical UTC timestamp ending in Z")
|
|
2031
|
+
try:
|
|
2032
|
+
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
|
|
2033
|
+
except ValueError as exc:
|
|
2034
|
+
raise RuntimeContractError(f"{path} must be a valid UTC timestamp") from exc
|
|
2035
|
+
if parsed.tzinfo != UTC:
|
|
2036
|
+
raise RuntimeContractError(f"{path} must be UTC")
|
|
2037
|
+
return parsed
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def _enum_string(value: Any, path: str, allowed: frozenset[str]) -> str:
|
|
2041
|
+
result = _required_string(value, path, maximum=128)
|
|
2042
|
+
if result not in allowed:
|
|
2043
|
+
raise RuntimeContractError(f"{path} is unsupported")
|
|
2044
|
+
return result
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _validate_source_candidate(value: Any, path: str) -> str:
|
|
2048
|
+
item = _object(value, path)
|
|
2049
|
+
_exact_fields(
|
|
2050
|
+
item,
|
|
2051
|
+
{
|
|
2052
|
+
"source_id",
|
|
2053
|
+
"name",
|
|
2054
|
+
"source_class",
|
|
2055
|
+
"locator",
|
|
2056
|
+
"observed_at",
|
|
2057
|
+
"coverage",
|
|
2058
|
+
"delay",
|
|
2059
|
+
"liveness",
|
|
2060
|
+
"rights_status",
|
|
2061
|
+
"evidence",
|
|
2062
|
+
},
|
|
2063
|
+
path,
|
|
2064
|
+
)
|
|
2065
|
+
source_id = _identifier(item["source_id"], f"{path}.source_id")
|
|
2066
|
+
_required_string(item["name"], f"{path}.name", maximum=512)
|
|
2067
|
+
# Coarse source-class family; see the vocabulary map in sources/contracts.py.
|
|
2068
|
+
_enum_string(
|
|
2069
|
+
item["source_class"],
|
|
2070
|
+
f"{path}.source_class",
|
|
2071
|
+
frozenset({"external", "user"}),
|
|
2072
|
+
)
|
|
2073
|
+
_required_string(item["locator"], f"{path}.locator", maximum=2_048)
|
|
2074
|
+
observed_at = _canonical_utc_datetime(item["observed_at"], f"{path}.observed_at")
|
|
2075
|
+
coverage = _object(item["coverage"], f"{path}.coverage")
|
|
2076
|
+
_exact_fields(coverage, {"start", "end", "population"}, f"{path}.coverage")
|
|
2077
|
+
_half_open_dates(coverage["start"], coverage["end"], f"{path}.coverage")
|
|
2078
|
+
_required_string(coverage["population"], f"{path}.coverage.population", maximum=1_000)
|
|
2079
|
+
delay_observed_at = _validate_delay(item["delay"], f"{path}.delay")
|
|
2080
|
+
liveness_checked_at = _validate_liveness(item["liveness"], f"{path}.liveness")
|
|
2081
|
+
if delay_observed_at > observed_at:
|
|
2082
|
+
raise RuntimeContractError(f"{path}.delay.observed_at cannot follow observed_at")
|
|
2083
|
+
if liveness_checked_at > observed_at:
|
|
2084
|
+
raise RuntimeContractError(f"{path}.liveness.checked_at cannot follow observed_at")
|
|
2085
|
+
# Spelling is shared with local_contracts and sources/contracts; see the vocabulary map.
|
|
2086
|
+
_enum_string(
|
|
2087
|
+
item["rights_status"],
|
|
2088
|
+
f"{path}.rights_status",
|
|
2089
|
+
frozenset({"approved", "conditional", "unclear", "prohibited"}),
|
|
2090
|
+
)
|
|
2091
|
+
_string_array(item["evidence"], f"{path}.evidence", nonempty=True, maximum=64)
|
|
2092
|
+
return source_id
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
def _validate_delay(value: Any, path: str) -> datetime:
|
|
2096
|
+
delay = _object(value, path)
|
|
2097
|
+
_exact_fields(delay, {"typical_seconds", "maximum_seconds", "observed_at"}, path)
|
|
2098
|
+
typical = _nonnegative_int(delay["typical_seconds"], f"{path}.typical_seconds")
|
|
2099
|
+
maximum = _nonnegative_int(delay["maximum_seconds"], f"{path}.maximum_seconds")
|
|
2100
|
+
if maximum < typical:
|
|
2101
|
+
raise RuntimeContractError(f"{path}.maximum_seconds cannot be below typical_seconds")
|
|
2102
|
+
return _canonical_utc_datetime(delay["observed_at"], f"{path}.observed_at")
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
def _validate_liveness(value: Any, path: str) -> datetime:
|
|
2106
|
+
liveness = _object(value, path)
|
|
2107
|
+
_exact_fields(liveness, {"status", "checked_at"}, path)
|
|
2108
|
+
# unknown has no local_contracts counterpart; see the vocabulary map in sources/contracts.py.
|
|
2109
|
+
_enum_string(
|
|
2110
|
+
liveness["status"],
|
|
2111
|
+
f"{path}.status",
|
|
2112
|
+
frozenset({"live", "delayed", "dead", "unknown"}),
|
|
2113
|
+
)
|
|
2114
|
+
return _canonical_utc_datetime(liveness["checked_at"], f"{path}.checked_at")
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def _validate_source_assessment(value: Any, path: str) -> None:
|
|
2118
|
+
assessment = _object(value, path)
|
|
2119
|
+
_exact_fields(
|
|
2120
|
+
assessment,
|
|
2121
|
+
{
|
|
2122
|
+
"fitness",
|
|
2123
|
+
"coverage",
|
|
2124
|
+
"delay",
|
|
2125
|
+
"liveness",
|
|
2126
|
+
"rights_status",
|
|
2127
|
+
"data_classification",
|
|
2128
|
+
"retention",
|
|
2129
|
+
"evidence",
|
|
2130
|
+
"rejection_codes",
|
|
2131
|
+
},
|
|
2132
|
+
path,
|
|
2133
|
+
)
|
|
2134
|
+
# Stage-specific triplet; see the vocabulary map in sources/contracts.py.
|
|
2135
|
+
_enum_string(
|
|
2136
|
+
assessment["fitness"],
|
|
2137
|
+
f"{path}.fitness",
|
|
2138
|
+
frozenset({"fit", "conditional", "reject"}),
|
|
2139
|
+
)
|
|
2140
|
+
coverage = _object(assessment["coverage"], f"{path}.coverage")
|
|
2141
|
+
_exact_fields(coverage, {"start", "end", "population"}, f"{path}.coverage")
|
|
2142
|
+
_half_open_dates(coverage["start"], coverage["end"], f"{path}.coverage")
|
|
2143
|
+
_required_string(coverage["population"], f"{path}.coverage.population", maximum=1_000)
|
|
2144
|
+
delay_observed_at = _validate_delay(assessment["delay"], f"{path}.delay")
|
|
2145
|
+
liveness_checked_at = _validate_liveness(assessment["liveness"], f"{path}.liveness")
|
|
2146
|
+
if delay_observed_at > liveness_checked_at:
|
|
2147
|
+
raise RuntimeContractError(f"{path}.delay.observed_at cannot follow liveness.checked_at")
|
|
2148
|
+
# Spelling is shared with local_contracts and sources/contracts; see the vocabulary map.
|
|
2149
|
+
_enum_string(
|
|
2150
|
+
assessment["rights_status"],
|
|
2151
|
+
f"{path}.rights_status",
|
|
2152
|
+
frozenset({"approved", "conditional", "unclear", "prohibited"}),
|
|
2153
|
+
)
|
|
2154
|
+
# sources/contracts adds a terminal rejected state; see the vocabulary map.
|
|
2155
|
+
_enum_string(
|
|
2156
|
+
assessment["data_classification"],
|
|
2157
|
+
f"{path}.data_classification",
|
|
2158
|
+
frozenset({"public", "internal", "confidential", "restricted"}),
|
|
2159
|
+
)
|
|
2160
|
+
retention = _object(assessment["retention"], f"{path}.retention")
|
|
2161
|
+
_exact_fields(
|
|
2162
|
+
retention,
|
|
2163
|
+
{"raw_days", "derived_days", "tombstone_required"},
|
|
2164
|
+
f"{path}.retention",
|
|
2165
|
+
)
|
|
2166
|
+
_nonnegative_int(retention["raw_days"], f"{path}.retention.raw_days")
|
|
2167
|
+
_nonnegative_int(retention["derived_days"], f"{path}.retention.derived_days")
|
|
2168
|
+
if type(retention["tombstone_required"]) is not bool:
|
|
2169
|
+
raise RuntimeContractError(f"{path}.retention.tombstone_required must be a boolean")
|
|
2170
|
+
_string_array(assessment["evidence"], f"{path}.evidence", nonempty=True, maximum=128)
|
|
2171
|
+
_string_array(
|
|
2172
|
+
assessment["rejection_codes"],
|
|
2173
|
+
f"{path}.rejection_codes",
|
|
2174
|
+
nonempty=False,
|
|
2175
|
+
maximum=64,
|
|
2176
|
+
)
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
def _validate_operation(value: Any, path: str) -> tuple[str, tuple[str, ...], tuple[str, ...]]:
|
|
2180
|
+
operation = _object(value, path)
|
|
2181
|
+
_exact_fields(
|
|
2182
|
+
operation,
|
|
2183
|
+
{"operation_id", "kind", "inputs", "outputs", "parameters"},
|
|
2184
|
+
path,
|
|
2185
|
+
)
|
|
2186
|
+
operation_id = _identifier(operation["operation_id"], f"{path}.operation_id")
|
|
2187
|
+
_enum_string(
|
|
2188
|
+
operation["kind"],
|
|
2189
|
+
f"{path}.kind",
|
|
2190
|
+
frozenset(
|
|
2191
|
+
{
|
|
2192
|
+
"profile",
|
|
2193
|
+
"clean",
|
|
2194
|
+
"normalize",
|
|
2195
|
+
"resolve",
|
|
2196
|
+
"join",
|
|
2197
|
+
"label",
|
|
2198
|
+
"validate",
|
|
2199
|
+
}
|
|
2200
|
+
),
|
|
2201
|
+
)
|
|
2202
|
+
inputs = _string_array(operation["inputs"], f"{path}.inputs", nonempty=True, maximum=128)
|
|
2203
|
+
outputs = _string_array(operation["outputs"], f"{path}.outputs", nonempty=True, maximum=128)
|
|
2204
|
+
_object(operation["parameters"], f"{path}.parameters")
|
|
2205
|
+
return operation_id, inputs, outputs
|
|
2206
|
+
|
|
2207
|
+
|
|
2208
|
+
def _validate_plan(value: Any, path: str) -> None:
|
|
2209
|
+
plan = _object(value, path)
|
|
2210
|
+
_exact_fields(
|
|
2211
|
+
plan,
|
|
2212
|
+
{
|
|
2213
|
+
"plan_id",
|
|
2214
|
+
"source_ids",
|
|
2215
|
+
"output_grain",
|
|
2216
|
+
"operations",
|
|
2217
|
+
"validation_rules",
|
|
2218
|
+
"temporal_policy",
|
|
2219
|
+
},
|
|
2220
|
+
path,
|
|
2221
|
+
)
|
|
2222
|
+
_identifier(plan["plan_id"], f"{path}.plan_id")
|
|
2223
|
+
source_ids = _string_array(plan["source_ids"], f"{path}.source_ids", nonempty=True, maximum=256)
|
|
2224
|
+
for index, source_id in enumerate(source_ids):
|
|
2225
|
+
_identifier(source_id, f"{path}.source_ids[{index}]")
|
|
2226
|
+
_string_array(plan["output_grain"], f"{path}.output_grain", nonempty=True, maximum=128)
|
|
2227
|
+
operations = _array(plan["operations"], f"{path}.operations", nonempty=True, maximum=512)
|
|
2228
|
+
operation_ids: list[str] = []
|
|
2229
|
+
available_inputs = set(source_ids)
|
|
2230
|
+
referenced_sources: set[str] = set()
|
|
2231
|
+
for index, operation in enumerate(operations):
|
|
2232
|
+
operation_id, inputs, outputs = _validate_operation(
|
|
2233
|
+
operation, f"{path}.operations[{index}]"
|
|
2234
|
+
)
|
|
2235
|
+
if operation_id in operation_ids:
|
|
2236
|
+
raise RuntimeContractError(f"{path}.operations operation IDs must be unique")
|
|
2237
|
+
operation_ids.append(operation_id)
|
|
2238
|
+
unknown_inputs = set(inputs) - available_inputs
|
|
2239
|
+
if unknown_inputs:
|
|
2240
|
+
raise RuntimeContractError(
|
|
2241
|
+
f"{path}.operations[{index}].inputs has unknown references {sorted(unknown_inputs)}"
|
|
2242
|
+
)
|
|
2243
|
+
referenced_sources.update(set(inputs) & set(source_ids))
|
|
2244
|
+
collisions = set(outputs) & available_inputs
|
|
2245
|
+
if collisions:
|
|
2246
|
+
raise RuntimeContractError(
|
|
2247
|
+
f"{path}.operations[{index}].outputs redefines references {sorted(collisions)}"
|
|
2248
|
+
)
|
|
2249
|
+
available_inputs.update(outputs)
|
|
2250
|
+
_require_unique(operation_ids, f"{path}.operations operation IDs")
|
|
2251
|
+
if referenced_sources != set(source_ids):
|
|
2252
|
+
raise RuntimeContractError(f"{path}.source_ids must all be referenced by operations")
|
|
2253
|
+
rules = _array(
|
|
2254
|
+
plan["validation_rules"],
|
|
2255
|
+
f"{path}.validation_rules",
|
|
2256
|
+
nonempty=True,
|
|
2257
|
+
maximum=512,
|
|
2258
|
+
)
|
|
2259
|
+
rule_ids: list[str] = []
|
|
2260
|
+
for index, value_rule in enumerate(rules):
|
|
2261
|
+
rule_path = f"{path}.validation_rules[{index}]"
|
|
2262
|
+
rule = _object(value_rule, rule_path)
|
|
2263
|
+
_exact_fields(rule, {"rule_id", "kind", "columns", "severity"}, rule_path)
|
|
2264
|
+
rule_ids.append(_identifier(rule["rule_id"], f"{rule_path}.rule_id"))
|
|
2265
|
+
_required_string(rule["kind"], f"{rule_path}.kind", maximum=128)
|
|
2266
|
+
_string_array(rule["columns"], f"{rule_path}.columns", nonempty=True, maximum=128)
|
|
2267
|
+
_enum_string(
|
|
2268
|
+
rule["severity"],
|
|
2269
|
+
f"{rule_path}.severity",
|
|
2270
|
+
frozenset({"blocker", "critical", "high", "medium", "low"}),
|
|
2271
|
+
)
|
|
2272
|
+
_require_unique(rule_ids, f"{path}.validation_rules rule IDs")
|
|
2273
|
+
temporal = _object(plan["temporal_policy"], f"{path}.temporal_policy")
|
|
2274
|
+
_exact_fields(
|
|
2275
|
+
temporal,
|
|
2276
|
+
{"cutoff", "event_time_column", "knowledge_time_column"},
|
|
2277
|
+
f"{path}.temporal_policy",
|
|
2278
|
+
)
|
|
2279
|
+
_canonical_utc_datetime(temporal["cutoff"], f"{path}.temporal_policy.cutoff")
|
|
2280
|
+
event_time_column = _required_string(
|
|
2281
|
+
temporal["event_time_column"],
|
|
2282
|
+
f"{path}.temporal_policy.event_time_column",
|
|
2283
|
+
maximum=128,
|
|
2284
|
+
)
|
|
2285
|
+
knowledge_time_column = _required_string(
|
|
2286
|
+
temporal["knowledge_time_column"],
|
|
2287
|
+
f"{path}.temporal_policy.knowledge_time_column",
|
|
2288
|
+
maximum=128,
|
|
2289
|
+
)
|
|
2290
|
+
if event_time_column == knowledge_time_column:
|
|
2291
|
+
raise RuntimeContractError(f"{path}.temporal_policy time columns must be distinct")
|
|
2292
|
+
|
|
2293
|
+
|
|
2294
|
+
def _validate_semantic_finding(value: Any, path: str) -> str:
|
|
2295
|
+
finding = _object(value, path)
|
|
2296
|
+
_exact_fields(
|
|
2297
|
+
finding,
|
|
2298
|
+
{
|
|
2299
|
+
"evaluator_finding_id",
|
|
2300
|
+
"severity",
|
|
2301
|
+
"category",
|
|
2302
|
+
"summary",
|
|
2303
|
+
"evidence_digest",
|
|
2304
|
+
"affected_artifact",
|
|
2305
|
+
"acceptance_conditions",
|
|
2306
|
+
"code",
|
|
2307
|
+
},
|
|
2308
|
+
path,
|
|
2309
|
+
)
|
|
2310
|
+
finding_id = _required_string(
|
|
2311
|
+
finding["evaluator_finding_id"],
|
|
2312
|
+
f"{path}.evaluator_finding_id",
|
|
2313
|
+
maximum=128,
|
|
2314
|
+
)
|
|
2315
|
+
if not _SCOPE_IDENTIFIER.fullmatch(finding_id):
|
|
2316
|
+
raise RuntimeContractError(f"{path}.evaluator_finding_id is not canonical")
|
|
2317
|
+
code = _required_string(finding["code"], f"{path}.code", maximum=128)
|
|
2318
|
+
if not _FINDING_CODE.fullmatch(code):
|
|
2319
|
+
raise RuntimeContractError(f"{path}.code is not canonical")
|
|
2320
|
+
_enum_string(
|
|
2321
|
+
finding["severity"],
|
|
2322
|
+
f"{path}.severity",
|
|
2323
|
+
frozenset({"blocker", "critical", "high", "medium", "low"}),
|
|
2324
|
+
)
|
|
2325
|
+
_enum_string(
|
|
2326
|
+
finding["category"],
|
|
2327
|
+
f"{path}.category",
|
|
2328
|
+
frozenset(
|
|
2329
|
+
{
|
|
2330
|
+
"source_fitness",
|
|
2331
|
+
"schema",
|
|
2332
|
+
"data_quality",
|
|
2333
|
+
"temporal_integrity",
|
|
2334
|
+
"join_cardinality",
|
|
2335
|
+
"lineage",
|
|
2336
|
+
"rights",
|
|
2337
|
+
"security",
|
|
2338
|
+
"reproducibility",
|
|
2339
|
+
"other",
|
|
2340
|
+
}
|
|
2341
|
+
),
|
|
2342
|
+
)
|
|
2343
|
+
_required_string(finding["summary"], f"{path}.summary", maximum=2_000)
|
|
2344
|
+
evidence_digest = _required_string(
|
|
2345
|
+
finding["evidence_digest"],
|
|
2346
|
+
f"{path}.evidence_digest",
|
|
2347
|
+
maximum=71,
|
|
2348
|
+
)
|
|
2349
|
+
if not _WIRE_SHA256.fullmatch(evidence_digest):
|
|
2350
|
+
raise RuntimeContractError(f"{path}.evidence_digest is not a canonical wire digest")
|
|
2351
|
+
affected = _object(finding["affected_artifact"], f"{path}.affected_artifact")
|
|
2352
|
+
_exact_fields(
|
|
2353
|
+
affected,
|
|
2354
|
+
{"artifact_id", "kind", "content_digest"},
|
|
2355
|
+
f"{path}.affected_artifact",
|
|
2356
|
+
)
|
|
2357
|
+
artifact_id = _required_string(
|
|
2358
|
+
affected["artifact_id"],
|
|
2359
|
+
f"{path}.affected_artifact.artifact_id",
|
|
2360
|
+
maximum=36,
|
|
2361
|
+
)
|
|
2362
|
+
if not _UUID.fullmatch(artifact_id):
|
|
2363
|
+
raise RuntimeContractError(f"{path}.affected_artifact.artifact_id is not canonical")
|
|
2364
|
+
kind = _required_string(
|
|
2365
|
+
affected["kind"],
|
|
2366
|
+
f"{path}.affected_artifact.kind",
|
|
2367
|
+
maximum=64,
|
|
2368
|
+
)
|
|
2369
|
+
if not _ARTIFACT_KIND.fullmatch(kind):
|
|
2370
|
+
raise RuntimeContractError(f"{path}.affected_artifact.kind is not canonical")
|
|
2371
|
+
content_digest = _required_string(
|
|
2372
|
+
affected["content_digest"],
|
|
2373
|
+
f"{path}.affected_artifact.content_digest",
|
|
2374
|
+
maximum=71,
|
|
2375
|
+
)
|
|
2376
|
+
if not _WIRE_SHA256.fullmatch(content_digest):
|
|
2377
|
+
raise RuntimeContractError(
|
|
2378
|
+
f"{path}.affected_artifact.content_digest is not a canonical wire digest"
|
|
2379
|
+
)
|
|
2380
|
+
conditions = _string_array(
|
|
2381
|
+
finding["acceptance_conditions"],
|
|
2382
|
+
f"{path}.acceptance_conditions",
|
|
2383
|
+
nonempty=True,
|
|
2384
|
+
maximum=32,
|
|
2385
|
+
)
|
|
2386
|
+
if any(len(condition) > 1_000 for condition in conditions):
|
|
2387
|
+
raise RuntimeContractError(f"{path}.acceptance_conditions contains oversized text")
|
|
2388
|
+
return finding_id
|
|
2389
|
+
|
|
2390
|
+
|
|
2391
|
+
def _validate_repair(value: Any, path: str) -> None:
|
|
2392
|
+
repair = _object(value, path)
|
|
2393
|
+
_exact_fields(
|
|
2394
|
+
repair,
|
|
2395
|
+
{
|
|
2396
|
+
"repair_id",
|
|
2397
|
+
"finding_ids",
|
|
2398
|
+
"operations",
|
|
2399
|
+
"acceptance_evidence",
|
|
2400
|
+
"successor_required",
|
|
2401
|
+
},
|
|
2402
|
+
path,
|
|
2403
|
+
)
|
|
2404
|
+
_identifier(repair["repair_id"], f"{path}.repair_id")
|
|
2405
|
+
_string_array(repair["finding_ids"], f"{path}.finding_ids", nonempty=True, maximum=256)
|
|
2406
|
+
operations = _array(
|
|
2407
|
+
repair["operations"],
|
|
2408
|
+
f"{path}.operations",
|
|
2409
|
+
nonempty=True,
|
|
2410
|
+
maximum=256,
|
|
2411
|
+
)
|
|
2412
|
+
operation_ids: list[str] = []
|
|
2413
|
+
for index, value_operation in enumerate(operations):
|
|
2414
|
+
operation_path = f"{path}.operations[{index}]"
|
|
2415
|
+
operation = _object(value_operation, operation_path)
|
|
2416
|
+
_exact_fields(
|
|
2417
|
+
operation,
|
|
2418
|
+
{"operation_id", "kind", "target", "parameters"},
|
|
2419
|
+
operation_path,
|
|
2420
|
+
)
|
|
2421
|
+
operation_ids.append(
|
|
2422
|
+
_identifier(
|
|
2423
|
+
operation["operation_id"],
|
|
2424
|
+
f"{operation_path}.operation_id",
|
|
2425
|
+
)
|
|
2426
|
+
)
|
|
2427
|
+
_required_string(operation["kind"], f"{operation_path}.kind", maximum=128)
|
|
2428
|
+
_required_string(operation["target"], f"{operation_path}.target", maximum=512)
|
|
2429
|
+
_object(operation["parameters"], f"{operation_path}.parameters")
|
|
2430
|
+
_require_unique(operation_ids, f"{path}.operations operation IDs")
|
|
2431
|
+
_string_array(
|
|
2432
|
+
repair["acceptance_evidence"],
|
|
2433
|
+
f"{path}.acceptance_evidence",
|
|
2434
|
+
nonempty=True,
|
|
2435
|
+
maximum=128,
|
|
2436
|
+
)
|
|
2437
|
+
if repair["successor_required"] is not True:
|
|
2438
|
+
raise RuntimeContractError(f"{path}.successor_required must be true")
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
def _strict_json_value(value: Any, path: str, *, depth: int, state: list[int]) -> Any:
|
|
2442
|
+
state[0] += 1
|
|
2443
|
+
if state[0] > 4_096:
|
|
2444
|
+
raise RuntimeContractError(f"{path} exceeds the structured payload node limit")
|
|
2445
|
+
if depth > 16:
|
|
2446
|
+
raise RuntimeContractError(f"{path} exceeds the structured payload depth limit")
|
|
2447
|
+
if value is None or type(value) is bool:
|
|
2448
|
+
return value
|
|
2449
|
+
if type(value) is int:
|
|
2450
|
+
if not -MAX_ACCOUNTING_INTEGER <= value <= MAX_ACCOUNTING_INTEGER:
|
|
2451
|
+
raise RuntimeContractError(f"{path} integer is outside the supported range")
|
|
2452
|
+
return value
|
|
2453
|
+
if isinstance(value, str):
|
|
2454
|
+
if len(value) > 100_000:
|
|
2455
|
+
raise RuntimeContractError(f"{path} string exceeds 100000 characters")
|
|
2456
|
+
return value
|
|
2457
|
+
if isinstance(value, Mapping):
|
|
2458
|
+
if len(value) > 1_024 or any(not isinstance(key, str) for key in value):
|
|
2459
|
+
raise RuntimeContractError(f"{path} must be a bounded object with string keys")
|
|
2460
|
+
return {
|
|
2461
|
+
key: _strict_json_value(item, f"{path}.{key}", depth=depth + 1, state=state)
|
|
2462
|
+
for key, item in value.items()
|
|
2463
|
+
}
|
|
2464
|
+
if isinstance(value, (list, tuple)):
|
|
2465
|
+
if len(value) > 1_024:
|
|
2466
|
+
raise RuntimeContractError(f"{path} array exceeds 1024 entries")
|
|
2467
|
+
return [
|
|
2468
|
+
_strict_json_value(item, f"{path}[{index}]", depth=depth + 1, state=state)
|
|
2469
|
+
for index, item in enumerate(value)
|
|
2470
|
+
]
|
|
2471
|
+
raise RuntimeContractError(f"{path} contains unsupported value type {type(value).__name__}")
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def _canonical_json_bytes(value: Any) -> bytes:
|
|
2475
|
+
normalized = _strict_json_value(value, "value", depth=0, state=[0])
|
|
2476
|
+
return (
|
|
2477
|
+
json.dumps(
|
|
2478
|
+
normalized,
|
|
2479
|
+
ensure_ascii=False,
|
|
2480
|
+
allow_nan=False,
|
|
2481
|
+
sort_keys=True,
|
|
2482
|
+
separators=(",", ":"),
|
|
2483
|
+
).encode("utf-8")
|
|
2484
|
+
+ b"\n"
|
|
2485
|
+
)
|
|
2486
|
+
|
|
2487
|
+
|
|
2488
|
+
def _safe_response_hash(value: Any) -> str:
|
|
2489
|
+
try:
|
|
2490
|
+
raw = _canonical_json_bytes(value)
|
|
2491
|
+
except RuntimeContractError:
|
|
2492
|
+
raw = f"<unsupported:{type(value).__module__}.{type(value).__qualname__}>".encode()
|
|
2493
|
+
return _sha256(raw)
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _provider_configuration_digest(adapter: ProviderAdapter) -> str:
|
|
2497
|
+
declared = getattr(adapter, "configuration_sha256", None)
|
|
2498
|
+
if declared is not None:
|
|
2499
|
+
return _digest(declared, "adapter.configuration_sha256")
|
|
2500
|
+
return _sha256(
|
|
2501
|
+
_canonical_json_bytes(
|
|
2502
|
+
{
|
|
2503
|
+
"provider": adapter.provider,
|
|
2504
|
+
"model_identifier": adapter.model_identifier,
|
|
2505
|
+
}
|
|
2506
|
+
)
|
|
2507
|
+
)
|
|
2508
|
+
|
|
2509
|
+
|
|
2510
|
+
def _prompt_digest(request: AgentRequest) -> str:
|
|
2511
|
+
return _sha256(
|
|
2512
|
+
_canonical_json_bytes(
|
|
2513
|
+
{
|
|
2514
|
+
"prompt_template_version": request.prompt_template_version,
|
|
2515
|
+
"payload_schema_version": request.payload_schema_version,
|
|
2516
|
+
"payload": request.payload,
|
|
2517
|
+
}
|
|
2518
|
+
)
|
|
2519
|
+
)
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
def _reject_duplicate_pairs(pairs: Sequence[tuple[str, Any]]) -> dict[str, Any]:
|
|
2523
|
+
result: dict[str, Any] = {}
|
|
2524
|
+
for key, value in pairs:
|
|
2525
|
+
if key in result:
|
|
2526
|
+
raise RuntimeContractError(f"duplicate JSON key: {key!r}")
|
|
2527
|
+
result[key] = value
|
|
2528
|
+
return result
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def _reject_json_constant(value: str) -> Any:
|
|
2532
|
+
raise RuntimeContractError(f"unsupported JSON constant: {value}")
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def _object(value: Any, path: str) -> Mapping[str, Any]:
|
|
2536
|
+
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
|
2537
|
+
raise RuntimeContractError(f"{path} must be an object with string keys")
|
|
2538
|
+
return value
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
def _array(
|
|
2542
|
+
value: Any,
|
|
2543
|
+
path: str,
|
|
2544
|
+
*,
|
|
2545
|
+
nonempty: bool = False,
|
|
2546
|
+
maximum: int,
|
|
2547
|
+
) -> Sequence[Any]:
|
|
2548
|
+
if not isinstance(value, list):
|
|
2549
|
+
raise RuntimeContractError(f"{path} must be an array")
|
|
2550
|
+
if nonempty and not value:
|
|
2551
|
+
raise RuntimeContractError(f"{path} cannot be empty")
|
|
2552
|
+
if len(value) > maximum:
|
|
2553
|
+
raise RuntimeContractError(f"{path} exceeds {maximum} entries")
|
|
2554
|
+
return value
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
def _string_array(
|
|
2558
|
+
value: Any,
|
|
2559
|
+
path: str,
|
|
2560
|
+
*,
|
|
2561
|
+
nonempty: bool,
|
|
2562
|
+
maximum: int,
|
|
2563
|
+
) -> tuple[str, ...]:
|
|
2564
|
+
values = _array(value, path, nonempty=nonempty, maximum=maximum)
|
|
2565
|
+
parsed = tuple(
|
|
2566
|
+
_required_string(item, f"{path}[{index}]", maximum=10_000)
|
|
2567
|
+
for index, item in enumerate(values)
|
|
2568
|
+
)
|
|
2569
|
+
_require_unique(parsed, path)
|
|
2570
|
+
return parsed
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def _exact_fields(
|
|
2574
|
+
data: Mapping[str, Any],
|
|
2575
|
+
required: set[str],
|
|
2576
|
+
path: str,
|
|
2577
|
+
*,
|
|
2578
|
+
optional: set[str] | None = None,
|
|
2579
|
+
) -> None:
|
|
2580
|
+
optional = optional or set()
|
|
2581
|
+
missing = required - data.keys()
|
|
2582
|
+
extra = data.keys() - required - optional
|
|
2583
|
+
if missing or extra:
|
|
2584
|
+
raise RuntimeContractError(
|
|
2585
|
+
f"{path} has invalid fields; missing={sorted(missing)}, extra={sorted(extra)}"
|
|
2586
|
+
)
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
def _required_string(value: Any, path: str, *, maximum: int) -> str:
|
|
2590
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
2591
|
+
raise RuntimeContractError(f"{path} must be nonempty trimmed text")
|
|
2592
|
+
if len(value) > maximum:
|
|
2593
|
+
raise RuntimeContractError(f"{path} exceeds {maximum} characters")
|
|
2594
|
+
if any(ord(char) < 0x20 and char not in "\t\n\r" for char in value):
|
|
2595
|
+
raise RuntimeContractError(f"{path} contains a control character")
|
|
2596
|
+
return value
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def _identifier(value: Any, path: str) -> str:
|
|
2600
|
+
text = _required_string(value, path, maximum=128)
|
|
2601
|
+
if not _IDENTIFIER.fullmatch(text):
|
|
2602
|
+
raise RuntimeContractError(f"{path} must be a canonical lowercase identifier")
|
|
2603
|
+
return text
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
def _version(value: Any, path: str) -> str:
|
|
2607
|
+
text = _required_string(value, path, maximum=128)
|
|
2608
|
+
if not _IDENTIFIER.fullmatch(text) or "." not in text:
|
|
2609
|
+
raise RuntimeContractError(f"{path} must be a canonical version identifier")
|
|
2610
|
+
return text
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
def _digest(value: Any, path: str) -> str:
|
|
2614
|
+
if not isinstance(value, str) or not _SHA256.fullmatch(value):
|
|
2615
|
+
raise RuntimeContractError(f"{path} must be a lowercase SHA-256 digest")
|
|
2616
|
+
return value
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
def _nonnegative_int(value: Any, path: str) -> int:
|
|
2620
|
+
if type(value) is not int or value < 0:
|
|
2621
|
+
raise RuntimeContractError(f"{path} must be a nonnegative integer")
|
|
2622
|
+
return value
|
|
2623
|
+
|
|
2624
|
+
|
|
2625
|
+
def _positive_int(value: Any, path: str) -> int:
|
|
2626
|
+
if type(value) is not int or value < 1:
|
|
2627
|
+
raise RuntimeContractError(f"{path} must be a positive integer")
|
|
2628
|
+
return value
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
def _accounting_int(value: Any, path: str) -> int:
|
|
2632
|
+
_nonnegative_int(value, path)
|
|
2633
|
+
if value > MAX_ACCOUNTING_INTEGER:
|
|
2634
|
+
raise RuntimeContractError(f"{path} exceeds the signed 64-bit accounting range")
|
|
2635
|
+
return value
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
def _positive_accounting_int(value: Any, path: str) -> int:
|
|
2639
|
+
_positive_int(value, path)
|
|
2640
|
+
if value > MAX_ACCOUNTING_INTEGER:
|
|
2641
|
+
raise RuntimeContractError(f"{path} exceeds the signed 64-bit accounting range")
|
|
2642
|
+
return value
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
def _provider_accounting(value: Any, path: str) -> tuple[int, bool]:
|
|
2646
|
+
_nonnegative_int(value, f"provider.{path}")
|
|
2647
|
+
return min(value, MAX_ACCOUNTING_INTEGER), value > MAX_ACCOUNTING_INTEGER
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
def _checked_add(left: int, right: int, path: str) -> int:
|
|
2651
|
+
result = left + right
|
|
2652
|
+
if result > MAX_ACCOUNTING_INTEGER:
|
|
2653
|
+
raise RuntimeContractError(f"{path} exceeds the signed 64-bit accounting range")
|
|
2654
|
+
return result
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
def _require_unique(values: Sequence[str] | Any, path: str) -> None:
|
|
2658
|
+
sequence = tuple(values)
|
|
2659
|
+
if len(sequence) != len(set(sequence)):
|
|
2660
|
+
raise RuntimeContractError(f"{path} must be unique")
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
def _sha256(value: bytes) -> str:
|
|
2664
|
+
return hashlib.sha256(value).hexdigest()
|
|
2665
|
+
|
|
2666
|
+
|
|
2667
|
+
def _stable_failure_code(error: Exception) -> str:
|
|
2668
|
+
text = str(error)
|
|
2669
|
+
for candidate in (
|
|
2670
|
+
"input_tokens_per_call",
|
|
2671
|
+
"output_tokens_per_call",
|
|
2672
|
+
"total_tokens_per_call",
|
|
2673
|
+
"cost_micros_per_call",
|
|
2674
|
+
"time_micros_per_call",
|
|
2675
|
+
"input_tokens_per_run",
|
|
2676
|
+
"output_tokens_per_run",
|
|
2677
|
+
"total_tokens_per_run",
|
|
2678
|
+
"cost_micros_per_run",
|
|
2679
|
+
"time_micros_per_run",
|
|
2680
|
+
"calls",
|
|
2681
|
+
):
|
|
2682
|
+
if candidate in text:
|
|
2683
|
+
return f"{candidate}_exhausted"
|
|
2684
|
+
return "budget_reservation_denied"
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
def _after_budget_code(exceeded: Sequence[str]) -> str:
|
|
2688
|
+
return f"{sorted(exceeded)[0]}_exceeded"
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _monotonic_micros() -> int:
|
|
2692
|
+
import time
|
|
2693
|
+
|
|
2694
|
+
return time.monotonic_ns() // 1_000
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
__all__ = [
|
|
2698
|
+
"AGENT_EVIDENCE_VERSION",
|
|
2699
|
+
"AGENT_REQUEST_VERSION",
|
|
2700
|
+
"AGENT_RESPONSE_VERSION",
|
|
2701
|
+
"AGENT_ROLES",
|
|
2702
|
+
"AGENT_RUNTIME_VERSION",
|
|
2703
|
+
"MAX_ACCOUNTING_INTEGER",
|
|
2704
|
+
"REPLAY_CATALOG_VERSION",
|
|
2705
|
+
"ROLE_PAYLOAD_SCHEMAS",
|
|
2706
|
+
"SEMANTIC_REVIEW_INPUT_VERSION",
|
|
2707
|
+
"SEMANTIC_REVIEW_OUTPUT_VERSION",
|
|
2708
|
+
"TOOL_POLICY_VERSION",
|
|
2709
|
+
"AgentCallCancelled",
|
|
2710
|
+
"AgentCallEvidence",
|
|
2711
|
+
"AgentCallFailure",
|
|
2712
|
+
"AgentCallResult",
|
|
2713
|
+
"AgentCallTimedOut",
|
|
2714
|
+
"AgentRequest",
|
|
2715
|
+
"AgentResponse",
|
|
2716
|
+
"AgentRuntime",
|
|
2717
|
+
"BudgetExceededAfterCall",
|
|
2718
|
+
"BudgetExceededBeforeCall",
|
|
2719
|
+
"BudgetLedger",
|
|
2720
|
+
"BudgetLimits",
|
|
2721
|
+
"BudgetSnapshot",
|
|
2722
|
+
"MalformedProviderResponse",
|
|
2723
|
+
"ProviderAdapter",
|
|
2724
|
+
"ProviderAgentRuntime",
|
|
2725
|
+
"ProviderCallFailed",
|
|
2726
|
+
"ProviderResult",
|
|
2727
|
+
"ProviderTimeoutError",
|
|
2728
|
+
"ReplayAgentRuntime",
|
|
2729
|
+
"ReplayAmbiguityError",
|
|
2730
|
+
"ReplayError",
|
|
2731
|
+
"ReplayMissError",
|
|
2732
|
+
"ReplayTamperError",
|
|
2733
|
+
"ReplayVersionError",
|
|
2734
|
+
"RuntimeContractError",
|
|
2735
|
+
"SecretReference",
|
|
2736
|
+
"TimingEvidence",
|
|
2737
|
+
"ToolCallEvidence",
|
|
2738
|
+
"ToolInvocation",
|
|
2739
|
+
"ToolPolicy",
|
|
2740
|
+
"ToolPolicyDenied",
|
|
2741
|
+
"ToolPolicyError",
|
|
2742
|
+
"Usage",
|
|
2743
|
+
]
|