feat(planner): persist reusable action semantics
Tests / Test passed: 879

This commit is contained in:
2026-07-15 18:14:28 +08:00
parent 361dada276
commit d69be48f96
41 changed files with 733 additions and 116 deletions
+41 -3
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from typing import Any, Protocol
from runtime.planner_config import PlannerConfig
from runtime.tool_specs import ToolSpec
from runtime.tool_specs import ACTION_TOOL_NAMES, ToolSpec
class ToolCallUnavailable(Exception):
@@ -36,6 +36,10 @@ class ToolCallDecision:
# Extended thinking block (Anthropic) or reasoning_content (OpenAI o-series).
# None when not enabled or not present in the response.
thinking: str | None = None
# Required structured metadata for device actions. These fields are removed
# from ``arguments`` before the Runtime invokes the physical device tool.
purpose: str | None = None
expected_outcome: str | None = None
class ToolCallingClient(Protocol):
@@ -352,14 +356,19 @@ def _decision_from_anthropic_response(
name = _value(block, "name")
arguments = _value(block, "input")
if isinstance(name, str) and isinstance(arguments, dict):
executable_arguments, purpose, expected_outcome = (
_split_action_metadata(name, arguments)
)
return ToolCallDecision(
tool_name=name,
arguments=arguments,
arguments=executable_arguments,
usage=_anthropic_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
text_output="\n".join(text_parts) if text_parts else None,
thinking=thinking,
purpose=purpose,
expected_outcome=expected_outcome,
)
raise ValueError("anthropic response did not include a tool_use block")
@@ -418,6 +427,9 @@ def _decision_from_openai_response(
if not isinstance(name, str):
raise ValueError("openai tool call missing a function name")
arguments = _decode_openai_arguments(_value(function, "arguments"))
executable_arguments, purpose, expected_outcome = _split_action_metadata(
name, arguments
)
# Extract reasoning_content from o-series models when present.
raw_reasoning = _value(message, "reasoning_content")
thinking: str | None = raw_reasoning if isinstance(raw_reasoning, str) else None
@@ -427,12 +439,14 @@ def _decision_from_openai_response(
text_output: str | None = raw_content if isinstance(raw_content, str) else None
return ToolCallDecision(
tool_name=name,
arguments=arguments,
arguments=executable_arguments,
usage=_openai_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
thinking=thinking,
text_output=text_output,
purpose=purpose,
expected_outcome=expected_outcome,
)
@@ -475,6 +489,30 @@ def _decode_openai_arguments(raw_arguments: Any) -> dict[str, Any]:
raise ValueError("openai tool call arguments must decode to a JSON object")
def _split_action_metadata(
tool_name: str,
arguments: dict[str, Any],
) -> tuple[dict[str, Any], str | None, str | None]:
executable_arguments = dict(arguments)
if tool_name not in ACTION_TOOL_NAMES:
return executable_arguments, None, None
return (
{
name: value
for name, value in executable_arguments.items()
if name not in {"purpose", "expected_outcome"}
},
_metadata_text(executable_arguments.get("purpose")),
_metadata_text(executable_arguments.get("expected_outcome")),
)
def _metadata_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
return value.strip() or None
def _value(source: Any, key: str) -> Any:
if isinstance(source, dict):
return source.get(key)