"""Shared resumable analysis dispatch used by trials and the service worker.""" import os from .analysis import (CaseInterpreter, HypothesisBuilder, HypothesisConfig, Investigator, DiagnosisBuilder, ChangeRequestBuilder, OutputValidator) from .analysis.correction import CorrectionConfig from .case import CaseBuilder from .lifecycle import State from .llm import GatewayConfig, LLMGateway from .llm.gateway import PRIVACY from .package import PackageReader class TrialError(Exception): def __init__(self, code): self.code = code super().__init__(code) def preflight(path): """Validate transport configuration and credentials without sending requests.""" config = GatewayConfig.load(path) gateway = LLMGateway(config) eligible = [p for p in config.providers if p.enabled and p.privacy in PRIVACY[config.privacy_profile]] if not eligible: raise TrialError("LLM_PRIVACY_NO_PROVIDER") for provider in eligible: if provider.api_key_env: value = os.environ.get(provider.api_key_env) if not value or any(ord(c) < 32 or ord(c) == 127 for c in value): raise TrialError("LLM_CREDENTIALS") return gateway def describe(path, status, *, code=None, exported=None, retry_not_before=None): with PackageReader(path) as reader: m = reader.manifest return {"status": status, "code": code, "package": str(path), "case_id": m["case_id"], "package_id": m["package_id"], "package_generation": m["package_generation"], "analysis_revision": m["analysis_revision"], "state": m["state"], "routing": m["routing"], "exported": str(exported) if exported else None, "retry_not_before": retry_not_before} async def drive(store, gateway, case_id, package_id, *, max_dispatches=100, attempt_id="default", policies=None, progress=None, stop_after=None): if type(max_dispatches) is not int or max_dispatches <= 0: raise TrialError("TRIAL_DISPATCH_LIMIT") if stop_after not in {None, "case", "interpretation"}: raise TrialError("TRIAL_STOP_PHASE") policies = policies or {} identity = (case_id, package_id) correction = policies.get("correction", CorrectionConfig()) from .tools import InvestigationToolRouter project_research = policies.get("project_research") interpreter = CaseInterpreter(store, gateway, correction_config=correction, project_research=project_research, joint_config=policies.get("analysis", HypothesisConfig()) if policies.get("planning_mode") == "joint" else None) hypotheses = HypothesisBuilder(store, gateway, policies.get("analysis"), correction_config=correction) loop = Investigator(store, gateway, policies.get("investigation"), budget_config=policies.get("budget"), router=InvestigationToolRouter(project_research=project_research)) diagnosis = DiagnosisBuilder(store, gateway, policies.get("diagnosis")) request = ChangeRequestBuilder(store, gateway, policies.get("change_request")) output = OutputValidator(store, gateway) for dispatch in range(max_dispatches): latest = store.latest(*identity) if latest is None: raise TrialError("CASE_NOT_FOUND") with PackageReader(latest, store.limits) as reader: state = reader.manifest["state"] artifacts = dict(reader.manifest["artifacts"]) routing = reader.manifest["routing"] # A reopened case retains prior artifacts. They are history, not # completed stages of the new revision. revision = reader.manifest['analysis_revision'] from .lifecycle.checkpoints import checkpoint_chain chain = checkpoint_chain(reader) for pointer, step in ( ('case', f'case-builder:v1:revision:{revision}:complete'), ('case_interpretation', f'case-interpreter:v1:revision:{revision}:complete'), ('hypotheses', f'hypothesis-builder:v1:revision:{revision}:complete'), ('diagnosis', f'diagnosis:v1:{revision}:complete'), ('change_request', f'change-request:v1:{revision}:complete')): if artifacts.get(pointer) and not any(cp['step_id'] == step for _, cp in chain): # Contracts identify current products even when newer phase # implementations use a different completion step name. import json doc = json.loads(reader.read_bytes(artifacts[pointer])) artifact_identity = doc.get('case', doc) if artifact_identity.get('analysis_revision') != revision: artifacts[pointer] = None if state == State.BLOCKED: return describe(latest, "BLOCKED", code=routing["reason"], retry_not_before=routing["retry_not_before"]) if phase_complete(latest, stop_after): return describe(latest, "PHASE_COMPLETE") if progress: progress({"dispatch": dispatch + 1, "state": state}) if state in {State.OUTPUT_READY, State.FAILED_TO_ANALYZE, State.HANDED_OFF}: result = await output.publish(*identity) elif state == State.BUILDING_CASE and artifacts.get("case"): result = await interpreter.interpret(*identity, attempt_id=attempt_id) elif state in {State.VALIDATED, State.REOPENED, State.BUILDING_CASE}: result = CaseBuilder(store, project_research=project_research).build(*identity) elif state == State.INTERPRETING_CASE: if artifacts.get("case_interpretation"): result = await hypotheses.build(*identity, attempt_id=attempt_id) else: result = await interpreter.interpret(*identity, attempt_id=attempt_id) elif state == State.GENERATING_HYPOTHESES: if artifacts.get("hypotheses"): result = await loop.step(*identity) else: result = await hypotheses.build(*identity, attempt_id=attempt_id) elif state == State.INVESTIGATING_HYPOTHESES: result = await loop.step(*identity) elif state == State.BUILDING_DIAGNOSIS: if not artifacts.get("diagnosis"): result = await diagnosis.build(*identity) elif not artifacts.get("change_request"): result = await request.build(*identity) else: result = await output.publish(*identity) else: raise TrialError("TRIAL_PHASE") status = getattr(result, "status", "COMPLETED") if status == "INCONCLUSIVE": result = await output.publish(*identity) status = result.status if status in {"BLOCKED", "RETRY_PENDING", "DEFERRED", "ERROR", "BUSY", "OUTPUT_READY", "REVIEW", "HANDED_OFF"}: return describe(result.package, status, code=getattr(result, "code", None), exported=getattr(result, "exported", None), retry_not_before=getattr(result, "retry_not_before", None)) latest = store.latest(*identity) return describe(latest, "PHASE_COMPLETE" if phase_complete(latest, stop_after) else "YIELDED") def phase_complete(path, stop_after): if stop_after is None: return False from .case.contracts import read_json with PackageReader(path) as reader: manifest = reader.manifest if stop_after == "case": ref = manifest["artifacts"].get("case") if manifest["state"] != State.BUILDING_CASE or not ref: return False doc = read_json(reader, ref, "case", 8 * 1024 * 1024)["case"] else: ref = manifest["artifacts"].get("case_interpretation") if manifest["state"] != State.INTERPRETING_CASE or not ref: return False doc = read_json(reader, ref, "case-interpretation", 2 * 1024 * 1024) return all(doc[field] == manifest[field] for field in ("case_id", "package_id", "analysis_revision"))