Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion dspy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,23 @@

from dspy.evaluate import Evaluate # isort: skip
from dspy.clients import * # isort: skip
from dspy.adapters import Adapter, ChatAdapter, JSONAdapter, XMLAdapter, TwoStepAdapter, Image, Audio, File, History, Type, Tool, ToolCalls, Code, Reasoning # isort: skip
from dspy.adapters import (
Adapter,
ChatAdapter,
JSONAdapter,
XMLAdapter,
TwoStepAdapter,
Image,
Audio,
File,
History,
Type,
Tool,
ToolCalls,
ToolCallResults,
Code,
Reasoning,
) # isort: skip
from dspy.primitives.sandbox_serializable import SandboxSerializable # isort: skip
from dspy.utils.exceptions import ContextWindowExceededError
from dspy.utils.logging_utils import configure_dspy_loggers, disable_logging, enable_logging
Expand Down
3 changes: 2 additions & 1 deletion dspy/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dspy.adapters.chat_adapter import ChatAdapter
from dspy.adapters.json_adapter import JSONAdapter
from dspy.adapters.two_step_adapter import TwoStepAdapter
from dspy.adapters.types import Audio, Code, File, History, Image, Reasoning, Tool, ToolCalls, Type
from dspy.adapters.types import Audio, Code, File, History, Image, Reasoning, Tool, ToolCallResults, ToolCalls, Type
from dspy.adapters.xml_adapter import XMLAdapter

__all__ = [
Expand All @@ -19,5 +19,6 @@
"TwoStepAdapter",
"Tool",
"ToolCalls",
"ToolCallResults",
"Reasoning",
]
529 changes: 435 additions & 94 deletions dspy/adapters/base.py

Large diffs are not rendered by default.

42 changes: 27 additions & 15 deletions dspy/adapters/chat_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,23 @@ def __call__(
) -> list[dict[str, Any]]:
try:
return super().__call__(lm, lm_kwargs, signature, demos, inputs)
except Exception as e:
except Exception as err:
# fallback to JSONAdapter
from dspy.adapters.json_adapter import JSONAdapter

if (
isinstance(e, ContextWindowExceededError)
isinstance(err, ContextWindowExceededError)
or isinstance(self, JSONAdapter)
or not self.use_json_adapter_fallback
):
# On context window exceeded error, already using JSONAdapter, or use_json_adapter_fallback is False
# we don't want to retry with a different adapter. Raise the original error instead of the fallback error.
raise e
return JSONAdapter()(lm, lm_kwargs, signature, demos, inputs)
raise
return JSONAdapter(
callbacks=self.callbacks,
use_native_function_calling=self.use_native_function_calling,
native_response_types=self.native_response_types,
)(lm, lm_kwargs, signature, demos, inputs)

async def acall(
self,
Expand All @@ -94,25 +98,29 @@ async def acall(
) -> list[dict[str, Any]]:
try:
return await super().acall(lm, lm_kwargs, signature, demos, inputs)
except Exception as e:
except Exception as err:
# fallback to JSONAdapter
from dspy.adapters.json_adapter import JSONAdapter

if (
isinstance(e, ContextWindowExceededError)
isinstance(err, ContextWindowExceededError)
or isinstance(self, JSONAdapter)
or not self.use_json_adapter_fallback
):
# On context window exceeded error, already using JSONAdapter, or use_json_adapter_fallback is False
# we don't want to retry with a different adapter. Raise the original error instead of the fallback error.
raise e
return await JSONAdapter().acall(lm, lm_kwargs, signature, demos, inputs)
raise
return await JSONAdapter(
callbacks=self.callbacks,
use_native_function_calling=self.use_native_function_calling,
native_response_types=self.native_response_types,
).acall(lm, lm_kwargs, signature, demos, inputs)

def format_field_description(self, signature: type[Signature]) -> str:
return (
f"Your input fields are:\n{get_field_description_string(signature.input_fields)}\n"
f"Your output fields are:\n{get_field_description_string(signature.output_fields)}"
)
description = f"Your input fields are:\n{get_field_description_string(signature.input_fields)}"
if signature.output_fields:
description += f"\nYour output fields are:\n{get_field_description_string(signature.output_fields)}"
return description

def format_field_structure(self, signature: type[Signature]) -> str:
"""
Expand All @@ -132,8 +140,9 @@ def format_signature_fields_for_instructions(fields: dict[str, FieldInfo]):
)

parts.append(format_signature_fields_for_instructions(signature.input_fields))
parts.append(format_signature_fields_for_instructions(signature.output_fields))
parts.append("[[ ## completed ## ]]\n")
if signature.output_fields:
parts.append(format_signature_fields_for_instructions(signature.output_fields))
parts.append("[[ ## completed ## ]]\n")
return "\n\n".join(parts).strip()

def format_task_description(self, signature: type[Signature]) -> str:
Expand Down Expand Up @@ -164,7 +173,7 @@ def format_user_message_content(
messages.append(suffix)
return "\n\n".join(messages).strip()

def user_message_output_requirements(self, signature: type[Signature]) -> str:
def user_message_output_requirements(self, signature: type[Signature]) -> str | None:
"""Returns a simplified format reminder for the language model.

In chat-based interactions, language models may lose track of the required output format
Expand All @@ -182,6 +191,9 @@ def user_message_output_requirements(self, signature: type[Signature]) -> str:
for inline reminders within chat messages.
"""

if not signature.output_fields:
return None

def type_info(v):
if v.annotation is not str:
return f" (must be formatted as a valid Python {get_annotation_name(v.annotation)})"
Expand Down
62 changes: 48 additions & 14 deletions dspy/adapters/json_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,18 @@ def _has_open_ended_mapping(signature: SignatureMeta) -> bool:


class JSONAdapter(ChatAdapter):
def __init__(self, callbacks: list[BaseCallback] | None = None, use_native_function_calling: bool = True):
def __init__(
self,
callbacks: list[BaseCallback] | None = None,
use_native_function_calling: bool = True,
native_response_types: list[type[type]] | None = None,
):
# JSONAdapter uses native function calling by default.
super().__init__(callbacks=callbacks, use_native_function_calling=use_native_function_calling)
super().__init__(
callbacks=callbacks,
use_native_function_calling=use_native_function_calling,
native_response_types=native_response_types,
)

def _json_adapter_call_common(self, lm, lm_kwargs, signature, demos, inputs, call_fn):
"""Common call logic to be used for both sync and async calls."""
Expand All @@ -49,7 +58,11 @@ def _json_adapter_call_common(self, lm, lm_kwargs, signature, demos, inputs, cal

has_tool_calls = any(field.annotation == ToolCalls for field in signature.output_fields.values())

if _has_open_ended_mapping(signature) or (not self.use_native_function_calling and has_tool_calls) or not lm.supports_response_schema:
if (
_has_open_ended_mapping(signature)
or (not self.use_native_function_calling and has_tool_calls)
or not lm.supports_response_schema
):
# We found that structured output mode doesn't work well with dspy.ToolCalls as output field.
# So we fall back to json mode if native function calling is disabled and ToolCalls is present.
lm_kwargs["response_format"] = {"type": "json_object"}
Expand All @@ -68,10 +81,6 @@ def __call__(
return result

try:
structured_output_model = _get_structured_outputs_response_format(
signature, self.use_native_function_calling
)
lm_kwargs["response_format"] = structured_output_model
return super().__call__(lm, lm_kwargs, signature, demos, inputs)
except Exception:
logger.warning("Failed to use structured output format, falling back to JSON mode.")
Expand All @@ -91,16 +100,37 @@ async def acall(
return await result

try:
structured_output_model = _get_structured_outputs_response_format(
signature, self.use_native_function_calling
)
lm_kwargs["response_format"] = structured_output_model
return await super().acall(lm, lm_kwargs, signature, demos, inputs)
except Exception:
logger.warning("Failed to use structured output format, falling back to JSON mode.")
lm_kwargs["response_format"] = {"type": "json_object"}
return await super().acall(lm, lm_kwargs, signature, demos, inputs)

def _prepare_request_kwargs(self, lm: BaseLM, state) -> dict[str, Any]:
request_kwargs = dict(state.lm_kwargs)
if "response_format" in request_kwargs or "response_format" not in lm.supported_params:
return request_kwargs
if not state.render_signature.output_fields:
return request_kwargs

has_tool_calls = any(
self._annotation_includes(field.annotation, ToolCalls)
for field in state.source_signature.output_fields.values()
)
if (
_has_open_ended_mapping(state.render_signature)
or (not self.use_native_function_calling and has_tool_calls)
or not lm.supports_response_schema
):
request_kwargs["response_format"] = {"type": "json_object"}
return request_kwargs

request_kwargs["response_format"] = _get_structured_outputs_response_format(
state.render_signature,
self.use_native_function_calling,
)
return request_kwargs

def format_field_structure(self, signature: type[Signature]) -> str:
parts = []
parts.append("All interactions will be structured in the following way, with the appropriate values filled in.")
Expand All @@ -116,11 +146,15 @@ def format_signature_fields_for_instructions(fields: dict[str, FieldInfo], role:

parts.append("Inputs will have the following structure:")
parts.append(format_signature_fields_for_instructions(signature.input_fields, role="user"))
parts.append("Outputs will be a JSON object with the following fields.")
parts.append(format_signature_fields_for_instructions(signature.output_fields, role="assistant"))
if signature.output_fields:
parts.append("Outputs will be a JSON object with the following fields.")
parts.append(format_signature_fields_for_instructions(signature.output_fields, role="assistant"))
return "\n\n".join(parts).strip()

def user_message_output_requirements(self, signature: type[Signature]) -> str:
def user_message_output_requirements(self, signature: type[Signature]) -> str | None:
if not signature.output_fields:
return None

def type_info(v):
return (
f" (must be formatted as a valid Python {get_annotation_name(v.annotation)})"
Expand Down
1 change: 1 addition & 0 deletions dspy/adapters/two_step_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ def format_user_message_content(
inputs: dict[str, Any],
prefix: str = "",
suffix: str = "",
main_request: bool = False,
) -> str:
parts = [prefix]

Expand Down
4 changes: 2 additions & 2 deletions dspy/adapters/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
from dspy.adapters.types.history import History
from dspy.adapters.types.image import Image
from dspy.adapters.types.reasoning import Reasoning
from dspy.adapters.types.tool import Tool, ToolCalls
from dspy.adapters.types.tool import Tool, ToolCallResults, ToolCalls

__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "Code", "Reasoning"]
__all__ = ["History", "Image", "Audio", "File", "Type", "Tool", "ToolCalls", "ToolCallResults", "Code", "Reasoning"]
50 changes: 47 additions & 3 deletions dspy/adapters/types/history.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
from typing import Any
from typing import Any, Callable

import pydantic
from pydantic import Field

from dspy.adapters.types.tool import ToolCallResults, ToolCalls
from dspy.adapters.utils import serialize_for_json


class History(pydantic.BaseModel):
Expand Down Expand Up @@ -58,11 +62,51 @@ class MySignature(dspy.Signature):
```
"""

messages: list[dict[str, Any]]
messages: list[dict[str, Any]] = Field(default_factory=list)

model_config = pydantic.ConfigDict(
frozen=True,
str_strip_whitespace=True,
validate_assignment=True,
extra="forbid",
)

def __init__(self, *args: Any, compact_fn: Callable[["History"], None] | None = None, **kwargs: Any):
super().__init__(*args, **kwargs)
object.__setattr__(self, "_compact_fn", compact_fn)

@pydantic.model_serializer()
def serialize_model(self) -> dict[str, Any]:
return {"messages": [self._serialize_message(message) for message in self.messages]}

def compact_if_needed(self) -> None:
compact_fn = getattr(self, "_compact_fn", None)
if compact_fn is not None:
compact_fn(self)

def append(self, message: dict[str, Any]) -> dict[str, Any]:
message = dict(message)
self.messages.append(message)
return message

@staticmethod
def _serialize_message(message: dict[str, Any]) -> dict[str, Any]:
serialized = {}
for key, value in message.items():
if isinstance(value, ToolCalls):
serialized[key] = {
"tool_calls": [
{
"name": tool_call.name,
"args": serialize_for_json(tool_call.args),
**({"id": tool_call.id} if tool_call.id is not None else {}),
}
for tool_call in value.tool_calls
]
}
elif isinstance(value, ToolCallResults):
serialized[key] = value.format()
elif hasattr(value, "model_dump"):
serialized[key] = value.model_dump()
else:
serialized[key] = value
return serialized
Loading