Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

292 lines
11 KiB
Python

"""Solve tools — the seam between the chat-loop solver and the SolveSession.
Three tools auto-mounted only when a solve turn is active (via the solve loop
capability). The chat agent loop IS the solver; these tools give it a deterministic
spine — a plan it commits to, a per-step "done" gate, and a bounded replan —
while the reasoning (how to actually solve each step) stays the model's job in
the loop, using the shared built-in tools (rag / code_execution / geogebra / …).
The active session id is injected server-side by the pipeline as
``_solve_session_id``; the model never supplies it. ``solve_finish_step`` emits
a ``_context_checkpoint`` so the loop folds the just-finished step's tool
chatter into a one-line summary — the loop-native equivalent of the old
pipeline's per-step message reset.
"""
from __future__ import annotations
import json
from typing import Any
from deeptutor.capabilities.solve.session import get_session
from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult
# Tool names the pipeline mounts together when a solve turn is active. Kept
# here so the mount policy and the registration list can't disagree.
SOLVE_TOOL_NAMES: tuple[str, ...] = (
"solve_plan",
"solve_finish_step",
"solve_replan",
)
def _resolve_session_id(kwargs: dict[str, Any]) -> str:
return str(kwargs.get("_solve_session_id") or "").strip()
def _json_result(
payload: dict[str, Any], *, meta_key: str, extra_meta: dict[str, Any] | None = None
) -> ToolResult:
metadata: dict[str, Any] = {meta_key: payload}
if extra_meta:
metadata.update(extra_meta)
return ToolResult(
content=json.dumps(payload, ensure_ascii=False),
success=True,
metadata=metadata,
)
def _no_session_result() -> ToolResult:
return ToolResult(
content="No solve session is active on this turn; solve tools are unavailable.",
success=False,
)
def _parse_steps(raw_steps: Any) -> list[tuple[str, str]]:
"""Validate the model-authored step list into ``(id, goal)`` pairs.
Ids are server-generated (``S1``, ``S2``, …) so the model never controls
storage keys; steps without a goal are dropped.
"""
if not isinstance(raw_steps, list):
return []
steps: list[tuple[str, str]] = []
for i, raw in enumerate(raw_steps):
if isinstance(raw, dict):
goal = str(raw.get("goal") or "").strip()
else:
goal = str(raw or "").strip()
if not goal:
continue
steps.append((f"S{len(steps) + 1}", goal))
return steps
class SolvePlanTool(BaseTool):
"""Commit to a step plan for the problem. Call FIRST on a solve turn."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="solve_plan",
description=(
"Lay out your plan for solving the problem: a short analysis plus "
"an ordered list of steps. Call this FIRST, before doing any work. "
"Then work the steps one at a time with the available tools, "
"calling solve_finish_step after each. Keep the plan tight (2-6 "
"steps); for a trivial problem a single step is fine."
),
parameters=[
ToolParameter(
name="analysis",
type="string",
description="One or two sentences: what the problem asks and your approach.",
),
ToolParameter(
name="steps",
type="array",
description="Ordered steps, each {goal}. Goals are short imperative phrases.",
items={
"type": "object",
"properties": {"goal": {"type": "string"}},
"required": ["goal"],
},
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
session_id = _resolve_session_id(kwargs)
if not session_id:
return _no_session_result()
steps = _parse_steps(kwargs.get("steps"))
if not steps:
return ToolResult(
content="solve_plan needs a non-empty 'steps' array, each with a 'goal'.",
success=False,
)
analysis = str(kwargs.get("analysis") or "").strip()
session = get_session(session_id)
session.set_plan(analysis, steps)
first = session.next_step()
return _json_result(
{
"status": "planned",
"analysis": analysis,
"steps": session.map(),
"next": first.to_dict() if first else None,
"instruction": (
"Work the first step now using the available tools, then call "
"solve_finish_step with a short summary of its result. Do not "
"skip steps."
),
},
meta_key="solve_plan",
)
class SolveFinishStepTool(BaseTool):
"""Record a step as done and advance; folds the step's chatter to a summary."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="solve_finish_step",
description=(
"Mark the current step done and move on. Pass a short summary of "
"what the step established (the key result / value / conclusion) — "
"this is kept as the step's record while its intermediate tool "
"output is folded away to save context. Returns the next step to "
"work on, or signals that all steps are done."
),
parameters=[
ToolParameter(
name="step_id",
type="string",
description="The step id from solve_plan (e.g. 'S1').",
),
ToolParameter(
name="summary",
type="string",
description="Short summary of the step's result, kept as its record.",
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
session_id = _resolve_session_id(kwargs)
if not session_id:
return _no_session_result()
step_id = str(kwargs.get("step_id") or "").strip()
summary = str(kwargs.get("summary") or "").strip()
session = get_session(session_id)
if not session.steps:
return ToolResult(
content="No plan yet. Call solve_plan before solve_finish_step.",
success=False,
)
step = session.mark_done(step_id, summary)
if step is None:
return ToolResult(
content=f"Unknown step {step_id!r}; valid ids: {[s.id for s in session.steps]}.",
success=False,
)
nxt = session.next_step()
payload = {
"status": "step_done",
"completed": step_id,
"next": nxt.to_dict() if nxt else None,
"all_done": session.all_done(),
"instruction": (
"Write the final answer now."
if nxt is None
else "Work the next step, then call solve_finish_step again."
),
}
# The checkpoint summary persists this step's outcome while the loop
# folds its intermediate tool messages away (see AgentLoop).
checkpoint = f"[{step.id}] {step.goal} — done. {summary}".strip()
return _json_result(
payload,
meta_key="solve_finish_step",
extra_meta={"_context_checkpoint": {"summary": checkpoint}},
)
class SolveReplanTool(BaseTool):
"""Discard the current plan for a new one when the approach is stuck."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="solve_replan",
description=(
"Replace the plan when the current approach has stalled or proved "
"wrong. Give the reason and a fresh ordered list of steps. This is "
"budget-limited — use it only for a genuine course correction, not "
"minor tweaks. If the budget is spent, finish with what you have."
),
parameters=[
ToolParameter(
name="reason",
type="string",
description="Why the current plan failed and what changes.",
),
ToolParameter(
name="steps",
type="array",
description="The new ordered steps, each {goal}.",
items={
"type": "object",
"properties": {"goal": {"type": "string"}},
"required": ["goal"],
},
),
],
)
async def execute(self, **kwargs: Any) -> ToolResult:
session_id = _resolve_session_id(kwargs)
if not session_id:
return _no_session_result()
steps = _parse_steps(kwargs.get("steps"))
if not steps:
return ToolResult(
content="solve_replan needs a non-empty 'steps' array, each with a 'goal'.",
success=False,
)
reason = str(kwargs.get("reason") or "").strip()
session = get_session(session_id)
if not session.replan(reason, steps):
return ToolResult(
content=json.dumps(
{
"status": "budget_exhausted",
"instruction": (
"Replan budget is spent. Do not replan again — finish "
"the problem with the best of what you have."
),
},
ensure_ascii=False,
),
success=False,
metadata={"solve_replan": {"status": "budget_exhausted"}},
)
first = session.next_step()
return _json_result(
{
"status": "replanned",
"reason": reason,
"replans_used": session.replans,
"replans_max": session.max_replans,
"steps": session.map(),
"next": first.to_dict() if first else None,
},
meta_key="solve_replan",
)
SOLVE_TOOL_TYPES: tuple[type[BaseTool], ...] = (
SolvePlanTool,
SolveFinishStepTool,
SolveReplanTool,
)
__all__ = [
"SOLVE_TOOL_NAMES",
"SOLVE_TOOL_TYPES",
"SolveFinishStepTool",
"SolvePlanTool",
"SolveReplanTool",
]