830 lines
61 KiB
Python
830 lines
61 KiB
Python
from viano_bug_analysis.telemetry.decorators import observed
|
|
import copy
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from viano_bug_analysis.budget import BudgetConfig,BudgetManager,BudgetError
|
|
from viano_bug_analysis.budget.engine import apply as budget_apply,TERMINAL
|
|
from viano_bug_analysis.case import CaseAssembler
|
|
from viano_bug_analysis.case.contracts import read_json,validate
|
|
from viano_bug_analysis.case.errors import CaseBuildError
|
|
from viano_bug_analysis.evidence import EvidenceStore,EvidenceError
|
|
from viano_bug_analysis.evidence.store import digest
|
|
from viano_bug_analysis.lifecycle import State,LifecycleError
|
|
from viano_bug_analysis.lifecycle.async_tests import AsyncTestCoordinator
|
|
from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain
|
|
from viano_bug_analysis.lifecycle.errors import LockBusy
|
|
from viano_bug_analysis.lifecycle.locking import FileLock
|
|
from viano_bug_analysis.lifecycle.storage import json_bytes,key
|
|
from viano_bug_analysis.llm import CaseLLMRunner,LLMError
|
|
from viano_bug_analysis.package import PackageReader,PackageReadError
|
|
from viano_bug_analysis.package.validator import ARCHIVE_ERRORS
|
|
from viano_bug_analysis.package.manifest import parse_manifest
|
|
from viano_bug_analysis.tools import InvestigationToolRouter,ToolError
|
|
from viano_bug_analysis.tools.context import QueryContext
|
|
|
|
from .investigation_config import InvestigationConfig,InvestigationLimits,InvestigationError
|
|
from .investigation_prompt import build_prompt
|
|
from .investigation_summary import render_investigation
|
|
|
|
PROVIDER_CONFIGURATION_ERRORS={"LLM_CREDENTIALS","LLM_HTTP_CONFIGURATION","LLM_ADAPTER","LLM_PRIVACY_NO_PROVIDER","LLM_PROVIDER_ERROR"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class InvestigationResult:
|
|
status: str
|
|
package: Path
|
|
state_ref: str | None = None
|
|
code: str | None = None
|
|
retry_not_before: str | None = None
|
|
|
|
|
|
class Investigator:
|
|
"""One durable model decision per step; no worker lock spans a model call."""
|
|
|
|
def __init__(self,store,gateway,config=None,limits=None,*,router=None,budget_config=None,clock=None):
|
|
self.store,self.gateway=store,gateway
|
|
self.config=config or InvestigationConfig()
|
|
self.config_explicit=config is not None
|
|
self.limits=limits or InvestigationLimits()
|
|
self.router=router or InvestigationToolRouter()
|
|
self.budget_config=budget_config
|
|
self.clock=clock or (lambda:datetime.now(timezone.utc))
|
|
self.runner=CaseLLMRunner(store,gateway,clock=self.clock)
|
|
self.evidence=EvidenceStore(store,router=self.router)
|
|
self.tests=AsyncTestCoordinator(store)
|
|
|
|
@observed("investigation_run")
|
|
async def run(self,case_id,package_id,*,max_steps=None,retry_id=None):
|
|
count=self.config.max_steps_per_dispatch if max_steps is None else max_steps
|
|
if max_steps is None:
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:
|
|
saved=self._load(reader)
|
|
if saved:count=saved["policy"]["max_steps_per_dispatch"]
|
|
if type(count) is not int or count<=0:
|
|
raise InvestigationError("INVESTIGATION_DISPATCH_LIMIT")
|
|
for _ in range(count):
|
|
result=await self.step(case_id,package_id,retry_id=retry_id)
|
|
if result.status not in {"PROGRESSED","REJECTED"}:
|
|
return result
|
|
return InvestigationResult("YIELDED",result.package,result.state_ref)
|
|
|
|
@observed("investigation_step")
|
|
async def step(self,case_id,package_id,*,retry_id=None):
|
|
if retry_id is not None and (not isinstance(retry_id,str) or not retry_id.strip()):
|
|
raise InvestigationError("INVESTIGATION_RETRY_ID")
|
|
try:
|
|
with FileLock(self.store.workspace.runtime/"locks"/f"investigation-{key(case_id)}.lock"):
|
|
return await self._step(case_id,package_id,retry_id)
|
|
except LockBusy:
|
|
raise
|
|
except (CaseBuildError,BudgetError,EvidenceError,LifecycleError,LLMError,ToolError,PackageReadError) as error:
|
|
raise InvestigationError(getattr(error,"code","INVESTIGATION_INPUT")) from None
|
|
except ARCHIVE_ERRORS+(ValueError,TypeError,KeyError,IndexError,RecursionError):
|
|
raise InvestigationError("INVESTIGATION_INPUT") from None
|
|
|
|
def _latest(self,case_id,package_id):
|
|
path=self.store.latest(case_id,package_id)
|
|
if path is None: raise InvestigationError("INVESTIGATION_CASE_NOT_FOUND")
|
|
return path
|
|
|
|
def _read(self,reader,ref,contract):
|
|
return read_json(reader,ref,contract,self.limits.max_json_bytes)
|
|
|
|
def _load(self,reader):
|
|
previous=None; state=None; sequence=0
|
|
revision=reader.manifest["analysis_revision"]
|
|
for _,cp in reversed(checkpoint_chain(reader)):
|
|
if cp["analysis_revision"]!=revision or cp["details"].get("operation")!="investigation-state":continue
|
|
sequence+=1
|
|
if sequence>self.limits.max_state_checkpoints:raise InvestigationError("INVESTIGATION_HISTORY_LIMIT")
|
|
details=cp["details"]
|
|
current=self._read(reader,details["state_ref"],"investigation-state")
|
|
if (current["sequence"]!=sequence or current["parent_ref"]!=previous
|
|
or digest(current)!=details["state_sha256"]
|
|
or any(current[f]!=reader.manifest[f] for f in ("case_id","package_id","analysis_revision"))):
|
|
raise InvestigationError("INVESTIGATION_HISTORY_INVALID")
|
|
if current["active"]:
|
|
context=self._read(reader,current["active"]["context_ref"],"investigation-context")
|
|
active=current["active"]
|
|
if (digest(context)!=active["context_sha256"] or context["selected_hypothesis"]!=active["hypothesis_id"]
|
|
or any(context["case"]["case"][f]!=current[f] for f in ("case_id","package_id","analysis_revision"))
|
|
or (active["phase"]=="executing")!=(active["decision"] is not None)):
|
|
raise InvestigationError("INVESTIGATION_CONTEXT_CHANGED")
|
|
previous=details["state_ref"];state=current
|
|
pointer=reader.manifest["artifacts"].get("investigation_state")
|
|
if state and pointer!=previous:raise InvestigationError("INVESTIGATION_POINTER_CHANGED")
|
|
if not state and pointer:
|
|
old=self._read(reader,pointer,"investigation-state")
|
|
if old["analysis_revision"]>=revision or any(old[f]!=reader.manifest[f] for f in ("case_id","package_id")):
|
|
raise InvestigationError("INVESTIGATION_HISTORY_MISSING")
|
|
if state:
|
|
for hid in state['hypotheses']:
|
|
self._brief(reader, state, hid)
|
|
session=state['hypotheses'][hid].get('lean_session',{})
|
|
seen=set()
|
|
for turn in session.get('turns',[]):
|
|
call=self._read(reader,turn['llm_ref'],'llm-call')
|
|
with reader._archive.open(call['request_ref']) as stream:
|
|
request=parse_manifest(reader.validator._read_bounded(stream,self.limits.max_json_bytes))
|
|
message=next((m for m in request['messages'] if m['role']=='user'),None)
|
|
if message is None:raise InvestigationError('INVESTIGATION_SESSION_BINDING')
|
|
live=parse_manifest(message['content'].encode('utf-8'))['input']
|
|
if not isinstance(live,dict) or not isinstance(live.get('evidence'),dict):
|
|
raise InvestigationError('INVESTIGATION_SESSION_BINDING')
|
|
if (turn['llm_ref'] in seen or call['status']!='SUCCESS' or call['phase']!=State.INVESTIGATING_HYPOTHESES
|
|
or any(call[k]!=state[k] for k in ('case_id','package_id','analysis_revision'))
|
|
or live.get('hypothesis',{}).get('id')!=hid
|
|
or live.get('previous_result')!=turn['evidence_id']
|
|
or sorted(live['evidence'])!=turn.get('read_ids',[])
|
|
or call['outcome']['document'].get('observation')!=turn['observation']):
|
|
raise InvestigationError('INVESTIGATION_SESSION_BINDING')
|
|
seen.add(turn['llm_ref'])
|
|
return state
|
|
|
|
def _brief(self, reader, state, hid):
|
|
hypothesis = state['hypotheses'][hid]
|
|
ref = hypothesis.get('working_brief_ref')
|
|
if ref is None:
|
|
return None
|
|
brief = self._read(reader, ref, 'investigation-working-brief-record')
|
|
if (digest(brief) != hypothesis['working_brief_sha256']
|
|
or brief['hypothesis_id'] != hid
|
|
or any(brief[k] != state[k] for k in ('case_id','package_id','analysis_revision'))):
|
|
raise InvestigationError('INVESTIGATION_BRIEF_BINDING')
|
|
return brief
|
|
|
|
def _save(self,state,*,context=None,summary=None,target=State.INVESTIGATING_HYPOTHESES,artifact_updates=None,additions=None):
|
|
state=copy.deepcopy(state)
|
|
path=self._latest(state["case_id"],state["package_id"])
|
|
with PackageReader(path,self.store.limits) as reader:
|
|
previous=self._load(reader)
|
|
if (previous["sequence"] if previous else 0)!=state["sequence"]:
|
|
raise InvestigationError("INVESTIGATION_GENERATION_CHANGED")
|
|
state["parent_ref"]=reader.manifest["artifacts"].get("investigation_state") if previous else None
|
|
state["sequence"]+=1
|
|
if context is not None:
|
|
validate(context,"investigation-context")
|
|
state["active"]["context_sha256"]=digest(context)
|
|
if state["sequence"]>self.limits.max_state_checkpoints:raise InvestigationError("INVESTIGATION_HISTORY_LIMIT")
|
|
ref=f"analysis/investigation/{state['analysis_revision']:04d}/states/{state['sequence']:08d}.json"
|
|
validate(state,"investigation-state")
|
|
changes={ref:json_bytes(state),**(additions or {})}
|
|
if summary is not None:changes[state["summary_ref"]]=summary.encode("utf-8")
|
|
if context is not None:
|
|
changes[state["active"]["context_ref"]]=json_bytes(context)
|
|
if any(len(data)>self.limits.max_json_bytes for data in changes.values()):raise InvestigationError("INVESTIGATION_STATE_LIMIT")
|
|
details={"operation":"investigation-state","state_ref":ref,"state_sha256":digest(state),
|
|
"context_ref":state["active"]["context_ref"] if state["active"] else None,"summary_ref":state["summary_ref"]}
|
|
brief_refs = [h.get('working_brief_ref') for h in state['hypotheses'].values()
|
|
if h.get('working_brief_ref') in changes]
|
|
if brief_refs:
|
|
details['working_brief_ref'] = brief_refs[0]
|
|
if reader.manifest['state']==State.BUILDING_DIAGNOSIS and target==State.INVESTIGATING_HYPOTHESES:
|
|
details['early_diagnosis_declined']=True
|
|
details['early_review_ref']=state['early_stop']['review_ref']
|
|
result=self.store.advance(state["case_id"],state["package_id"],target,
|
|
step_id=f"investigation:v1:{state['analysis_revision']}:state:{state['sequence']}",
|
|
additions=changes,details=details,artifacts={"investigation_state":ref,"investigation_summary":state["summary_ref"],**(artifact_updates or {})},expected_generation=reader.manifest["package_generation"])
|
|
return state,InvestigationResult("PROGRESSED",result,ref)
|
|
|
|
def _budget(self,case_id,package_id):
|
|
budget=BudgetManager(self.store,self.budget_config)
|
|
budget.initialize(case_id,package_id)
|
|
if self.budget_config is None:
|
|
snap=budget.snapshot(case_id,package_id)
|
|
with PackageReader(snap.package,self.store.limits) as reader:
|
|
policy=self._read(reader,snap.event_ref,"budget-event")["policy"]
|
|
budget=BudgetManager(self.store,BudgetConfig(**{k:v for k,v in policy.items() if k not in {"format","format_version","unit"}}))
|
|
return budget
|
|
|
|
def _begin(self,reader):
|
|
hypotheses=self._read(reader,reader.manifest["artifacts"].get("hypotheses"),"hypotheses")
|
|
initial={}
|
|
for h in hypotheses["hypotheses"]:
|
|
initial[h["id"]]={"proposal":h["proposal"],"confidence":"unassessed","evidence_refs":[],"notes":[],
|
|
"questions":[{"id":f"Q{i+1:04d}","text":text,"resolved":False} for i,text in enumerate(h["proposal"]["open_questions"])],
|
|
"disposition":"active","iterations":0,"invalid_responses":0,"last_record_ref":None}
|
|
return {"format":"viano-investigation-state","format_version":1,
|
|
**{f:reader.manifest[f] for f in ("case_id","package_id","analysis_revision")},
|
|
"sequence":0,"parent_ref":None,**({"prompt_protocol":2} if self.router.project_research is not None else {}),"policy":asdict(self.config),"hypotheses":initial,"cursor":0,"active":None,"completed":False,
|
|
"summary_ref":None,"countercheck_complete":False,"early_stop_protocol":1}
|
|
|
|
def _context(self,state,hid,budget):
|
|
snapshot=budget.snapshot(state["case_id"],state["package_id"])
|
|
refs=self.evidence.snapshot(state["case_id"],state["package_id"]).record_refs
|
|
with PackageReader(snapshot.package,self.store.limits) as reader:
|
|
case=self._read(reader,reader.manifest["artifacts"]["case"],"case")
|
|
if CaseAssembler(self.router.case_limits).assemble(reader)["inputs"]["request_hash"]!=case["inputs"]["request_hash"]:
|
|
raise InvestigationError("INVESTIGATION_STALE_CASE")
|
|
interpretation=self._read(reader,reader.manifest["artifacts"]["case_interpretation"],"case-interpretation")
|
|
interpretation_context = (self._read(reader,interpretation["context_ref"],"case-interpretation-context")
|
|
if state.get("prompt_protocol") == 2 else None)
|
|
if interpretation_context is not None and digest(interpretation_context) != interpretation["context_sha256"]:
|
|
raise InvestigationError("INVESTIGATION_CONTEXT_CHANGED")
|
|
evidence={}
|
|
for ref in refs:
|
|
record=self._read(reader,ref,"evidence-record")
|
|
if record["analysis_revision"]==state["analysis_revision"]:
|
|
evidence[record["id"]]={"record_ref":ref,"record":record,"content":self._read(reader,record["content_ref"],"evidence-content")}
|
|
ledger=self._read(reader,snapshot.event_ref,"budget-event")
|
|
last=state["hypotheses"][hid]["last_record_ref"]
|
|
previous=next((entry for entry in evidence.values() if entry["record_ref"]==last),None)
|
|
context={"format":"viano-investigation-context","format_version":1,"case":case,
|
|
"interpretation":interpretation["interpretation"],"hypotheses":state["hypotheses"],"selected_hypothesis":hid,
|
|
"budget":snapshot.state,"budget_policy":ledger["policy"],"previous_result":previous,
|
|
"evidence":evidence,"policy":state["policy"],"tool_schema":self.router.request_schema()}
|
|
if state.get("prompt_protocol") == 2:
|
|
context["prompt_version"] = "investigation_project_v2"
|
|
context["case_sources"] = interpretation_context["sources"]
|
|
if self.router.project_research is not None:
|
|
service = self.router.project_research
|
|
service.check_project(case["case"]["project"])
|
|
context["tool_schema"]["x-project-bindings"] = {
|
|
"source":service.source.binding() if service.source else None,
|
|
"database":service.database.binding() if service.database else None,
|
|
"query_schema":service.query_schema(), "max_result_bytes":service.limits.max_result_bytes,
|
|
"max_rows":service.limits.max_rows, "max_lines":service.limits.max_lines}
|
|
if service.relations:
|
|
from viano_bug_analysis.tools.database_relations import extend_tool_schema
|
|
context['tool_schema']['x-project-bindings']['relation_profiles'] = service.relations.catalog()
|
|
context['tool_schema']['x-project-bindings']['relation_preference'] = (
|
|
'Prefer a matching database.relations profile to separate schema/row/link calls. '
|
|
'Use primitive tools for missing details. Complete means only configured edges; '
|
|
'upstream truncation invalidates downstream absence claims.')
|
|
items = context['tool_schema']['properties']['requests']['items']
|
|
context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items)
|
|
if service.source_context:
|
|
from viano_bug_analysis.tools.source_context import extend_tool_schema
|
|
context['tool_schema']['x-project-bindings']['source_analysis'] = service.source_context.catalog()
|
|
items = context['tool_schema']['properties']['requests']['items']
|
|
context['tool_schema']['properties']['requests']['items'] = extend_tool_schema(items, service.source_context.config.protocol)
|
|
if service.relations or service.source_context:
|
|
context['tool_guidance_version'] = 1
|
|
else:
|
|
variants=context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]
|
|
context["tool_schema"]["properties"]["requests"]["items"]["oneOf"]=[
|
|
v for v in variants if v["properties"]["operation"].get("const")!="project.query"]
|
|
if state.get('early_stop_protocol')==1:
|
|
context['prompt_version']='investigation_project_v18' if self.router.project_research is not None else 'investigation_v3'
|
|
context['early_review']={'attempts':state.get('early_stop_attempts',0),
|
|
'limit':state['policy'].get('max_early_reviews',3),'request':state.get('early_stop')}
|
|
if context.get('prompt_version') in {'investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
with PackageReader(snapshot.package, self.store.limits) as reader:
|
|
brief = self._brief(reader, state, hid)
|
|
if brief is not None:
|
|
context['working_brief'] = brief
|
|
if context['prompt_version'] in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
with PackageReader(snapshot.package,self.store.limits) as reader:
|
|
context['other_working_briefs']={}
|
|
for other in state['hypotheses']:
|
|
if other!=hid:
|
|
saved=self._brief(reader,state,other)
|
|
if saved is not None:context['other_working_briefs'][other]=saved
|
|
context['recalled_evidence']=state.get('recalled_evidence',{}).get(hid,[])
|
|
if context.get('prompt_version') in {'investigation_project_v8','investigation_project_v9'}:
|
|
context['recalled_evidence'] = state.get('recalled_evidence', {}).get(hid, [])
|
|
if state['policy'].get('lean_loop'):
|
|
if self.router.project_research is None:raise InvestigationError('INVESTIGATION_LEAN_REQUIRES_PROJECT')
|
|
if state['policy'].get('lean_protocol',1)==1 or state['hypotheses'][hid]['iterations']>0:
|
|
context['lean_session']=copy.deepcopy(state['hypotheses'][hid].get('lean_session',{'turns':[],'checkpoint':False}))
|
|
if state['policy'].get('lean_protocol',1)==2:context['lean_protocol']=2
|
|
elif state['policy'].get('bootstrap_json_framing'):
|
|
context['bootstrap_json_framing']=True
|
|
if state['policy'].get('bootstrap_framing_version',1)==2:
|
|
context['bootstrap_framing_version']=2
|
|
if state.get('pending_format_notice'):
|
|
context['format_notice']=state.pop('pending_format_notice')
|
|
if len(json_bytes(context))>self.limits.max_context_bytes:raise InvestigationError("INVESTIGATION_CONTEXT_LIMIT")
|
|
validate(context,"investigation-context")
|
|
return context
|
|
|
|
async def _step(self,case_id,package_id,retry_id):
|
|
path=self._latest(case_id,package_id)
|
|
with PackageReader(path,self.store.limits) as reader:
|
|
manifest=reader.manifest;state=self._load(reader)
|
|
if manifest["state"]==State.BLOCKED:
|
|
return InvestigationResult("BLOCKED",path,manifest["artifacts"].get("investigation_state"),manifest["routing"]["reason"],manifest["routing"]["retry_not_before"])
|
|
if manifest["state"] in {State.GENERATING_HYPOTHESES,State.INVESTIGATING_HYPOTHESES,State.BUILDING_DIAGNOSIS}:
|
|
case=self._read(reader,manifest["artifacts"].get("case"),"case")
|
|
if CaseAssembler(self.router.case_limits).assemble(reader)["inputs"]["request_hash"]!=case["inputs"]["request_hash"]:
|
|
raise InvestigationError("INVESTIGATION_STALE_CASE")
|
|
if state and state["completed"]:
|
|
if manifest["artifacts"].get("investigation_summary")!=state["summary_ref"]:
|
|
raise InvestigationError("INVESTIGATION_SUMMARY_CHANGED")
|
|
expected=render_investigation(state,BudgetManager(self.store).snapshot(case_id,package_id).state).encode()
|
|
if reader.read_bytes(state["summary_ref"])!=expected:raise InvestigationError("INVESTIGATION_SUMMARY_CHANGED")
|
|
return InvestigationResult("COMPLETED",path,manifest["artifacts"]["investigation_state"])
|
|
if manifest["state"] not in {State.GENERATING_HYPOTHESES,State.INVESTIGATING_HYPOTHESES}:
|
|
raise InvestigationError("INVESTIGATION_PHASE")
|
|
budget=self._budget(case_id,package_id)
|
|
if state is None:
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:state=self._begin(reader)
|
|
state,_=self._save(state)
|
|
effective_policy={**asdict(InvestigationConfig()),**state['policy']}
|
|
if state['policy'].get('bootstrap_json_framing') and 'bootstrap_framing_version' not in state['policy']:
|
|
effective_policy['bootstrap_framing_version']=1
|
|
if self.config_explicit and effective_policy!=asdict(self.config):raise InvestigationError("INVESTIGATION_POLICY_CHANGED")
|
|
if state["active"] and state["active"]["phase"]=="executing":return self._execute_plan(state,budget)
|
|
if state["active"] is None:
|
|
state.setdefault('early_stop_protocol',1)
|
|
if self.router.project_research is not None:state.setdefault('prompt_protocol',2)
|
|
snapshot=budget.snapshot(case_id,package_id)
|
|
from .early_stop import pending_accounts
|
|
pending=pending_accounts(snapshot.state)
|
|
early=state.get('early_stop',{})
|
|
if early.get('status')=='preparing' and not pending:
|
|
early['status']='requested'
|
|
early['deferred_hypotheses']=[hid for hid,h in state['hypotheses'].items()
|
|
if hid!=early['hypothesis_id'] and h['disposition']=='active']
|
|
state['completed']=True;state['countercheck_complete']=True
|
|
state['summary_ref']=f"analysis/investigation/{state['analysis_revision']:04d}/early-{early['number']:04d}.md"
|
|
state,result=self._save(state,summary=render_investigation(state,snapshot.state),target=State.BUILDING_DIAGNOSIS)
|
|
return InvestigationResult('COMPLETED',result.package,result.state_ref)
|
|
ids=sorted(state["hypotheses"],key=lambda value:int(value[1:]))
|
|
selected=None
|
|
from .investigation_scheduler import priority_order
|
|
ordered=priority_order(state['hypotheses'])
|
|
preferred=state.get('lean_preferred_hypothesis')
|
|
if preferred:ordered.sort(key=lambda entry:entry[1]!=preferred)
|
|
if early.get('status')=='preparing':
|
|
ordered=[entry for entry in ordered if entry[1] in pending]
|
|
for pos,hid in ordered:
|
|
h=state["hypotheses"][hid];account=snapshot.state["accounts"][hid]
|
|
if h["disposition"]!="active":continue
|
|
if any(a["status"]!="settled" for a in account["actions"].values()) and h["last_record_ref"] is None:
|
|
raise InvestigationError("INVESTIGATION_UNOWNED_PENDING_ACTION")
|
|
if account["status"] in TERMINAL:
|
|
h["disposition"]=account["status"];continue
|
|
if h["iterations"]>=state["policy"]["max_iterations_per_hypothesis"]:
|
|
if any(a["status"]!="settled" for a in account["actions"].values()):
|
|
# One final assessment is allowed; it must finish/reject.
|
|
selected=(pos,hid);break
|
|
budget.complete(case_id,package_id,hid,reason="INVESTIGATION_ITERATION_LIMIT")
|
|
h["disposition"]="iteration_limit";continue
|
|
selected=(pos,hid);break
|
|
if state['policy'].get('lean_loop'):
|
|
# Flush provisional work before switching or concluding, even
|
|
# when the budget/iteration scheduler has become terminal.
|
|
dirty=[(ids.index(k),k) for k,h in state['hypotheses'].items() if h.get('lean_session',{}).get('turns')]
|
|
if dirty:
|
|
prior=dirty[0]
|
|
session=state['hypotheses'][prior[1]]['lean_session']
|
|
if selected != prior or (state['policy'].get('lean_checkpoint_steps',4)>0 and len(session['turns'])>=state['policy']['lean_checkpoint_steps']):
|
|
session['checkpoint']=True
|
|
selected=prior
|
|
if selected is None:
|
|
state["completed"]=True
|
|
final_budget=budget.snapshot(case_id,package_id).state
|
|
supported=[hid for hid,h in state["hypotheses"].items() if h["confidence"] in {"supported","strongly_supported"}]
|
|
state["countercheck_complete"]=(sum(bool(a["actions"]) for a in final_budget["accounts"].values())>=2
|
|
and all(any(other!=hid and a["actions"] for other,a in final_budget["accounts"].items()) for hid in supported))
|
|
state["summary_ref"]=f"analysis/investigation/{state['analysis_revision']:04d}/summary.md"
|
|
state,report=self._save(state,summary=render_investigation(state,final_budget),target=State.BUILDING_DIAGNOSIS)
|
|
return InvestigationResult("COMPLETED",report.package,report.state_ref)
|
|
pos,hid=selected
|
|
state.pop('lean_preferred_hypothesis',None)
|
|
context=self._context(state,hid,budget)
|
|
state["hypotheses"][hid]["iterations"]+=1
|
|
iteration=state["hypotheses"][hid]["iterations"]
|
|
state["cursor"]=(pos+1)%len(ids)
|
|
state["active"]={"hypothesis_id":hid,"iteration":iteration,"attempt":0,"phase":"prompt",
|
|
"context_ref":f"analysis/investigation/{state['analysis_revision']:04d}/contexts/{hid}-{iteration:04d}.json",
|
|
"context_sha256":digest(context),"decision":None,"llm_ref":None,"test_request":None,"test_deadline":None,"retry_token":"initial"}
|
|
state,_=self._save(state,context=context)
|
|
active=state["active"]
|
|
if retry_id is not None and active["retry_token"]!=key(retry_id):
|
|
old_operation=f"investigation:v1:{active['hypothesis_id']}:{active['iteration']}:{active['attempt']}:{active['retry_token']}"
|
|
operation_key=key(f"{state['analysis_revision']}:{old_operation}")
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:
|
|
records=[cp for _,cp in checkpoint_chain(reader) if cp["details"].get("operation")=="llm-round" and cp["details"].get("operation_key")==operation_key]
|
|
if records:
|
|
detail=records[0]["details"]
|
|
if detail["status"]!="BLOCKED" and detail.get("code") not in PROVIDER_CONFIGURATION_ERRORS:
|
|
raise InvestigationError("INVESTIGATION_RETRY_NOT_EXHAUSTED")
|
|
active["retry_token"]=key(retry_id);state,_=self._save(state);active=state["active"]
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:
|
|
context=self._read(reader,active["context_ref"],"investigation-context")
|
|
if budget.snapshot(case_id,package_id).state!=context["budget"]:
|
|
raise InvestigationError("INVESTIGATION_CONTEXT_CHANGED")
|
|
operation=f"investigation:v1:{active['hypothesis_id']}:{active['iteration']}:{active['attempt']}:{active['retry_token']}"
|
|
if context.get('response_repair'):
|
|
from viano_bug_analysis.llm.response_recovery import admission_reason
|
|
plan=context['response_repair']
|
|
if admission_reason(plan['document'],plan['path'],plan['schema']):
|
|
return self._research_blocked(state,'LLM_REPAIR_INELIGIBLE','INVALID_LLM_RESPONSES')
|
|
try:
|
|
request=build_prompt(context)
|
|
if context.get('prompt_version') in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
characters=sum(len(message['content']) for message in request['messages'])
|
|
target=context['policy'].get('prompt_target_characters',13125)
|
|
self.runner.logger.info('Investigation prompt size case_id=%s hypothesis=%s message_characters=%s target=%s above_target=%s',
|
|
case_id,context['selected_hypothesis'],characters,target,characters>target)
|
|
outcome=await self.runner.run(case_id,package_id,operation_id=operation,request=request)
|
|
except LLMError as error:
|
|
if error.code in PROVIDER_CONFIGURATION_ERRORS:return self._provider_blocked(state,error.code)
|
|
raise
|
|
if outcome.status not in {"SUCCESS","ERROR"}:
|
|
return InvestigationResult(outcome.status,outcome.package, self._state_ref(case_id,package_id),outcome.code,outcome.retry_not_before)
|
|
if outcome.status=="ERROR":
|
|
if outcome.code in PROVIDER_CONFIGURATION_ERRORS:return self._provider_blocked(state,outcome.code)
|
|
return self._invalid(state,budget,outcome.code or "LLM_ERROR",outcome.result_ref)
|
|
with PackageReader(outcome.package,self.store.limits) as reader:
|
|
call=self._read(reader,outcome.result_ref,"llm-call")
|
|
if (call["phase"]!=State.INVESTIGATING_HYPOTHESES or call["input_request_hash"]!=digest(self.gateway.validate_request(build_prompt(context)))
|
|
or any(call[f]!=state[f] for f in ("case_id","package_id","analysis_revision"))):
|
|
raise InvestigationError("INVESTIGATION_LLM_BINDING")
|
|
decision=call["outcome"]["document"]
|
|
try:
|
|
self._validate_decision(state,context,decision,budget)
|
|
except (InvestigationError,CaseBuildError,BudgetError,ToolError) as error:
|
|
if getattr(error,"code",None) in {"PROJECT_PROVIDER_UNAVAILABLE", "PROJECT_SOURCE_UNAVAILABLE", "PROJECT_DATABASE_UNAVAILABLE", "PROJECT_DATABASE_CREDENTIALS", "PROJECT_DATABASE_DRIVER", "PROJECT_BINDING_CHANGED"}:
|
|
return self._research_blocked(state,error.code)
|
|
return self._invalid(state,budget,getattr(error,"code","INVESTIGATION_OUTPUT"),outcome.result_ref,
|
|
feedback=getattr(error,'feedback',None))
|
|
active["decision"]=decision;active["llm_ref"]=outcome.result_ref;active["phase"]="executing"
|
|
if call['outcome'].get('normalization'):
|
|
state['pending_format_notice']=call['outcome']['normalization']['rules'][:16]
|
|
if call['outcome'].get('repair'):
|
|
state['pending_format_notice']=['format_repair:/'+ '/'.join(map(str,context['response_repair']['path']))]
|
|
if decision["next"]["kind"]=="test":
|
|
now=self.clock()
|
|
if now.tzinfo is None:raise InvestigationError("INVESTIGATION_CLOCK")
|
|
active["test_deadline"]=(now+timedelta(seconds=state["policy"]["test_deadline_seconds"])).isoformat()
|
|
state,_=self._save(state)
|
|
return self._execute_plan(state,budget)
|
|
|
|
def _state_ref(self,case_id,package_id):
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:return reader.manifest["artifacts"].get("investigation_state")
|
|
|
|
def _provider_blocked(self,state,code):
|
|
due=(self.clock()+timedelta(seconds=self.gateway.config.blocked_retry_seconds)).isoformat()
|
|
path=self.store.advance(state["case_id"],state["package_id"],State.BLOCKED,
|
|
step_id=f"investigation-provider-blocked:{state['analysis_revision']}:{state['active']['context_sha256']}:{state['active']['attempt']}:{state['active']['retry_token']}",
|
|
reason="LLM_CONFIGURATION",retry_not_before=due,
|
|
details={"operation":"investigation-provider-blocked","automatic_retry":False,"code":code})
|
|
self.runner.logger.error("Investigation blocked case_id=%s code=%s",state["case_id"],code)
|
|
return InvestigationResult("BLOCKED",path,self._state_ref(state["case_id"],state["package_id"]),code,due)
|
|
|
|
def _research_blocked(self,state,code,reason="RESEARCH_UNAVAILABLE"):
|
|
due=(self.clock()+timedelta(seconds=self.gateway.config.blocked_retry_seconds)).isoformat()
|
|
with PackageReader(self._latest(state["case_id"],state["package_id"]),self.store.limits) as reader:
|
|
generation=reader.manifest["package_generation"]
|
|
path=self.store.advance(state["case_id"],state["package_id"],State.BLOCKED,
|
|
step_id=f"project-provider-blocked:{state['analysis_revision']}:{state['active']['context_sha256']}:{state['active']['attempt']}:{generation}",
|
|
reason=reason,retry_not_before=due,
|
|
details={"operation":"project-provider-blocked","automatic_retry":False,"code":code})
|
|
return InvestigationResult("BLOCKED",path,self._state_ref(state["case_id"],state["package_id"]),code,due)
|
|
|
|
def _tool_request(self,state,next_action):
|
|
active=state["active"];action=f"INV-{state['analysis_revision']}-{active['hypothesis_id']}-{active['iteration']}"
|
|
arguments=copy.deepcopy(next_action["arguments"])
|
|
if state.get('prompt_protocol') == 2 and any(k in arguments for k in ('source_ref', 'source_refs', 'path')):
|
|
from .investigation_view import working_view
|
|
aliases={}
|
|
with PackageReader(self._latest(state['case_id'],state['package_id']),self.store.limits) as reader:
|
|
context=self._read(reader,active['context_ref'],'investigation-context')
|
|
source_catalog = {}
|
|
if context.get('prompt_version') in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
from .investigation_compact import source_aliases
|
|
_,aliases=source_aliases(context)
|
|
elif context.get('prompt_version') in {'investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
from .investigation_focus import full_history_view
|
|
source_catalog = full_history_view(context, aliases_out=aliases).get('shared_sources', {})
|
|
elif context.get('prompt_version') in {'investigation_project_v8','investigation_project_v9'}:
|
|
from .investigation_focus import focused_view
|
|
source_catalog = focused_view(context, aliases_out=aliases).get('shared_sources', {})
|
|
elif context.get('prompt_version') == 'investigation_project_v7':
|
|
from .investigation_blocks import block_view
|
|
source_catalog = block_view(context, aliases_out=aliases).get('shared_sources', {})
|
|
elif context.get('prompt_version') == 'investigation_project_v6':
|
|
from .investigation_consolidation import consolidated_view
|
|
source_catalog = consolidated_view(context, aliases_out=aliases).get('shared_sources', {})
|
|
else:
|
|
working_view(context,aliases_out=aliases)
|
|
if isinstance(arguments.get('source_ref'),str):
|
|
arguments['source_ref']=aliases.get(arguments['source_ref'],arguments['source_ref'])
|
|
if isinstance(arguments.get('source_refs'),list):
|
|
arguments['source_refs']=[aliases.get(ref,ref) for ref in arguments['source_refs']]
|
|
if context.get('prompt_version') in {'investigation_project_v6','investigation_project_v7','investigation_project_v8','investigation_project_v9','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'} and isinstance(arguments.get('path'), str):
|
|
source = source_catalog.get(arguments['path'])
|
|
if source:
|
|
path = source['metadata']['path']
|
|
arguments['path'] = aliases.get(path, path)
|
|
return {"format":"viano-investigation-request","format_version":1,
|
|
**{f:state[f] for f in ("case_id","package_id","analysis_revision")},
|
|
"requests":[{"request_id":action,"hypothesis_id":active["hypothesis_id"],"action_id":action,
|
|
"operation":next_action["operation"],"arguments":arguments}]}
|
|
|
|
def _validate_decision(self,state,context,decision,budget):
|
|
from .investigation_prompt import output_schema
|
|
if context.get('prompt_version') in {'investigation_project_v3','investigation_v2','investigation_project_v4','investigation_project_v5','investigation_project_v6','investigation_project_v7','investigation_project_v8','investigation_project_v9','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18','investigation_v3'}:
|
|
from jsonschema import Draft202012Validator
|
|
if not Draft202012Validator(output_schema(context)).is_valid(decision):
|
|
raise InvestigationError('INVESTIGATION_OUTPUT')
|
|
else:
|
|
validate(decision,"investigation-project-output" if "x-project-bindings" in context["tool_schema"] else "investigation-output")
|
|
from .investigation_compact import validate_tool_shape
|
|
validate_tool_shape(context,decision)
|
|
from .investigation_decision import normalize
|
|
decision = normalize(context, decision)
|
|
hid=state["active"]["hypothesis_id"];h=state["hypotheses"][hid];update=decision["update"];next_action=decision["next"]
|
|
previous=context["previous_result"]
|
|
if (decision["assessment"] is None)!=(previous is None):raise InvestigationError("INVESTIGATION_ASSESSMENT_REQUIRED")
|
|
evidence=context["evidence"]
|
|
from .confidence_review import effective_confidence, validate_review
|
|
if context.get('prompt_version') in {'investigation_project_v12','investigation_project_v13','investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
validate_review(update, evidence)
|
|
if context.get('prompt_version') == 'investigation_project_v13':
|
|
from .working_brief import validate_brief
|
|
validate_brief(update, context.get('working_brief', {}).get('brief'), evidence)
|
|
confidence = effective_confidence(update)
|
|
if any(ref not in evidence for ref in update["evidence_refs"]):raise InvestigationError("INVESTIGATION_EVIDENCE_UNKNOWN")
|
|
if confidence in {"supported","strongly_supported"} and not any(evidence[ref]["content"]["items"] for ref in update["evidence_refs"]):
|
|
raise InvestigationError("INVESTIGATION_SUPPORT_REQUIRED")
|
|
if next_action["kind"]=="reject" and confidence in {"supported","strongly_supported"}:
|
|
raise InvestigationError("INVESTIGATION_CONTRADICTORY_UPDATE")
|
|
questions={q["id"]:q for q in h["questions"]}
|
|
# Reconfirming an already resolved question is an idempotent no-op.
|
|
# Only a new resolution needs evidence; unknown IDs remain invalid.
|
|
if (any(q not in questions for q in update["resolve_questions"])
|
|
or (any(not questions[q]["resolved"] for q in update["resolve_questions"])
|
|
and not update["evidence_refs"])):raise InvestigationError("INVESTIGATION_QUESTION")
|
|
if len(h["questions"])+len(update["add_questions"])>state["policy"]["max_questions_per_hypothesis"]:
|
|
raise InvestigationError("INVESTIGATION_QUESTION_LIMIT")
|
|
if any(text.casefold().strip() in {q["text"].casefold().strip() for q in h["questions"]} for text in update["add_questions"]):
|
|
raise InvestigationError("INVESTIGATION_QUESTION_DUPLICATE")
|
|
snapshot=budget.snapshot(state["case_id"],state["package_id"]);projected=snapshot.state
|
|
if previous:
|
|
assessment=decision["assessment"]
|
|
if type(assessment["semantic_score"]) is not int:raise InvestigationError("INVESTIGATION_SCORE")
|
|
if previous["record"]["id"] not in assessment["evidence_refs"] or any(ref not in evidence for ref in assessment["evidence_refs"]):
|
|
raise InvestigationError("INVESTIGATION_ASSESSMENT_BINDING")
|
|
projected=budget_apply(projected,{"operation":"settle","hypothesis_id":hid,"action_id":previous["record"]["action_id"],
|
|
"semantic_score":assessment["semantic_score"],"reason":assessment["reason"]},context["budget_policy"])
|
|
if next_action['kind']=='defer' and state.get('early_stop',{}).get('status')!='preparing' and 'lean_session' not in context:
|
|
raise InvestigationError('EARLY_STOP_NOT_PENDING')
|
|
if state.get('early_stop',{}).get('status')=='preparing' and next_action['kind'] not in {'defer','reject'}:
|
|
raise InvestigationError('EARLY_STOP_ASSESS_ONLY')
|
|
if next_action['kind']=='request_diagnosis':
|
|
from .early_stop import check_request
|
|
reviewed=copy.deepcopy(decision)
|
|
reviewed['update']['confidence']=confidence
|
|
check_request(state,context,reviewed,projected)
|
|
if next_action['kind'] == 'recall':
|
|
if context.get('prompt_version') in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
from .investigation_compact import model_view
|
|
visible=set(model_view(context)['evidence_policy']['full_visible_ids'])
|
|
if set(next_action['evidence_refs']).issubset(visible):
|
|
raise InvestigationError('INVESTIGATION_RECALL_VISIBLE',feedback={'field':'next.evidence_refs',
|
|
'ids':next_action['evidence_refs'],'requirement':'already_visible_use_findings_or_choose_a_new_action'})
|
|
if h['iterations'] > state['policy']['max_iterations_per_hypothesis']:
|
|
raise InvestigationError('INVESTIGATION_ITERATION_LIMIT')
|
|
if any(ref not in evidence for ref in next_action['evidence_refs']):
|
|
raise InvestigationError('INVESTIGATION_EVIDENCE_UNKNOWN')
|
|
if next_action["kind"] in {"tool","test"}:
|
|
if h["iterations"]>state["policy"]["max_iterations_per_hypothesis"]:raise InvestigationError("INVESTIGATION_ITERATION_LIMIT")
|
|
if next_action["kind"]=="tool":
|
|
if next_action["operation"]=="project.query":
|
|
self._check_project_binding(context)
|
|
request=self._tool_request(state,next_action);validate(request,"investigation-request")
|
|
with PackageReader(snapshot.package,self.store.limits) as reader:
|
|
ctx=QueryContext(reader,self.router.limits,self.router.case_limits)
|
|
tool=self.router.tool(next_action["operation"])
|
|
tool.prepare(ctx,request["requests"][0])
|
|
operation=next_action["operation"]
|
|
else:
|
|
operation="testing.request_human" if next_action["executor"]=="human" else "testing.request_automated"
|
|
if operation not in context["budget_policy"]["action_costs"]:raise InvestigationError("INVESTIGATION_TEST_COST")
|
|
budget_apply(projected,{"operation":"reserve","hypothesis_id":hid,"action_id":f"INV-{state['analysis_revision']}-{hid}-{state['active']['iteration']}","action":operation},context["budget_policy"])
|
|
|
|
def _check_project_binding(self,context):
|
|
service=self.router.project_research
|
|
binding=context["tool_schema"].get("x-project-bindings",{})
|
|
if service is None or binding.get("source")!=(service.source.binding() if service.source else None) or binding.get("database")!=(service.database.binding() if service.database else None):
|
|
raise ToolError("PROJECT_BINDING_CHANGED")
|
|
if binding.get('relation_profiles') is not None and binding['relation_profiles'] != (service.relations.catalog() if service.relations else None):
|
|
raise ToolError('PROJECT_BINDING_CHANGED')
|
|
if binding.get('source_analysis') is not None and binding['source_analysis'] != (service.source_context.catalog() if service.source_context else None):
|
|
raise ToolError('PROJECT_BINDING_CHANGED')
|
|
|
|
def _invalid(self,state,budget,code,llm_ref,*,feedback=None):
|
|
active=state["active"];h=state["hypotheses"][active["hypothesis_id"]]
|
|
h["invalid_responses"]+=1;active["attempt"]+=1
|
|
# The rejected reply is already immutable in the LLM call log. Retain
|
|
# its safe error code/reference in the controller's audit notes.
|
|
h["notes"].append({"kind":"invalid_response","code":code,"llm_ref":llm_ref})
|
|
context=None
|
|
with PackageReader(self._latest(state["case_id"],state["package_id"]),self.store.limits) as reader:
|
|
context=self._read(reader,active["context_ref"],"investigation-context")
|
|
context["previous_rejection"]=code
|
|
if context.get('prompt_version') in {'investigation_project_v14','investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
context.pop('previous_rejection_details', None)
|
|
if feedback:
|
|
context['previous_rejection_details']=feedback
|
|
elif code == 'LLM_OUTPUT_SCHEMA':
|
|
with PackageReader(self._latest(state['case_id'],state['package_id']),self.store.limits) as reader:
|
|
call=self._read(reader,llm_ref,'llm-call')
|
|
info=call.get('outcome',{}).get('diagnostics',{})
|
|
context['previous_rejection_details']={k:info[k] for k in ('validation_path','validation_fields') if k in info}
|
|
context['previous_rejection_details']['requirement']={
|
|
'required':'provide_required_fields',
|
|
'additionalProperties':'remove_unrecognized_fields',
|
|
'oneOf':'choose_one_valid_next_action',
|
|
'anyOf':'match_an_allowed_shape',
|
|
}.get(info.get('validation_keyword'),'match_schema_at_validation_path')
|
|
if context.get('bootstrap_json_framing'):
|
|
context['previous_rejection_details'].update({k:info[k] for k in ('expected_types','actual_type') if k in info})
|
|
context['previous_rejection_details']['requirement']={
|
|
'type':'match_expected_type_at_validation_path',
|
|
'enum':'use_allowed_value_at_validation_path',
|
|
'const':'use_allowed_value_at_validation_path',
|
|
}.get(info.get('validation_keyword'),context['previous_rejection_details']['requirement'])
|
|
elif code == 'LLM_RESPONSE' and context.get('prompt_version') in {'investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
with PackageReader(self._latest(state['case_id'],state['package_id']),self.store.limits) as reader:
|
|
call=self._read(reader,llm_ref,'llm-call')
|
|
from viano_bug_analysis.llm.diagnostics import sanitize
|
|
info=sanitize(call.get('outcome',{}).get('diagnostics',{}))
|
|
context['previous_rejection_details']={k:info[k] for k in
|
|
('response_stage','response_error','parse_line','parse_column','json_error_kind','json_error_at_end') if k in info}
|
|
context['previous_rejection_details']['requirement']=(
|
|
'return_one_valid_JSON_object_no_fences_or_duplicate_keys'
|
|
if info.get('response_stage') == 'output_json'
|
|
else 'provider_response_unreadable_repeat_same_task_without_research_side_effects')
|
|
if context['policy'].get('response_recovery_version')==1 and not context.get('response_repair'):
|
|
with PackageReader(self._latest(state['case_id'],state['package_id']),self.store.limits) as reader:
|
|
call=self._read(reader,llm_ref,'llm-call')
|
|
from viano_bug_analysis.llm.response_recovery import make_repair,filter_contract_feedback
|
|
document=call['outcome'].get('rejected_document',call['outcome'].get('document'))
|
|
contract_feedback=filter_contract_feedback(document)
|
|
if contract_feedback:context['previous_rejection_details']=contract_feedback
|
|
prepared=make_repair(context,call,llm_ref)
|
|
if prepared:
|
|
context['response_repair'],context['repair_binding']=prepared
|
|
active["context_ref"]=f"analysis/investigation/{state['analysis_revision']:04d}/contexts/{active['hypothesis_id']}-{active['iteration']:04d}-attempt-{active['attempt']:04d}.json"
|
|
state,result=self._save(state,context=context)
|
|
self.runner.logger.error("Investigation response rejected case_id=%s code=%s",state["case_id"],code)
|
|
if h["invalid_responses"]>=state["policy"]["max_invalid_responses"]:
|
|
# Technical rejection is not evidence against a hypothesis. Preserve
|
|
# open hypotheses, reservations, prior evidence and remaining budget.
|
|
return self._research_blocked(state,code,"INVALID_LLM_RESPONSES")
|
|
return InvestigationResult("REJECTED",result.package,result.state_ref,code)
|
|
|
|
def _execute_plan(self,state,budget):
|
|
active=state["active"];decision=active["decision"];hid=active["hypothesis_id"];h=state["hypotheses"][hid]
|
|
case_id,package_id=state["case_id"],state["package_id"]
|
|
with PackageReader(self._latest(case_id,package_id),self.store.limits) as reader:
|
|
context=self._read(reader,active["context_ref"],"investigation-context")
|
|
call=self._read(reader,active["llm_ref"],"llm-call")
|
|
request_ref=active["llm_ref"].rsplit("/",1)[0]+"/request.json"
|
|
with reader._archive.open(request_ref) as stream:
|
|
saved_request=parse_manifest(reader.validator._read_bounded(stream,self.limits.max_json_bytes))
|
|
expected=build_prompt(context);expected["privacy"]=saved_request.get("privacy")
|
|
operation=f"investigation:v1:{active['hypothesis_id']}:{active['iteration']}:{active['attempt']}:{active['retry_token']}"
|
|
if (call["status"]!="SUCCESS" or call["phase"]!=State.INVESTIGATING_HYPOTHESES
|
|
or any(call[f]!=state[f] for f in ("case_id","package_id","analysis_revision"))
|
|
or call["operation_key"]!=key(f"{state['analysis_revision']}:{operation}")
|
|
or call["outcome"].get("document")!=decision or saved_request!=expected
|
|
or call["input_request_hash"]!=digest(saved_request)):
|
|
raise InvestigationError("INVESTIGATION_PLAN_BINDING")
|
|
# State permits several frozen wire versions. Recheck against this
|
|
# exact request before any resumed budget or tool side effect.
|
|
from jsonschema import Draft202012Validator
|
|
from .investigation_prompt import output_schema
|
|
contract=output_schema(context) if context.get('response_repair') else saved_request['output_schema']
|
|
if context.get('response_repair'):
|
|
from viano_bug_analysis.llm.response_recovery import apply_repair,sha
|
|
source=self._read(reader,context['response_repair']['source_ref'],'llm-call')
|
|
original=source['outcome'].get('rejected_document',source['outcome'].get('document'))
|
|
if (sha(source)!=context['response_repair']['source_sha256']
|
|
or original!=context['response_repair']['document']
|
|
or source['phase']!=State.INVESTIGATING_HYPOTHESES
|
|
or any(source[k]!=state[k] for k in ('case_id','package_id','analysis_revision'))):
|
|
raise InvestigationError('INVESTIGATION_REPAIR_BINDING')
|
|
patch=call['outcome'].get('repair',{}).get('patch')
|
|
if not patch or apply_repair(saved_request,patch)!=decision:
|
|
raise InvestigationError('INVESTIGATION_REPAIR_BINDING')
|
|
if not Draft202012Validator(contract).is_valid(decision):
|
|
raise InvestigationError('INVESTIGATION_PLAN_BINDING')
|
|
from .investigation_compact import validate_tool_shape
|
|
validate_tool_shape(context,decision)
|
|
from .investigation_decision import normalize
|
|
decision = normalize(context, decision)
|
|
if decision["next"].get("operation")=="project.query":
|
|
try:
|
|
self._check_project_binding(context)
|
|
except ToolError as error:
|
|
return self._research_blocked(state,error.code)
|
|
previous=context["previous_result"]
|
|
if previous:
|
|
assessment=decision["assessment"]
|
|
budget.settle(case_id,package_id,hid,previous["record"]["action_id"],semantic_score=assessment["semantic_score"],reason=assessment["reason"])
|
|
next_action=decision["next"];kind=next_action["kind"]
|
|
action_id=f"INV-{state['analysis_revision']}-{hid}-{active['iteration']}"
|
|
if kind in {"tool","test"}:
|
|
operation=next_action["operation"] if kind=="tool" else "testing.request_human" if next_action["executor"]=="human" else "testing.request_automated"
|
|
budget.reserve(case_id,package_id,hid,action_id,operation)
|
|
evidence_operation=f"investigation:{hid}:{active['iteration']}"
|
|
# A published evidence record is reusable even if subsequent result
|
|
# accounting/checkpoint publication was interrupted.
|
|
saved_refs=[]
|
|
snapshot=self.evidence.snapshot(case_id,package_id)
|
|
with PackageReader(snapshot.package,self.store.limits) as reader:
|
|
for ref in snapshot.record_refs:
|
|
record=self._read(reader,ref,"evidence-record")
|
|
if record["analysis_revision"]==state["analysis_revision"] and record["hypothesis_id"]==hid and record["action_id"]==action_id:saved_refs.append(ref)
|
|
if saved_refs:
|
|
record_ref=saved_refs[0]
|
|
elif kind=="tool":
|
|
request=self._tool_request(state,next_action)
|
|
try:
|
|
result=self.router.query(self._latest(case_id,package_id),request)
|
|
except ToolError as error:
|
|
if error.code.startswith("PROJECT_"):
|
|
return self._research_blocked(state,error.code)
|
|
raise
|
|
record_ref=self.evidence.store_tool_results(case_id,package_id,evidence_operation,request,result).record_refs[0]
|
|
else:
|
|
if active["test_request"] is None:
|
|
snap=budget.snapshot(case_id,package_id)
|
|
active["test_request"]={"format":"viano-async-test-request","format_version":1,
|
|
**{f:state[f] for f in ("case_id","package_id","analysis_revision")},"request_id":action_id,"hypothesis_id":hid,"action_id":action_id,
|
|
**{f:next_action[f] for f in ("executor","question","steps","prerequisites","requested_observations","environment")},
|
|
"deadline":active["test_deadline"],"budget_ledger_ref":snap.event_ref}
|
|
state,_=self._save(state);active=state["active"];h=state["hypotheses"][hid]
|
|
handoff=self.tests.submit(active["test_request"])
|
|
if handoff.feedback_ref is None:
|
|
return InvestigationResult("BLOCKED",handoff.package,self._state_ref(case_id,package_id),"AWAITING_TEST_FEEDBACK",active["test_deadline"])
|
|
record_ref=self.evidence.store_feedback(case_id,package_id,evidence_operation,handoff.feedback_ref).record_refs[0]
|
|
budget.record_evidence(case_id,package_id,hid,action_id,record_ref)
|
|
h["last_record_ref"]=record_ref
|
|
elif kind=='recall':
|
|
old=state.get('recalled_evidence',{}).get(hid,[]) if context.get('prompt_version') in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'} else []
|
|
state.setdefault('recalled_evidence', {})[hid] = list(dict.fromkeys([ref for ref in old if ref not in next_action['evidence_refs']]+next_action['evidence_refs']))[-4:]
|
|
h['last_record_ref']=None
|
|
elif kind=='defer':
|
|
h['last_record_ref']=None
|
|
elif kind=='request_diagnosis':
|
|
from .early_stop import evidence_fingerprint
|
|
number=state.get('early_stop_attempts',0)+1
|
|
state['early_stop_attempts']=number
|
|
state['early_stop']={'status':'preparing','number':number,'hypothesis_id':hid,
|
|
'evidence_refs':decision['update']['evidence_refs'],
|
|
'countercheck_refs':next_action['countercheck_refs'],'reason':next_action['reason'],
|
|
'fingerprint':evidence_fingerprint(context['evidence'])}
|
|
h['last_record_ref']=None
|
|
else:
|
|
snap=budget.snapshot(case_id,package_id)
|
|
if snap.state["accounts"][hid]["status"] not in TERMINAL:
|
|
if kind=="reject":budget.reject(case_id,package_id,hid,reason=next_action["reason"])
|
|
else:budget.complete(case_id,package_id,hid,reason=next_action["reason"])
|
|
h["disposition"]=kind
|
|
if kind != 'recall' and context.get('prompt_version') not in {'investigation_project_v15','investigation_project_v16','investigation_project_v17','investigation_project_v18'}:
|
|
state.get('recalled_evidence', {}).pop(hid, None)
|
|
if 'lean_session' in context:
|
|
from .investigation_lean import checkpoint
|
|
if checkpoint(context):
|
|
h['lean_session']={'turns':[],'checkpoint':False}
|
|
if call['outcome']['document']['next']['kind']=='switch':
|
|
state['lean_preferred_hypothesis']=call['outcome']['document']['next']['hypothesis_id']
|
|
else:
|
|
raw=call['outcome']['document']
|
|
session=h.setdefault('lean_session',{'turns':[],'checkpoint':False})
|
|
session['turns'].append({'llm_ref':active['llm_ref'],'observation':raw['observation'],
|
|
'evidence_id':previous['record']['id'] if previous else None,
|
|
'read_ids':sorted(json.loads(saved_request['messages'][1]['content'])['input']['evidence'])})
|
|
session['checkpoint']=raw['next']['kind']=='checkpoint'
|
|
if context.get('lean_protocol')==2 and session['checkpoint']:session['intent']=raw['next'].get('intent','review')
|
|
update=decision["update"]
|
|
from .confidence_review import effective_confidence
|
|
from .investigation_lean import checkpoint as lean_checkpoint
|
|
if 'lean_session' not in context or lean_checkpoint(context):
|
|
h["confidence"]=effective_confidence(update);h["evidence_refs"]=update["evidence_refs"]
|
|
for q in h["questions"]:
|
|
if q["id"] in update["resolve_questions"]:q["resolved"]=True
|
|
for text in update["add_questions"]:h["questions"].append({"id":f"Q{len(h['questions'])+1:04d}","text":text,"resolved":False})
|
|
note={"kind":"assessment","text":update["note"],"llm_ref":active["llm_ref"]}
|
|
if 'causal_review' in update:
|
|
note.update(causal_review=copy.deepcopy(update['causal_review']),
|
|
requested_confidence=update['confidence'],effective_confidence=h['confidence'])
|
|
h["notes"].append(note)
|
|
additions = {}
|
|
if 'working_brief' in update:
|
|
previous_brief = context.get('working_brief')
|
|
record = {'format':'viano-investigation-working-brief','format_version':1,
|
|
**{k:state[k] for k in ('case_id','package_id','analysis_revision')},
|
|
'hypothesis_id':hid,'sequence':previous_brief['sequence']+1 if previous_brief else 1,
|
|
'parent_ref':h.get('working_brief_ref'),'llm_ref':active['llm_ref'],
|
|
'brief':copy.deepcopy(update['working_brief'])}
|
|
if 'brief_change_audit' in decision:
|
|
record['change_audit']=decision['brief_change_audit']
|
|
validate(record, 'investigation-working-brief-record')
|
|
ref=f"analysis/investigation/{state['analysis_revision']:04d}/briefs/{hid}-{record['sequence']:04d}.json"
|
|
h['working_brief_ref']=ref;h['working_brief_sha256']=digest(record)
|
|
additions[ref]=json_bytes(record)
|
|
h["invalid_responses"]=0;state["active"]=None
|
|
state,result=self._save(state, additions=additions)
|
|
return result
|