core/agent: restore OpenAI responses dispatch and provider_tools

This commit is contained in:
2026-08-01 00:34:15 +08:00
parent ad58d6cdc6
commit de7cae83e4
6 changed files with 489 additions and 17 deletions
+5
View File
@@ -172,6 +172,7 @@ class ChatAgent:
todo_nag_strategy: TodoNagStrategy
directive_reminder_tokens: int
directive_reminder_text: str | None
provider_tools: list[dict[str, object]]
messages: list[AnyChatMessage]
session_usage: TokenUsage
last_turn_usage: TokenUsage
@@ -205,6 +206,7 @@ class ChatAgent:
directive_reminder_text: str | None = None,
peak_prompt_tokens: int = 0,
reminder_last_band: int = 0,
provider_tools: Sequence[dict[str, object]] | None = None,
) -> None:
self.client = client
self.model = model
@@ -224,6 +226,7 @@ class ChatAgent:
self.directive_reminder_tokens = max(0, int(directive_reminder_tokens))
text = directive_reminder_text.strip() if directive_reminder_text else ""
self.directive_reminder_text = text or None
self.provider_tools = [dict(item) for item in provider_tools] if provider_tools else []
self.messages = list(messages) if messages is not None else []
self.session_usage = TokenUsage()
self.last_turn_usage = TokenUsage()
@@ -456,6 +459,7 @@ class ChatAgent:
directive_reminder_text=self.directive_reminder_text,
reminder_last_band=self.reminder_last_band,
on_reminder_band=self._on_reminder_band,
provider_tools=self.provider_tools or None,
):
if isinstance(event, UsageEvent):
turn_rounds += 1
@@ -589,6 +593,7 @@ class ChatAgent:
todo_nag_strategy="none",
directive_reminder_tokens=0,
messages=history,
provider_tools=self.provider_tools or None,
)
async for event in aside.run(text):
yield event
+16 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
from msgspec import UNSET
@@ -143,11 +143,24 @@ async def summarize_messages(
model=model,
temperature=temperature if temperature is not None else UNSET,
)
kind = getattr(client, "kind", "chat_completions")
response: ChatCompletionResponse
if getattr(client, "kind", "chat_completions") == "chat_completions":
if kind == "chat_completions":
response = await cast("OpenAICompatibleClient", client).chat_completions(param, stream=False)
elif kind == "responses":
from .responses_dispatch import dispatch_responses
response = cast(
"ChatCompletionResponse",
await dispatch_responses(
cast("Any", client),
param,
provider_tools=None,
stream=False,
),
)
else:
msg = f"summarization for client.kind={client.kind!r} is not implemented"
msg = f"summarization for client.kind={kind!r} is not implemented"
raise NotImplementedError(msg)
if not response.choices:
msg = "summarization response contained no choices"
+40 -13
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import inspect
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast
import msgspec
from msgspec import UNSET
@@ -54,6 +54,7 @@ from .usage import resolve_round_usage, token_usage_from_api
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
from .todo_nag import TodoNagStrategy
@@ -193,25 +194,37 @@ async def _dispatch_chat_completions(
param: ChatCompletionsParam,
*,
stream: bool,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
kind = getattr(client, "kind", "chat_completions")
if kind != "chat_completions":
if kind == "messages":
msg = "Anthropic /messages dispatch is not wired in the agent loop yet"
else:
msg = "OpenAI /responses dispatch is not wired in the agent loop yet"
if kind == "chat_completions":
cc = cast("OpenAICompatibleClient", client)
return await cc.chat_completions(param, stream=stream)
if kind == "responses":
from .responses_dispatch import dispatch_responses
return await dispatch_responses(
cast("OpenAIClient", client),
param,
provider_tools=provider_tools,
stream=stream,
)
if kind == "messages":
msg = "Anthropic /messages dispatch is not wired in the agent loop yet"
raise NotImplementedError(msg)
cc = cast("OpenAICompatibleClient", client)
return await cc.chat_completions(param, stream=stream)
msg = f"client kind {kind!r} is not supported by the agent loop"
raise NotImplementedError(msg)
async def _non_stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
response = cast(
"ChatCompletionResponse",
await _dispatch_chat_completions(client, param, stream=False),
await _dispatch_chat_completions(client, param, stream=False, provider_tools=provider_tools),
)
if not response.choices:
msg = "chat completion response contained no choices"
@@ -238,6 +251,8 @@ async def _non_stream_round(
async def _stream_round(
client: AnyLLMClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""Stream one completion; yield text deltas as chunks arrive, then assistant.
@@ -252,7 +267,7 @@ async def _stream_round(
)
stream = cast(
"AsyncIterator[ChatCompletionChunk]",
await _dispatch_chat_completions(client, stream_param, stream=True),
await _dispatch_chat_completions(client, stream_param, stream=True, provider_tools=provider_tools),
)
content_parts: list[str] = []
reasoning_parts: list[str] = []
@@ -317,15 +332,16 @@ async def _assistant_round(
messages: list[AnyChatMessage],
*,
stream: bool,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""One model turn: yield events and append the assistant message to ``messages``."""
if stream:
async for event in _stream_round(client, param):
async for event in _stream_round(client, param, provider_tools=provider_tools):
if isinstance(event, AssistantMessageEvent):
messages.append(event.message)
yield event
return
async for event in _non_stream_round(client, param):
async for event in _non_stream_round(client, param, provider_tools=provider_tools):
if isinstance(event, AssistantMessageEvent):
messages.append(event.message)
yield event
@@ -390,6 +406,7 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
directive_reminder_text: str | None = None,
reminder_last_band: int = 0,
on_reminder_band: Callable[[int], Awaitable[None] | None] | None = None,
provider_tools: Sequence[dict[str, Any]] | None = None,
) -> AsyncIterator[AgentEvent]:
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
@@ -398,6 +415,10 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
Multiple tool calls in one round run in parallel when ``parallel_tools``.
Request payloads may shrink older tool results when over ``max_context_tokens``.
*provider_tools* are hosted/provider-side tools (OpenAI Responses only, e.g.
``web_search``) merged into the request as opaque dicts; never executed by the
:class:`ToolRegistry`. Ignored by non-Responses clients.
When *todo_stack* is set and still needs review after a natural stop
(open items, or non-empty stack untouched this turn), injects a review nag
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
@@ -442,7 +463,13 @@ async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
pre_len = len(messages)
last_usage_event: UsageEvent | None = None
async for event in _assistant_round(client, param, messages, stream=stream):
async for event in _assistant_round(
client,
param,
messages,
stream=stream,
provider_tools=provider_tools,
):
if isinstance(event, UsageEvent):
# Next rounds scale char-estimates by real/resolved prompt size.
prompt_tokens_hint = event.usage.prompt_tokens
+111
View File
@@ -0,0 +1,111 @@
"""OpenAI Responses dispatch: present ``/responses`` as chat completions to the loop.
Agent memory and events stay chat-completions-shaped; only the transport uses
``POST /responses``. DeepSeek / openai-compatible paths never enter this module.
This restores the dispatch that lived on the removed ``ResponsesChatClient``
wrapper, now as plain functions called by the kind-based switch in the loop.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import msgspec
from msgspec import UNSET
from plyngent.agent.responses_bridge import (
chat_param_to_responses_kwargs,
finish_reason_chunk,
reasoning_delta_chunk,
response_to_assistant_message,
response_to_chat_completion,
responses_status_to_finish_reason,
text_delta_chunk,
tool_call_chunks_from_response,
usage_chunk_from_response,
)
from plyngent.lmproto.openai.model import Response as ResponseModel
from plyngent.lmproto.openai.model import ResponsesCreateParam
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Sequence
from plyngent.lmproto.openai.client import OpenAIClient
from plyngent.lmproto.openai_compatible.model import (
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsParam,
)
# Responses stream event types that carry reasoning text deltas.
_REASONING_DELTA_TYPES = {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}
async def _stream_as_chat_chunks(
client: OpenAIClient,
create: ResponsesCreateParam,
*,
model: str,
) -> AsyncIterator[ChatCompletionChunk]:
"""Yield chat-completions chunks synthesized from a Responses SSE stream.
Text / reasoning deltas stream as they arrive; tool calls, finish_reason,
and usage are emitted once on ``response.completed`` (the full Response is
needed to decode function calls + usage). A stream that ends without
``response.completed`` yields nothing here — the loop treats that as a
missing terminal signal.
"""
stream = await client.responses(create, stream=True)
final: ResponseModel | None = None
async for event in stream:
etype = event.type
if etype == "response.output_text.delta" and isinstance(event.delta, str) and event.delta:
yield text_delta_chunk(model=model, content=event.delta)
continue
if etype in _REASONING_DELTA_TYPES and isinstance(event.delta, str) and event.delta:
yield reasoning_delta_chunk(model=model, content=event.delta)
continue
if etype == "response.completed" and event.response is not UNSET:
try:
final = msgspec.convert(event.response, ResponseModel)
except TypeError, ValueError, msgspec.ValidationError:
final = None
if final is None:
# Stream ended without response.completed — signal missing terminal.
# The loop will treat empty + no finish_reason as a glitch.
return
assistant = response_to_assistant_message(final)
has_tools = assistant.tool_calls is not UNSET and bool(assistant.tool_calls)
finish = responses_status_to_finish_reason(final, has_tool_calls=has_tools)
for chunk in tool_call_chunks_from_response(final, model=model):
yield chunk
yield finish_reason_chunk(model=model, finish_reason=finish)
usage = usage_chunk_from_response(final, model=model)
if usage is not None:
yield usage
async def dispatch_responses(
client: OpenAIClient,
param: ChatCompletionsParam,
*,
provider_tools: Sequence[dict[str, Any]] | None = None,
stream: bool = False,
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
"""Run one Responses turn and return a chat-completions-shaped result.
*provider_tools* are hosted tools (web_search, file_search, …) as opaque
dicts merged after local function tools; never executed by the registry.
"""
kwargs = chat_param_to_responses_kwargs(param, provider_tools=provider_tools)
create = ResponsesCreateParam(**kwargs)
if stream:
return _stream_as_chat_chunks(client, create, model=param.model)
response = await client.responses(create, stream=False)
return response_to_chat_completion(response)
__all__ = ["dispatch_responses"]
+1 -1
View File
@@ -75,7 +75,6 @@ class ReplState:
_remote_models_error: str | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
self.client = create_client(self.provider, model=self.model)
self.workspace = Path(self.workspace).expanduser().resolve()
self.instance_state.workspace_root = self.workspace
@@ -266,6 +265,7 @@ class ReplState:
directive_reminder_text=agent_cfg.directive_reminder_text or None,
peak_prompt_tokens=peak,
reminder_last_band=band,
provider_tools=self.effective_provider.provider_tools or None,
)
if last_req is not None and not last_req.is_zero():
agent.last_request_usage = last_req