feat(strategist) S3: propose_lead / declare_investigation_complete
DESIGN_STRATEGIST.md §2.5. The strategist's two write actions. propose_lead validates motivating_hypothesis exists in the graph, validates expected_evidence_type is a real edge type, validates source_id refers to a real source in the case — fast specific errors so the strategist gets fixable feedback rather than a generic crash. On success, calls graph.add_lead with proposed_by= "strategist" and round_number=graph.current_strategist_round so the round-completion code can collect this round's leads. declare_investigation_complete sets graph.strategist_complete_requested which the orchestrator inspects after each strategist run to decide whether to break the loop. reason must come from a closed enum so the audit log is consistent. EvidenceGraph gains two transient run-context fields: current_strategist_round — set by orchestrator at start of round strategist_complete_requested — flipped by declare_complete These are intentionally NOT persisted — they're per-run flags, not graph state. Both tools required to be in InvestigationStrategist.mandatory_record_ tools (added in S4) so the agent's forced-retry mechanism kicks in if it returns without taking a documented decision. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
174
tool_registry.py
174
tool_registry.py
@@ -1077,6 +1077,180 @@ def register_all_tools(graph: Any) -> None:
|
||||
tags=["strategy", "budget", "read-only"],
|
||||
)
|
||||
|
||||
# ---- Strategist decision actions (DESIGN_STRATEGIST.md §2.5) ----
|
||||
# propose_lead is the strategist's tool for "go deeper here";
|
||||
# declare_investigation_complete is its tool for "we're done".
|
||||
# Both are required to be in BaseAgent.mandatory_record_tools for the
|
||||
# strategist subclass so the agent can't return without taking a
|
||||
# documented decision.
|
||||
|
||||
_ALLOWED_EVIDENCE_EDGE_TYPES = (
|
||||
"direct_evidence", "supports", "contradicts",
|
||||
"weakens", "prerequisite_met", "consequence_observed",
|
||||
)
|
||||
|
||||
async def _exec_propose_lead(
|
||||
description: str,
|
||||
target_agent: str,
|
||||
motivating_hypothesis: str,
|
||||
expected_evidence_type: str,
|
||||
rationale: str = "",
|
||||
source_id: str = "",
|
||||
) -> str:
|
||||
"""Propose a new lead from the strategist. Idempotent on the
|
||||
(motivating_hypothesis, expected_evidence_type, target_agent,
|
||||
source_id) tuple within a single run.
|
||||
"""
|
||||
# Validate refs early so the strategist gets a fast, specific error.
|
||||
if motivating_hypothesis and motivating_hypothesis not in graph.hypotheses:
|
||||
return (
|
||||
f"Error: motivating_hypothesis {motivating_hypothesis!r} is "
|
||||
f"not in graph.hypotheses. Call graph_overview to see the "
|
||||
f"current hypothesis ids."
|
||||
)
|
||||
if expected_evidence_type not in _ALLOWED_EVIDENCE_EDGE_TYPES:
|
||||
return (
|
||||
f"Error: expected_evidence_type {expected_evidence_type!r} is "
|
||||
f"not one of {list(_ALLOWED_EVIDENCE_EDGE_TYPES)}."
|
||||
)
|
||||
if source_id:
|
||||
src_obj = graph.case.get_source(source_id) if graph.case else None
|
||||
if src_obj is None:
|
||||
return (
|
||||
f"Error: source_id {source_id!r} is not in the case. "
|
||||
f"Valid ids: {[s.id for s in (graph.case.sources if graph.case else [])]}"
|
||||
)
|
||||
|
||||
lid = await graph.add_lead(
|
||||
target_agent=target_agent,
|
||||
description=description,
|
||||
proposed_by="strategist",
|
||||
motivating_hypothesis=motivating_hypothesis,
|
||||
expected_evidence_type=expected_evidence_type,
|
||||
round_number=graph.current_strategist_round,
|
||||
hypothesis_id=motivating_hypothesis or None,
|
||||
context={"source_id": source_id, "rationale": rationale} if source_id or rationale else {},
|
||||
)
|
||||
return (
|
||||
f"Lead {lid} proposed: target_agent={target_agent}, "
|
||||
f"motivating_hypothesis={motivating_hypothesis}, "
|
||||
f"expected={expected_evidence_type}, source={source_id or '—'}."
|
||||
)
|
||||
|
||||
TOOL_CATALOG["propose_lead"] = ToolDefinition(
|
||||
name="propose_lead",
|
||||
description=(
|
||||
"Propose a specific investigation lead that will be dispatched "
|
||||
"after this strategist round. Each lead MUST name a motivating "
|
||||
"hypothesis it expects to move and the kind of edge it expects "
|
||||
"to produce. Do NOT propose a lead that just adds more same-"
|
||||
"direction evidence to an already-supported hypothesis — harmonic "
|
||||
"damping makes repeats cheap. DO propose leads when (a) a "
|
||||
"hypothesis is supported only by one source — get cross-source "
|
||||
"corroboration; (b) a hypothesis is in the active band — give it "
|
||||
"the deciding evidence; (c) a high-value artefact is uncovered on "
|
||||
"a source where an active hypothesis suggests it matters. "
|
||||
"Idempotent on (motivating_hypothesis, expected_evidence_type, "
|
||||
"target_agent, source_id) — re-proposing the same triple while "
|
||||
"pending is a no-op that returns the existing lead's id."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "1-2 sentence specific investigation request, including target source/artefact.",
|
||||
},
|
||||
"target_agent": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"filesystem", "registry", "communication", "network",
|
||||
"ios_artifact", "android_artifact", "media",
|
||||
"hypothesis", "timeline",
|
||||
],
|
||||
"description": "Which worker agent should pick this up.",
|
||||
},
|
||||
"source_id": {
|
||||
"type": "string",
|
||||
"description": "Which evidence source to investigate (e.g. 'src-ios-chan'). Optional for cross-source leads.",
|
||||
},
|
||||
"motivating_hypothesis": {
|
||||
"type": "string",
|
||||
"description": "hyp-id this lead is meant to corroborate or refute.",
|
||||
},
|
||||
"expected_evidence_type": {
|
||||
"type": "string",
|
||||
"enum": list(_ALLOWED_EVIDENCE_EDGE_TYPES),
|
||||
"description": "What kind of P→H edge you expect this lead to produce.",
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"description": "Why this fills a real gap — referenced in audit + worker prompt.",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"description", "target_agent",
|
||||
"motivating_hypothesis", "expected_evidence_type",
|
||||
],
|
||||
},
|
||||
executor=_exec_propose_lead,
|
||||
module="strategy",
|
||||
tags=["strategy", "lead", "decision"],
|
||||
)
|
||||
|
||||
_COMPLETE_REASONS = (
|
||||
"marginal_yield_zero", "budget_exhausted",
|
||||
"all_hypotheses_resolved", "coverage_saturated", "other",
|
||||
)
|
||||
|
||||
async def _exec_declare_investigation_complete(
|
||||
reason: str, rationale: str = "",
|
||||
) -> str:
|
||||
"""Terminal strategist action: signal "we're done" to the orchestrator."""
|
||||
if reason not in _COMPLETE_REASONS:
|
||||
return (
|
||||
f"Error: reason {reason!r} not in "
|
||||
f"{list(_COMPLETE_REASONS)}."
|
||||
)
|
||||
graph.strategist_complete_requested = True
|
||||
return (
|
||||
f"Investigation marked complete in round "
|
||||
f"{graph.current_strategist_round}. reason={reason}. "
|
||||
f"rationale={rationale or '(none)'}. The orchestrator will exit "
|
||||
f"the strategist loop after this round."
|
||||
)
|
||||
|
||||
TOOL_CATALOG["declare_investigation_complete"] = ToolDefinition(
|
||||
name="declare_investigation_complete",
|
||||
description=(
|
||||
"Terminal strategist action. Call this when (a) marginal_yield "
|
||||
"shows zero across 2+ rounds, (b) budget is exhausted, (c) all "
|
||||
"active hypotheses are resolved, or (d) coverage is saturated "
|
||||
"with respect to the active hypotheses. After this call, the "
|
||||
"orchestrator finishes the strategist loop and proceeds to "
|
||||
"Phase 4 (timeline) and Phase 5 (report). The current round's "
|
||||
"in-flight work still completes."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": list(_COMPLETE_REASONS),
|
||||
"description": "Termination cause — picked from a closed set so the audit log is consistent.",
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string",
|
||||
"description": "Free-text justification — quoted into the InvestigationRound's decision_rationale.",
|
||||
},
|
||||
},
|
||||
"required": ["reason"],
|
||||
},
|
||||
executor=_exec_declare_investigation_complete,
|
||||
module="strategy",
|
||||
tags=["strategy", "terminal", "decision"],
|
||||
)
|
||||
|
||||
# ---- Wrap every executor with invocation logging (+ cache + auto-record) ----
|
||||
# Must run AFTER all tools are registered. Every tool call now produces
|
||||
# a ToolInvocation entry on the graph (provenance for grounding), and
|
||||
|
||||
Reference in New Issue
Block a user