diff --git a/dspy/adapters/base.py b/dspy/adapters/base.py index efab5c29b3..88700d181a 100644 --- a/dspy/adapters/base.py +++ b/dspy/adapters/base.py @@ -1,23 +1,20 @@ import json import logging -from typing import Any, get_origin +from dataclasses import dataclass +from dataclasses import field as dataclass_field +from typing import Any, get_args, get_origin import json_repair from dspy.adapters._legacy_type_markers import ( - _expand_legacy_custom_type_markers_in_chat_message, _expand_legacy_custom_type_markers_in_lm_message, ) from dspy.adapters.types import History, Type from dspy.adapters.types.reasoning import Reasoning from dspy.adapters.types.tool import Tool, ToolCalls from dspy.clients.base_lm import BaseLM -from dspy.clients.openai_format import ( - legacy_outputs_from_lm_response, - lm_response_from_legacy_outputs, - to_openai_chat_request, -) -from dspy.core.types import LMMessage, LMRequest, LMResponse +from dspy.clients.openai_format import lm_response_from_legacy_outputs, message_to_openai_chat, to_openai_chat_request +from dspy.core.types import LMMessage, LMRequest, LMResponse, LMToolSpec from dspy.experimental import Citations from dspy.signatures.signature import Signature from dspy.utils.callback import BaseCallback, with_callbacks @@ -28,6 +25,17 @@ _DEFAULT_NATIVE_RESPONSE_TYPES = [Citations, Reasoning] +@dataclass +class _AdapterRequestState: + source_signature: type[Signature] + render_signature: type[Signature] + inputs: dict[str, Any] + lm_kwargs: dict[str, Any] + tools: list[LMToolSpec] = dataclass_field(default_factory=list) + prepared_messages: list[LMMessage] = dataclass_field(default_factory=list) + hidden_output_fields: tuple[str, ...] = () + + class Adapter: """Base Adapter class. @@ -202,24 +210,19 @@ def _call_postprocess( return values - def _render_request( - self, - lm: BaseLM, - lm_kwargs: dict[str, Any], - messages: list[LMMessage | dict[str, Any]], - ) -> LMRequest: - """Build the normalized LM request for the current adapter call path. - - TODO(adapters-plan): This currently receives already-rendered messages. - Once planning lands, this should render from `_AdapterPlan` and apply - planned message/part insertions before creating `LMRequest`. - """ + def _render_request(self, lm: BaseLM, state: _AdapterRequestState, demos: list[dict[str, Any]]) -> LMRequest: + messages = self._format_request_with_callbacks(state, demos) + request_kwargs = self._prepare_request_kwargs(lm, state) return LMRequest.from_call( model=lm.model, messages=self._coerce_lm_messages(messages), - **lm_kwargs, + tools=state.tools, + **request_kwargs, ) + def _prepare_request_kwargs(self, lm: BaseLM, state: _AdapterRequestState) -> dict[str, Any]: + return dict(state.lm_kwargs) + def _call_lm(self, lm: BaseLM, request: LMRequest) -> LMResponse: """Call current `BaseLM` through the normalized request/response boundary. @@ -299,15 +302,140 @@ def _chat_dict_to_lm_message(self, message: dict[str, Any]) -> LMMessage: message["content"] = sanitized return LMMessage(**message) - def _normalize_legacy_outputs(self, outputs: list[dict[str, Any] | str | None], request: LMRequest) -> LMResponse: + def _normalize_legacy_outputs( + self, outputs: list[dict[str, Any] | str | None] | LMResponse, request: LMRequest + ) -> LMResponse: """Convert current `BaseLM` outputs into a normalized `LMResponse`. TODO(language-models): Current `BaseLM` returns `list[str | dict | None]`. Future LMs should return `LMResponse` directly, making this method a compatibility-only path for old/custom LMs. """ + if isinstance(outputs, LMResponse): + return outputs return lm_response_from_legacy_outputs(outputs, request) + def _prepare_request_state( + self, + lm: BaseLM, + lm_kwargs: dict[str, Any], + signature: type[Signature], + inputs: dict[str, Any], + ) -> _AdapterRequestState: + """Build the normalized state for one adapter request. + + Preprocessing may rewrite the signature, inputs, and LM kwargs, so this copies caller-owned data before + applying it and records both the source signature used for final outputs and the render signature used for + prompt formatting. + """ + copied_inputs = dict(inputs) + copied_lm_kwargs = dict(lm_kwargs) + render_signature = self._call_preprocess(lm, copied_lm_kwargs, signature, copied_inputs) + tools = self._extract_lm_tools(copied_lm_kwargs) + + hidden_output_fields = tuple( + field_name for field_name in signature.output_fields if field_name not in render_signature.output_fields + ) + return _AdapterRequestState( + source_signature=signature, + render_signature=render_signature, + inputs=copied_inputs, + lm_kwargs=copied_lm_kwargs, + tools=tools, + hidden_output_fields=hidden_output_fields, + ) + + def _extract_lm_tools(self, lm_kwargs: dict[str, Any]) -> list[LMToolSpec]: + """Remove native LM tool specs from LM kwargs and normalize them for request rendering.""" + raw_tools = lm_kwargs.pop("tools", None) + if not raw_tools: + return [] + tools = raw_tools if isinstance(raw_tools, list) else [raw_tools] + return [self._coerce_lm_tool_spec(tool) for tool in tools] + + @staticmethod + def _coerce_lm_tool_spec(tool: Any) -> LMToolSpec: + """Convert supported tool shapes into the internal LMToolSpec representation.""" + if isinstance(tool, LMToolSpec): + return tool + if hasattr(tool, "to_lm_tool_spec"): + return tool.to_lm_tool_spec() + if isinstance(tool, dict): + if "function" in tool: + function = tool["function"] + provider_data = {key: value for key, value in tool.items() if key not in {"type", "function"}} + return LMToolSpec( + name=function.get("name"), + description=function.get("description"), + parameters=function.get("parameters", {}), + provider_data=provider_data, + ) + return LMToolSpec(**tool) + raise TypeError(f"Cannot convert {type(tool)!r} to LMToolSpec.") + + def _parse_response(self, state: _AdapterRequestState, response: LMResponse) -> list[dict[str, Any]]: + """Parse normalized LM outputs against the original source signature. + + Text is parsed with the render signature, while native response fields and tool call outputs are restored onto + the source signature so request-time signature rewrites do not drop fields from the final value. + """ + values = [] + tool_call_output_field_name = self._get_tool_call_output_field_name(state.source_signature) + + for output in response.outputs: + if output.metadata.get("empty_legacy_outputs"): + continue + + value: dict[str, Any] = {} + parsed_any = False + + if output.text and state.render_signature.output_fields: + value.update(self.parse(state.render_signature, output.text)) + parsed_any = True + + if output.tool_calls and tool_call_output_field_name: + value[tool_call_output_field_name] = ToolCalls.from_dict_list( + [ + { + "name": tool_call.name, + "args": tool_call.args, + } + for tool_call in output.tool_calls + ] + ) + parsed_any = True + + output_dict = output.to_output_dict() + legacy_output = output.provider_output if output.provider_output is not None else output_dict + for name, field_info in state.source_signature.output_fields.items(): + if ( + isinstance(field_info.annotation, type) + and field_info.annotation in self.native_response_types + and issubclass(field_info.annotation, Type) + ): + parsed_value = field_info.annotation.parse_lm_response(legacy_output) + if parsed_value is not None: + value[name] = parsed_value + parsed_any = True + + for field_name in state.source_signature.output_fields: + value.setdefault(field_name, None) + + if not parsed_any: + raise AdapterParseError( + adapter_name=type(self).__name__, + signature=state.source_signature, + lm_response=str(output_dict), + message="The LM returned an empty or null response.", + ) + + if output.logprobs is not None: + value["logprobs"] = output.logprobs + + values.append(value) + + return values + def __call__( self, lm: BaseLM, @@ -332,16 +460,10 @@ def __call__( List of dictionaries representing parsed LM responses. Each dictionary contains keys matching the signature's output field names. For multiple generations (n > 1), returns multiple dictionaries. """ - processed_signature = self._call_preprocess(lm, lm_kwargs, signature, inputs) - messages = self.format(processed_signature, demos, inputs) - request = self._render_request(lm, lm_kwargs, messages) + state = self._prepare_request_state(lm, lm_kwargs, signature, inputs) + request = self._render_request(lm, state, demos) response = self._call_lm(lm, request) - # TODO(adapters-response): We normalize at the LM boundary, but still - # convert back to legacy postprocess dictionaries here to keep this PR - # behavior-preserving. Replace with direct `LMResponse` parsing once the - # explicit adapter plan exists. - outputs = legacy_outputs_from_lm_response(response) - return self._call_postprocess(processed_signature, signature, outputs, lm, lm_kwargs) + return self._parse_response(state, response) async def acall( self, @@ -351,14 +473,10 @@ async def acall( demos: list[dict[str, Any]], inputs: dict[str, Any], ) -> list[dict[str, Any]]: - processed_signature = self._call_preprocess(lm, lm_kwargs, signature, inputs) - messages = self.format(processed_signature, demos, inputs) - request = self._render_request(lm, lm_kwargs, messages) + state = self._prepare_request_state(lm, lm_kwargs, signature, inputs) + request = self._render_request(lm, state, demos) response = await self._acall_lm(lm, request) - # TODO(adapters-response): Keep in sync with `__call__()` until both use - # direct `LMResponse` parsing. - outputs = legacy_outputs_from_lm_response(response) - return self._call_postprocess(processed_signature, signature, outputs, lm, lm_kwargs) + return self._parse_response(state, response) def format( self, @@ -405,35 +523,55 @@ def format( Returns: A list of multiturn messages as expected by the LM. """ - inputs_copy = dict(inputs) + state = _AdapterRequestState( + source_signature=signature, + render_signature=signature, + inputs=dict(inputs), + lm_kwargs={}, + ) + return [message_to_openai_chat(message) for message in self._format_request_messages(state, demos)] - # If the signature and inputs have conversation history, we need to format the conversation history and - # remove the history field from the signature. - history_field_name = self._get_history_field_name(signature) - if history_field_name: - # In order to format the conversation history, we need to remove the history field from the signature. - signature_without_history = signature.delete(history_field_name) - conversation_history = self.format_conversation_history( - signature_without_history, - history_field_name, - inputs_copy, - ) + @with_callbacks + def _format_request_with_callbacks(self, state: _AdapterRequestState, demos: list[dict[str, Any]]) -> list[LMMessage]: + """Render state-aware messages while preserving the existing adapter format callback.""" + return self._format_request_messages(state, demos) - messages = [] - system_message = self.format_system_message(signature) - messages.append({"role": "system", "content": system_message}) - messages.extend(self.format_demos(signature, demos)) + def _format_request_messages( + self, + state: _AdapterRequestState, + demos: list[dict[str, Any]], + ) -> list[LMMessage]: + """Render LM messages from prepared request state. + + The render signature is used for system instructions and demos. If the signature includes a history field, that + field is removed only from the per-turn input signature so history is expanded into messages instead of rendered + again as raw current input. + """ + render_signature = state.render_signature + inputs_copy = dict(state.inputs) + conversation_history: list[LMMessage] = [] + input_signature = render_signature + + history_field_name = self._get_history_field_name(render_signature) if history_field_name: - # Conversation history and current input - content = self.format_user_message_content(signature_without_history, inputs_copy, main_request=True) - messages.extend(conversation_history) - messages.append({"role": "user", "content": content}) - else: - # Only current input - content = self.format_user_message_content(signature, inputs_copy, main_request=True) - messages.append({"role": "user", "content": content}) + input_signature = render_signature.delete(history_field_name) + history_obj = inputs_copy.pop(history_field_name, None) + if history_obj is not None: + conversation_history = self.format_history( + history_obj, + input_signature, + use_native_tool_calls=bool(state.tools), + ) + + messages: list[LMMessage | dict[str, Any]] = [ + {"role": "system", "content": self.format_system_message(render_signature)} + ] - return [_expand_legacy_custom_type_markers_in_chat_message(message) for message in messages] + messages.extend(self._format_demos_as_lm_messages(render_signature, demos)) + messages.extend(conversation_history) + messages.extend(state.prepared_messages) + messages.extend(self._format_input_messages(input_signature, inputs_copy, main_request=True)) + return self._coerce_lm_messages(messages) def format_system_message(self, signature: type[Signature]) -> str: """Format the system message for the LM call. @@ -546,6 +684,9 @@ def format_demos(self, signature: type[Signature], demos: list[dict[str, Any]]) Returns: A list of multiturn messages. """ + return [message_to_openai_chat(message) for message in self._format_demos_as_lm_messages(signature, demos)] + + def _format_demos_as_lm_messages(self, signature: type[Signature], demos: list[dict[str, Any]]) -> list[LMMessage]: complete_demos = [] incomplete_demos = [] @@ -563,45 +704,94 @@ def format_demos(self, signature: type[Signature], demos: list[dict[str, Any]]) # We only keep incomplete demos that have at least one input and one output field incomplete_demos.append(demo) - messages = [] + messages: list[LMMessage] = [] incomplete_demo_prefix = "This is an example of the task, though some input or output fields are not supplied." for demo in incomplete_demos: - messages.append( - { - "role": "user", - "content": self.format_user_message_content(signature, demo, prefix=incomplete_demo_prefix), - } + messages.extend( + self._format_input_messages( + signature, + demo, + main_request=False, + prefix=incomplete_demo_prefix, + ) ) - messages.append( - { - "role": "assistant", - "content": self.format_assistant_message_content( - signature, demo, missing_field_message="Not supplied for this particular example. " - ), - } + messages.extend( + self._format_output_messages( + signature, + demo, + missing_field_message="Not supplied for this particular example. ", + ) ) for demo in complete_demos: - messages.append({"role": "user", "content": self.format_user_message_content(signature, demo)}) - messages.append( - { - "role": "assistant", - "content": self.format_assistant_message_content( - signature, demo, missing_field_message="Not supplied for this conversation history message. " - ), - } + messages.extend(self._format_input_messages(signature, demo, main_request=False)) + messages.extend( + self._format_output_messages( + signature, + demo, + missing_field_message="Not supplied for this conversation history message. ", + ) ) return messages - def _get_history_field_name(self, signature: type[Signature]) -> bool: + def _format_input_messages( + self, + signature: type[Signature], + inputs: dict[str, Any], + *, + main_request: bool, + prefix: str = "", + suffix: str = "", + ) -> list[LMMessage]: + regular_inputs = self._drop_absent_optional_inputs(signature, dict(inputs)) + content = self.format_user_message_content( + signature, + regular_inputs, + prefix=prefix, + suffix=suffix, + main_request=main_request, + ) + return [LMMessage.model_validate({"role": "user", "content": content})] if self._has_content(content) else [] + + def _format_output_messages( + self, + signature: type[Signature], + outputs: dict[str, Any], + *, + missing_field_message: str | None, + ) -> list[LMMessage]: + content = self.format_assistant_message_content( + signature, + outputs, + missing_field_message=missing_field_message, + ) + return ( + [LMMessage.model_validate({"role": "assistant", "content": content})] if self._has_content(content) else [] + ) + + @staticmethod + def _drop_absent_optional_inputs(signature: type[Signature], inputs: dict[str, Any]) -> dict[str, Any]: + return { + key: value + for key, value in inputs.items() + if not (value is None and key in signature.input_fields and signature.input_fields[key].default is None) + } + + @staticmethod + def _has_content(content: Any) -> bool: + if isinstance(content, str): + return bool(content.strip()) + return bool(content) + + def _get_history_field_name(self, signature: type[Signature]) -> str | None: for name, field in signature.input_fields.items(): if field.annotation == History: return name return None - def _get_tool_call_input_field_name(self, signature: type[Signature]) -> bool: + def _get_tool_call_input_field_name(self, signature: type[Signature]) -> str | None: for name, field in signature.input_fields.items(): # Look for annotation `list[dspy.Tool]` or `dspy.Tool` origin = get_origin(field.annotation) @@ -611,12 +801,52 @@ def _get_tool_call_input_field_name(self, signature: type[Signature]) -> bool: return name return None - def _get_tool_call_output_field_name(self, signature: type[Signature]) -> bool: + def _get_tool_call_output_field_name(self, signature: type[Signature]) -> str | None: for name, field in signature.output_fields.items(): - if field.annotation == ToolCalls: + if self._annotation_includes(field.annotation, ToolCalls): return name return None + @classmethod + def _annotation_includes(cls, annotation: Any, target: type) -> bool: + if annotation is target: + return True + return any(cls._annotation_includes(arg, target) for arg in get_args(annotation)) + + def force_tool_call_config(self, tool_name: str) -> dict[str, Any]: + if not self.use_native_function_calling: + return {} + return {"tool_choice": {"mode": "required", "allowed": [tool_name]}} + + def format_history( + self, + history: History, + signature: type[Signature], + *, + use_native_tool_calls: bool = False, + ) -> list[LMMessage]: + history = history if isinstance(history, History) else History.model_validate(history) + messages: list[LMMessage] = [] + + for entry in history.messages: + if not isinstance(entry, dict): + continue + + input_values = {key: value for key, value in entry.items() if key in signature.input_fields} + output_values = {key: value for key, value in entry.items() if key in signature.output_fields} + if input_values: + messages.extend(self._format_input_messages(signature, input_values, main_request=False)) + if output_values: + messages.extend( + self._format_output_messages( + signature, + output_values, + missing_field_message="Not supplied for this conversation history message. ", + ) + ) + + return messages + def format_conversation_history( self, signature: type[Signature], @@ -640,25 +870,10 @@ def format_conversation_history( if conversation_history is None: return [] - messages = [] - for message in conversation_history: - messages.append( - { - "role": "user", - "content": self.format_user_message_content(signature, message), - } - ) - messages.append( - { - "role": "assistant", - "content": self.format_assistant_message_content(signature, message), - } - ) - # Remove the history field from the inputs del inputs[history_field_name] - - return messages + history = History(messages=conversation_history) + return [message_to_openai_chat(message) for message in self.format_history(history, signature)] def parse(self, signature: type[Signature], completion: str) -> dict[str, Any]: """Parse the LM output into a dictionary of the output fields. diff --git a/dspy/adapters/two_step_adapter.py b/dspy/adapters/two_step_adapter.py index 0e427dea25..be81139455 100644 --- a/dspy/adapters/two_step_adapter.py +++ b/dspy/adapters/two_step_adapter.py @@ -9,6 +9,7 @@ from dspy.clients.base_lm import BaseLM from dspy.signatures.field import InputField from dspy.signatures.signature import Signature, make_signature +from dspy.utils.callback import with_callbacks """ NOTE/TODO/FIXME: @@ -61,6 +62,20 @@ def format( Returns: A list of messages to be passed to the main LM. """ + return self._format_main_request_messages(signature, demos, inputs) + + @with_callbacks + def _format_request_with_callbacks(self, state, demos: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Render the first-stage prompt while preserving the adapter format callback.""" + return self._format_main_request_messages(state.render_signature, demos, state.inputs) + + def _format_main_request_messages( + self, + signature: type[Signature], + demos: list[dict[str, Any]], + inputs: dict[str, Any], + ) -> list[dict[str, Any]]: + """Build the natural-language first-stage prompt shared by format and the state-aware render path.""" messages = [] # Create a task description for the main LM @@ -180,6 +195,7 @@ def format_user_message_content( inputs: dict[str, Any], prefix: str = "", suffix: str = "", + main_request: bool = False, ) -> str: parts = [prefix] diff --git a/dspy/utils/callback.py b/dspy/utils/callback.py index cd9094f7e0..71cfc4220f 100644 --- a/dspy/utils/callback.py +++ b/dspy/utils/callback.py @@ -356,7 +356,7 @@ def _get_on_start_handler(callback: BaseCallback, instance: Any, fn: Callable) - return callback.on_evaluate_start if isinstance(instance, dspy.Adapter): - if fn.__name__ == "format": + if fn.__name__ in ("format", "_format_request_with_callbacks"): return callback.on_adapter_format_start elif fn.__name__ == "parse": return callback.on_adapter_parse_start @@ -378,7 +378,7 @@ def _get_on_end_handler(callback: BaseCallback, instance: Any, fn: Callable) -> return callback.on_evaluate_end if isinstance(instance, (dspy.Adapter)): - if fn.__name__ == "format": + if fn.__name__ in ("format", "_format_request_with_callbacks"): return callback.on_adapter_format_end elif fn.__name__ == "parse": return callback.on_adapter_parse_end diff --git a/tests/adapters/test_adapter_base.py b/tests/adapters/test_adapter_base.py new file mode 100644 index 0000000000..44b8ac86b2 --- /dev/null +++ b/tests/adapters/test_adapter_base.py @@ -0,0 +1,182 @@ +from typing import ClassVar + +import dspy +from dspy.core.types import LMOutput, LMResponse, LMTextPart + + +def search(query: str) -> str: + return query + + +def add(a: int, b: int) -> int: + return a + b + + +class NativeToolLM: + model = "openai/gpt-5-nano" + model_type = "chat" + kwargs: ClassVar[dict] = {} + supported_params = frozenset() + supports_function_calling = True + supports_reasoning = False + supports_response_schema = False + + def __init__(self, output=None): + self.output = output + self.messages = None + self.kwargs = None + + def __call__(self, messages, **kwargs): + self.messages = messages + self.kwargs = kwargs + return [self.output] + + +def test_prepare_request_state_copies_kwargs_and_extracts_tools(): + class ToolSignature(dspy.Signature): + question: str = dspy.InputField() + tools: list[dspy.Tool] = dspy.InputField() + tool_calls: dspy.ToolCalls = dspy.OutputField() + + adapter = dspy.Adapter(use_native_function_calling=True) + lm_kwargs = {"temperature": 0.2} + inputs = {"question": "Q?", "tools": [dspy.Tool(search)]} + + state = adapter._prepare_request_state(NativeToolLM(), lm_kwargs, ToolSignature, inputs) + + assert lm_kwargs == {"temperature": 0.2} + assert inputs["tools"][0].name == "search" + assert "tools" not in state.lm_kwargs + assert state.tools[0].name == "search" + assert "tools" not in state.render_signature.input_fields + assert "tool_calls" not in state.render_signature.output_fields + assert state.hidden_output_fields == ("tool_calls",) + + +def test_prepare_request_state_preserves_normal_signature_and_copies_data(): + class QASignature(dspy.Signature): + question: str = dspy.InputField() + answer: str = dspy.OutputField() + + adapter = dspy.Adapter() + lm_kwargs = {"temperature": 0.2, "metadata": {"trace_id": "abc"}} + inputs = {"question": "Q?"} + + state = adapter._prepare_request_state(NativeToolLM(), lm_kwargs, QASignature, inputs) + + assert state.source_signature is QASignature + assert state.render_signature is QASignature + assert state.inputs == inputs + assert state.inputs is not inputs + assert state.lm_kwargs == lm_kwargs + assert state.lm_kwargs is not lm_kwargs + assert state.tools == [] + assert state.prepared_messages == [] + assert state.hidden_output_fields == () + assert lm_kwargs == {"temperature": 0.2, "metadata": {"trace_id": "abc"}} + assert inputs == {"question": "Q?"} + + +def test_render_request_normal_state_preserves_messages_and_kwargs(): + class QASignature(dspy.Signature): + question: str = dspy.InputField() + answer: str = dspy.OutputField() + + adapter = dspy.ChatAdapter() + lm = NativeToolLM() + state = adapter._prepare_request_state( + lm, + {"temperature": 0.7, "n": 2, "custom_option": "value"}, + QASignature, + {"question": "Q2?"}, + ) + + request = adapter._render_request(lm, state, demos=[{"question": "Q1?", "answer": "A1"}]) + + assert request.model == lm.model + assert request.tools == [] + assert request.config.temperature == 0.7 + assert request.config.n == 2 + assert request.config.extensions == {"custom_option": "value"} + assert [message.role for message in request.messages] == ["system", "user", "assistant", "user"] + assert "Q1?" in request.messages[1].text + assert "A1" in request.messages[2].text + assert "Q2?" in request.messages[3].text + + +def test_render_request_normal_state_expands_history_without_mutating_inputs(): + class HistorySignature(dspy.Signature): + history: dspy.History = dspy.InputField() + question: str = dspy.InputField() + answer: str = dspy.OutputField() + + adapter = dspy.ChatAdapter() + lm = NativeToolLM() + history = dspy.History(messages=[{"question": "What is the capital of France?", "answer": "Paris"}]) + inputs = {"history": history, "question": "What country is it in?"} + + state = adapter._prepare_request_state(lm, {}, HistorySignature, inputs) + request = adapter._render_request(lm, state, demos=[]) + + assert inputs == {"history": history, "question": "What country is it in?"} + assert [message.role for message in request.messages] == ["system", "user", "assistant", "user"] + assert "What is the capital of France?" in request.messages[1].text + assert "Paris" in request.messages[2].text + assert "What country is it in?" in request.messages[3].text + assert "[[ ## history ## ]]" not in request.messages[3].text + assert "Paris" not in request.messages[3].text + + +def test_parse_response_normal_state_parses_text_output_and_logprobs(): + class QASignature(dspy.Signature): + question: str = dspy.InputField() + answer: str = dspy.OutputField() + + adapter = dspy.ChatAdapter() + state = adapter._prepare_request_state(NativeToolLM(), {}, QASignature, {"question": "Q?"}) + response = LMResponse( + model="dummy", + outputs=[ + LMOutput( + parts=[LMTextPart(text="[[ ## answer ## ]]\nA\n\n[[ ## completed ## ]]")], + logprobs={"tokens": ["A"]}, + ) + ], + ) + + assert adapter._parse_response(state, response) == [{"answer": "A", "logprobs": {"tokens": ["A"]}}] + + +def test_native_tool_response_can_combine_visible_text_and_tool_calls(): + class ToolSignature(dspy.Signature): + question: str = dspy.InputField() + tools: list[dspy.Tool] = dspy.InputField() + answer: str = dspy.OutputField() + tool_calls: dspy.ToolCalls = dspy.OutputField() + + lm = NativeToolLM( + { + "text": "[[ ## answer ## ]]\nworking\n\n[[ ## completed ## ]]", + "tool_calls": [ + { + "id": "call_add", + "type": "function", + "function": {"name": "add", "arguments": '{"a": 1, "b": 2}'}, + } + ], + } + ) + + result = dspy.ChatAdapter(use_native_function_calling=True)( + lm, + {}, + ToolSignature, + [], + {"question": "What is 1+2?", "tools": [dspy.Tool(add)]}, + )[0] + + assert result["answer"] == "working" + assert result["tool_calls"].tool_calls[0].name == "add" + assert result["tool_calls"].tool_calls[0].args == {"a": 1, "b": 2} + assert "tools" in lm.kwargs + assert lm.kwargs["tools"][0]["function"]["name"] == "add"