-
Notifications
You must be signed in to change notification settings - Fork 0
feat(history): render native tool-call messages #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: isaac/react-v2-clean
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,7 @@ | |
| from pydantic import Field, model_validator | ||
|
|
||
| from dspy.adapters.types.tool import ToolCalls | ||
| from dspy.core.types import LMMessage | ||
| from dspy.core.types import LMMessage, LMTextPart, LMToolResultPart | ||
|
|
||
|
|
||
| class Observation(pydantic.BaseModel): | ||
|
|
@@ -133,21 +133,53 @@ def has_open_episode(self) -> bool: | |
| last_boundary = "output" | ||
| return last_boundary == "input" | ||
|
|
||
| def to_lm_messages(self, adapter: Any, signature: type[Any]) -> list[LMMessage]: | ||
| def to_lm_messages(self, adapter: Any, signature: type[Any], *, use_native_tool_calls: bool = False) -> list[LMMessage]: | ||
| messages: list[LMMessage] = [] | ||
| for entry in self.frames: | ||
| for frame_idx, entry in enumerate(self.frames): | ||
| frame = self._entry_to_frame(signature, entry) | ||
| if frame.inputs: | ||
| content = adapter.format_user_message_content(signature, frame.inputs) | ||
| if self._has_content(content): | ||
| messages.append(self._content_message("user", content)) | ||
|
|
||
| if frame.outputs: | ||
| messages.append(self._content_message("assistant", self._format_outputs(adapter, signature, frame.outputs))) | ||
| if frame.observations: | ||
| messages.extend(self._format_frame_outputs(adapter, signature, frame, frame_idx, use_native_tool_calls)) | ||
| elif frame.observations: | ||
| messages.append(self._content_message("user", self._format_observations(frame.observations))) | ||
| return messages | ||
|
|
||
| def _format_frame_outputs( | ||
| self, | ||
| adapter: Any, | ||
| signature: type[Any], | ||
| frame: HistoryFrame, | ||
| frame_idx: int, | ||
| use_native_tool_calls: bool, | ||
| ) -> list[LMMessage]: | ||
| tool_calls = self._find_tool_calls(frame.outputs) | ||
| if use_native_tool_calls and tool_calls is not None: | ||
| parts = [] | ||
| content = self._format_frame_native_content(adapter, signature, frame.outputs) | ||
| if content: | ||
| parts.append(LMTextPart(text=content)) | ||
| parts.extend(tool_calls.to_lm_parts(id_prefix=f"call_{frame_idx}")) | ||
|
|
||
| messages = [LMMessage(role="assistant", parts=parts)] | ||
| non_native_observations = [] | ||
| for observation in frame.observations: | ||
| if observation.call_id is not None: | ||
| messages.append(self._native_tool_observation(observation)) | ||
| else: | ||
| non_native_observations.append(observation) | ||
| if non_native_observations: | ||
| messages.append(self._content_message("user", self._format_observations(non_native_observations))) | ||
|
Comment on lines
+159
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A concrete failure: history recorded before native calling was enabled stores |
||
| return messages | ||
|
|
||
| messages = [self._content_message("assistant", self._format_outputs(adapter, signature, frame.outputs))] | ||
| if frame.observations: | ||
| messages.append(self._content_message("user", self._format_observations(frame.observations))) | ||
| return messages | ||
|
|
||
| @staticmethod | ||
| def _entry_to_frame(signature: type[Any], entry: HistoryEntry) -> HistoryFrame: | ||
| if isinstance(entry, HistoryFrame): | ||
|
|
@@ -186,6 +218,21 @@ def _format_outputs(self, adapter: Any, signature: type[Any], outputs: dict[str, | |
| sections.append("[[ ## completed ## ]]") | ||
| return "\n\n".join(section for section in sections if section) | ||
|
|
||
| @staticmethod | ||
| def _find_tool_calls(outputs: dict[str, Any]) -> ToolCalls | None: | ||
| for value in outputs.values(): | ||
| if isinstance(value, ToolCalls): | ||
| return value | ||
| return None | ||
|
|
||
| def _format_frame_native_content(self, adapter: Any, signature: type[Any], outputs: dict[str, Any]) -> str | None: | ||
| non_tool_outputs = {key: value for key, value in outputs.items() if not isinstance(value, ToolCalls)} | ||
| if not non_tool_outputs: | ||
| return None | ||
| if len(non_tool_outputs) == 1: | ||
| return str(next(iter(non_tool_outputs.values()))) | ||
| return self._format_outputs(adapter, signature, non_tool_outputs) | ||
|
Comment on lines
+228
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When there is exactly one non- |
||
|
|
||
| def _format_observations(self, observations: list[Observation]) -> str: | ||
| rendered = [] | ||
| for idx, observation in enumerate(observations): | ||
|
|
@@ -214,6 +261,19 @@ def _format_observation_content(content: Any) -> str: | |
| return "\n".join(str(item) for item in content) | ||
| return str(content) | ||
|
|
||
| def _native_tool_observation(self, observation: Observation) -> LMMessage: | ||
| return LMMessage( | ||
| role="tool", | ||
| parts=[ | ||
| LMToolResultPart( | ||
| call_id=observation.call_id, | ||
| name=observation.name, | ||
| content=[LMTextPart(text=self._format_observation_content(observation.value))], | ||
| is_error=observation.is_error, | ||
| ) | ||
| ], | ||
| ) | ||
|
|
||
| @staticmethod | ||
| def _has_content(content: Any) -> bool: | ||
| if isinstance(content, str): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import json | ||
|
|
||
| import dspy | ||
| from dspy.adapters.types.history import History, Observation | ||
| from dspy.adapters.types.tool import Tool, ToolCalls | ||
|
|
||
|
|
||
| def add(a: int, b: int) -> int: | ||
| return a + b | ||
|
|
||
|
|
||
| class NativeToolLM: | ||
| model = "openai/gpt-5-nano" | ||
| supported_params = frozenset() | ||
| supports_function_calling = True | ||
| supports_reasoning = False | ||
| supports_response_schema = False | ||
|
|
||
| def __call__(self, messages, **kwargs): | ||
| self.messages = messages | ||
| self.kwargs = kwargs | ||
| return [{"text": "[[ ## next_thought ## ]]\nDone.\n\n[[ ## completed ## ]]"}] | ||
|
|
||
|
|
||
| def test_history_renders_native_tool_messages_through_lm_message_path(): | ||
| adapter = dspy.ChatAdapter(use_native_function_calling=True) | ||
| signature = ( | ||
| dspy.Signature({}, "Do the task.") | ||
| .append("question", dspy.InputField(), type_=str) | ||
| .append("history", dspy.InputField(), type_=dspy.History) | ||
| .append("tools", dspy.InputField(), type_=list[dspy.Tool]) | ||
| .append("next_thought", dspy.OutputField(), type_=str) | ||
| .append("tool_calls", dspy.OutputField(), type_=dspy.ToolCalls) | ||
| ) | ||
| history = History(frames=[]) | ||
| history.append_inputs({"question": "What is 1+2?"}) | ||
| history.append_outputs( | ||
| { | ||
| "next_thought": "I should add the two numbers.", | ||
| "tool_calls": ToolCalls.from_dict_list( | ||
| [{"name": "add", "args": {"a": 1, "b": 2}, "id": "call_add"}] | ||
| ), | ||
| }, | ||
| observations=[Observation(value=3, source="tool", call_id="call_add", name="add")], | ||
| ) | ||
|
|
||
| lm = NativeToolLM() | ||
| adapter(lm, {}, signature, [], {"question": "What is 1+2?", "history": history, "tools": [Tool(add)]}) | ||
|
|
||
| assistant_message = next(message for message in lm.messages if message["role"] == "assistant") | ||
| tool_message = next(message for message in lm.messages if message["role"] == "tool") | ||
| final_user_message = lm.messages[-1] | ||
|
|
||
| assert assistant_message["content"] == "I should add the two numbers." | ||
| assert assistant_message["tool_calls"][0]["id"] == "call_add" | ||
| assert assistant_message["tool_calls"][0]["function"]["name"] == "add" | ||
| assert json.loads(assistant_message["tool_calls"][0]["function"]["arguments"]) == {"a": 1, "b": 2} | ||
| assert tool_message == {"role": "tool", "content": "3", "tool_call_id": "call_add", "name": "add"} | ||
| assert "What is 1+2?" not in final_user_message["content"] | ||
| assert "Respond with the corresponding output fields" in final_user_message["content"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_history_to_lm_messagesis still present but now silently uses the defaultuse_native_tool_calls=False. Any subclass or external caller that invokes this method will never get native tool-call rendering even when_uses_native_tool_callswould returnTrue. The method is also unused by the main execution path (plan_fieldscallsto_lm_messagesdirectly). Updating the signature keeps the helper consistent with the new behaviour.