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
292 lines
11 KiB
Python
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",
|
|
]
|