Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/check_alembic_heads.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check Alembic has exactly one head

set -e
Expand Down
4 changes: 4 additions & 0 deletions src/xagent/core/agent/context/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ def get_messages_for_llm(

for message in visible_messages:
message_dict = message.to_dict()
if message.role == "assistant":
provider_state = message.metadata.get("_xagent_provider_state")
if isinstance(provider_state, dict):
message_dict["_xagent_provider_state"] = provider_state
waiting_response = message.metadata.get("response_to_waiting_for_user")
if message_dict.get("role") == "user" and isinstance(
waiting_response, dict
Expand Down
19 changes: 19 additions & 0 deletions src/xagent/core/agent/pattern/react/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,12 +423,16 @@ async def _run_tool_calling_loop(
assistant_content = normalized.get("content")
tool_calls = normalized.get("tool_calls", [])
if assistant_content is not None or normalized.get("tool_calls"):
metadata = (
self._provider_state_for_context(normalized) if tool_calls else {}
)
context.add_assistant_message(
assistant_content or "",
tool_calls=[
self._tool_call_for_context(tool_call)
for tool_call in tool_calls
],
**({"metadata": metadata} if metadata else {}),
)

if answer_streamer is not None:
Expand Down Expand Up @@ -816,6 +820,21 @@ def _normalize_llm_response(self, response: Any) -> dict[str, Any]:
"raw": response,
}

def _provider_state_for_context(
self, normalized_response: dict[str, Any]
) -> dict[str, Any]:
raw = normalized_response.get("raw")
marker_key = "_xagent_provider_state"
if isinstance(raw, dict):
raw_provider_state = raw.get(marker_key)
if isinstance(raw_provider_state, dict):
return {marker_key: raw_provider_state}
if marker_key in normalized_response and isinstance(
normalized_response[marker_key], dict
):
return {marker_key: normalized_response[marker_key]}
return {}

def _normalize_tool_calls(self, tool_calls: list[Any]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for index, tool_call in enumerate(tool_calls):
Expand Down
29 changes: 28 additions & 1 deletion src/xagent/core/agent/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ async def consume_stream() -> Any:
content_parts: list[str] = []
tool_call_chunks: dict[int, dict[str, Any]] = {}
usage_payload: dict[str, Any] = {}
provider_payload: dict[str, Any] = {}
saw_payload_chunk = False
async for chunk in stream_chat(**kwargs):
await self._raise_if_interrupted("interrupted during LLM stream")
Expand All @@ -158,6 +159,7 @@ async def consume_stream() -> Any:
chunk_usage = self._chunk_usage(chunk)
if chunk_usage:
self._merge_usage(usage_payload, chunk_usage)
self._merge_provider_payload(provider_payload, chunk)
if on_chunk is not None:
await self._maybe_await(on_chunk(chunk))

Expand All @@ -172,14 +174,21 @@ async def consume_stream() -> Any:
}
if usage_payload:
response["usage"] = usage_payload
if provider_payload:
response.update(provider_payload)
return response
if not saw_payload_chunk:
return await self.run_llm_call(llm, **kwargs)
if usage_payload:
return {
response = {
"content": content,
"usage": usage_payload,
}
if provider_payload:
response.update(provider_payload)
return response
if provider_payload:
return {"content": content, **provider_payload}
return content

task: asyncio.Future[Any] = asyncio.ensure_future(consume_stream())
Expand Down Expand Up @@ -261,6 +270,24 @@ def _merge_usage(
elif value is not None:
current[key] = value

def _merge_provider_payload(
self,
current: dict[str, Any],
chunk: Any,
) -> None:
raw = getattr(chunk, "raw", None)
model_dump = getattr(raw, "model_dump", None)
if callable(model_dump):
raw = model_dump()
if not isinstance(raw, dict):
return
for key in ("reasoning_content", "reasoning"):
if key in raw and raw[key] is not None:
current[key] = raw[key]
provider_state = raw.get("_xagent_provider_state")
if isinstance(provider_state, dict):
current["_xagent_provider_state"] = provider_state

def _merge_tool_call_chunks(
self,
accumulator: dict[int, dict[str, Any]],
Expand Down
78 changes: 74 additions & 4 deletions src/xagent/core/model/chat/basic/deepseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

from ...providers import is_placeholder_api_key
from .base import StreamChunk
from .openai import OpenAILLM
from .openai import PROVIDER_STATE_METADATA_KEY, OpenAICompatibleLLM

logger = logging.getLogger(__name__)

DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com"
DEEPSEEK_PROVIDER_STATE_NAMESPACE = "deepseek"
DEEPSEEK_REASONING_CONTENT_STATE_KEY = "reasoning_content"
DEEPSEEK_SUPPORTED_MODELS = (
"deepseek-v4-flash",
"deepseek-v4-pro",
Expand Down Expand Up @@ -45,7 +47,7 @@ def resolve_deepseek_api_key(api_key: Optional[str] = None) -> str:
return resolved_api_key or ""


class DeepSeekLLM(OpenAILLM):
class DeepSeekLLM(OpenAICompatibleLLM):
"""DeepSeek v4 client using the OpenAI SDK with DeepSeek-specific options."""

def __init__(
Expand Down Expand Up @@ -145,6 +147,74 @@ def _prepare_deepseek_kwargs(

return extra_body, updated_kwargs

def _prepare_messages_for_request(
self,
messages: List[Dict[str, Any]],
*,
thinking: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Preserve DeepSeek thinking metadata on assistant tool-call history.

DeepSeek V4 requires assistant messages in a tool-call chain to replay
the exact ``reasoning_content`` returned by the provider. An explicit
empty string is semantically different from a missing field, so this
must use key presence rather than truthiness. If older context lacks
captured provider state, use an empty string fallback to keep assistant
tool-call history structurally valid for later DeepSeek requests.
"""
prepared: List[Dict[str, Any]] = []
for message in messages:
prepared_message = dict(message)
provider_state = prepared_message.get(PROVIDER_STATE_METADATA_KEY)
if isinstance(provider_state, dict):
deepseek_metadata = provider_state.get(
DEEPSEEK_PROVIDER_STATE_NAMESPACE
)
if (
isinstance(deepseek_metadata, dict)
and DEEPSEEK_REASONING_CONTENT_STATE_KEY in deepseek_metadata
):
prepared_message["reasoning_content"] = deepseek_metadata[
DEEPSEEK_REASONING_CONTENT_STATE_KEY
]
if (
prepared_message.get("role") == "assistant"
and prepared_message.get("tool_calls")
and "reasoning_content" not in prepared_message
):
prepared_message["reasoning_content"] = ""
prepared.append(prepared_message)
return prepared

def _response_provider_state(self, result: Dict[str, Any]) -> Dict[str, Any]:
if "reasoning_content" not in result:
return {}
return {
DEEPSEEK_PROVIDER_STATE_NAMESPACE: {
DEEPSEEK_REASONING_CONTENT_STATE_KEY: result["reasoning_content"],
},
}

def _attach_reasoning_content_to_raw(
self,
raw_payload: Any,
reasoning_content: str,
*,
has_reasoning_content: bool = False,
) -> Any:
raw_payload = super()._attach_reasoning_content_to_raw(
raw_payload,
reasoning_content,
has_reasoning_content=has_reasoning_content,
)
if has_reasoning_content and isinstance(raw_payload, dict):
raw_payload[PROVIDER_STATE_METADATA_KEY] = {
DEEPSEEK_PROVIDER_STATE_NAMESPACE: {
DEEPSEEK_REASONING_CONTENT_STATE_KEY: reasoning_content,
},
}
return raw_payload

def _normalize_response_format(
self,
response_format: Optional[Dict[str, Any]],
Expand Down Expand Up @@ -203,7 +273,7 @@ async def chat(
tools=tools,
tool_choice=tool_choice,
response_format=response_format,
thinking=None,
thinking=thinking,
output_config=output_config,
extra_body=extra_body,
**kwargs,
Expand Down Expand Up @@ -239,7 +309,7 @@ async def stream_chat(
tools=tools,
tool_choice=tool_choice,
response_format=response_format,
thinking=None,
thinking=thinking,
output_config=output_config,
extra_body=extra_body,
**kwargs,
Expand Down
Loading
Loading