mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/agent: restore OpenAI responses dispatch and provider_tools
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user