mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
396 lines
14 KiB
Python
396 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
from typing import TYPE_CHECKING, cast
|
|
|
|
import msgspec
|
|
from msgspec import UNSET
|
|
|
|
from plyngent.lmproto.openai_compatible.client import merge_stream_tool_calls
|
|
from plyngent.lmproto.openai_compatible.model import (
|
|
AnyAssistantToolCall,
|
|
AssistantChatMessage,
|
|
AssistantFunctionToolCall,
|
|
ChatCompletionsParam,
|
|
DeveloperChatMessage,
|
|
StreamOptions,
|
|
StreamToolCallDelta,
|
|
ToolChatMessage,
|
|
)
|
|
from plyngent.typedef import Unset # noqa: TC001
|
|
|
|
from .budget import (
|
|
DEFAULT_CONTEXT_MAX_TOKENS,
|
|
DEFAULT_TOOL_RESULT_MAX_CHARS,
|
|
compact_messages_for_request,
|
|
estimate_messages_tokens,
|
|
truncate_tool_result,
|
|
)
|
|
from .events import (
|
|
AgentEvent,
|
|
AssistantMessageEvent,
|
|
ErrorEvent,
|
|
MaxRoundsEvent,
|
|
ReasoningDeltaEvent,
|
|
TextDeltaEvent,
|
|
ToolCallEvent,
|
|
ToolResultEvent,
|
|
UsageEvent,
|
|
)
|
|
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_compatible.model import AnyChatMessage, AnyToolItem
|
|
|
|
from .client import ChatClient
|
|
from .todo_stack import TodoStack
|
|
from .tools import ToolRegistry
|
|
|
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
|
|
|
|
|
DEFAULT_MAX_ROUNDS = 32
|
|
|
|
|
|
async def _call_on_limit(on_limit: LimitContinueHook, reason: str) -> bool:
|
|
result = on_limit(reason)
|
|
if inspect.isawaitable(result):
|
|
return bool(await result)
|
|
return bool(result)
|
|
|
|
|
|
async def _run_one_tool(
|
|
tools: ToolRegistry,
|
|
call: AnyAssistantToolCall,
|
|
*,
|
|
max_result_chars: int,
|
|
) -> tuple[ToolChatMessage, ErrorEvent | None]:
|
|
if isinstance(call, AssistantFunctionToolCall):
|
|
try:
|
|
result_text = await tools.execute(call.function.name, call.function.arguments)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001
|
|
result_text = f"error: tool {call.function.name!r} failed: {exc}"
|
|
err = ErrorEvent(message=result_text, retryable=True, source="tool")
|
|
truncated = truncate_tool_result(result_text, max_result_chars)
|
|
return ToolChatMessage(content=truncated, tool_call_id=call.id), err
|
|
truncated = truncate_tool_result(result_text, max_result_chars)
|
|
return ToolChatMessage(content=truncated, tool_call_id=call.id), None
|
|
msg = ToolChatMessage(
|
|
content="error: custom tool calls are not supported",
|
|
tool_call_id=call.id,
|
|
)
|
|
return msg, ErrorEvent(
|
|
message=msg.content,
|
|
retryable=False,
|
|
source="tool",
|
|
)
|
|
|
|
|
|
async def _execute_tool_calls(
|
|
tools: ToolRegistry,
|
|
tool_calls: Sequence[AnyAssistantToolCall],
|
|
messages: list[AnyChatMessage],
|
|
*,
|
|
max_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
|
parallel: bool = True,
|
|
) -> AsyncIterator[AgentEvent]:
|
|
for call in tool_calls:
|
|
yield ToolCallEvent(tool_call=call)
|
|
|
|
if parallel and len(tool_calls) > 1:
|
|
results = await asyncio.gather(
|
|
*[_run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
|
|
)
|
|
else:
|
|
results = [await _run_one_tool(tools, call, max_result_chars=max_result_chars) for call in tool_calls]
|
|
|
|
for tool_msg, err in results:
|
|
if err is not None:
|
|
yield err
|
|
messages.append(tool_msg)
|
|
yield ToolResultEvent(message=tool_msg)
|
|
|
|
|
|
def _assistant_has_payload(assistant: AssistantChatMessage) -> bool:
|
|
"""True if the model produced text, reasoning, or tool calls."""
|
|
if assistant.tool_calls is not UNSET and assistant.tool_calls:
|
|
return True
|
|
if isinstance(assistant.content, str) and assistant.content.strip():
|
|
return True
|
|
reasoning = assistant.reasoning_content
|
|
return bool(isinstance(reasoning, str) and reasoning.strip())
|
|
|
|
|
|
def _finish_reason_value(finish: object) -> str | None:
|
|
if finish is UNSET or finish is None:
|
|
return None
|
|
if isinstance(finish, str) and finish.strip():
|
|
return finish.strip()
|
|
return None
|
|
|
|
|
|
def _validate_assistant_terminal(
|
|
assistant: AssistantChatMessage,
|
|
*,
|
|
finish_reason: str | None,
|
|
stream_terminal: bool,
|
|
) -> None:
|
|
"""Raise when the round is not a usable agent stop.
|
|
|
|
Distinguishes empty generation, truncated/filtered stops, and missing
|
|
stream terminals (network/client glitch) from a normal stop/tool_calls end.
|
|
"""
|
|
reason = (finish_reason or "").lower() or None
|
|
|
|
if reason in {"length", "content_filter"}:
|
|
label = "truncated (max tokens)" if reason == "length" else "content filter"
|
|
detail = ""
|
|
if isinstance(assistant.content, str) and assistant.content.strip():
|
|
detail = f"; partial text kept ({len(assistant.content)} chars)"
|
|
msg = f"model stopped early: {label}{detail}"
|
|
raise RuntimeError(msg)
|
|
|
|
if reason in {"failed", "incomplete", "cancelled"}:
|
|
msg = f"model response status: {reason}"
|
|
raise RuntimeError(msg)
|
|
|
|
if _assistant_has_payload(assistant):
|
|
return
|
|
|
|
if not stream_terminal and reason is None:
|
|
msg = (
|
|
"stream ended without a terminal signal (no finish_reason / response.completed) and empty assistant output"
|
|
)
|
|
raise RuntimeError(msg)
|
|
|
|
if reason in {"stop", "tool_calls", "function_call"} or reason is None:
|
|
msg = "empty model completion (no text, reasoning, or tool calls)"
|
|
raise RuntimeError(msg)
|
|
|
|
msg = f"empty model completion (finish_reason={reason})"
|
|
raise RuntimeError(msg)
|
|
|
|
|
|
async def _non_stream_round(
|
|
client: ChatClient,
|
|
param: ChatCompletionsParam,
|
|
) -> AsyncIterator[AgentEvent]:
|
|
response = await client.chat_completions(param, stream=False)
|
|
if not response.choices:
|
|
msg = "chat completion response contained no choices"
|
|
raise RuntimeError(msg)
|
|
choice = response.choices[0]
|
|
assistant = choice.message
|
|
finish = _finish_reason_value(choice.finish_reason)
|
|
_validate_assistant_terminal(
|
|
assistant,
|
|
finish_reason=finish,
|
|
stream_terminal=True,
|
|
)
|
|
reasoning = assistant.reasoning_content
|
|
if isinstance(reasoning, str) and reasoning:
|
|
yield ReasoningDeltaEvent(content=reasoning)
|
|
if isinstance(assistant.content, str) and assistant.content:
|
|
yield TextDeltaEvent(content=assistant.content)
|
|
yield AssistantMessageEvent(message=assistant)
|
|
yield UsageEvent(
|
|
usage=resolve_round_usage(response.usage, param.messages, assistant),
|
|
)
|
|
|
|
|
|
async def _stream_round(
|
|
client: ChatClient,
|
|
param: ChatCompletionsParam,
|
|
) -> AsyncIterator[AgentEvent]:
|
|
"""Stream one completion; yield text deltas as chunks arrive, then assistant.
|
|
|
|
Pattern: ``stream = await client.chat_completions(..., stream=True)`` then
|
|
``async for chunk in stream``. Tool-call deltas are merged after the stream.
|
|
Requests ``stream_options.include_usage`` so providers may send a final usage chunk.
|
|
Falls back to a char-based token estimate when the provider omits usage.
|
|
"""
|
|
stream_param = msgspec.structs.replace(
|
|
param,
|
|
stream_options=StreamOptions(include_usage=True),
|
|
)
|
|
stream = await client.chat_completions(stream_param, stream=True)
|
|
content_parts: list[str] = []
|
|
reasoning_parts: list[str] = []
|
|
tool_deltas: list[StreamToolCallDelta] = []
|
|
last_api_usage: object = UNSET
|
|
finish_reason: str | None = None
|
|
saw_terminal = False
|
|
|
|
async for chunk in stream:
|
|
parsed = token_usage_from_api(chunk.usage)
|
|
if parsed is not None:
|
|
last_api_usage = chunk.usage
|
|
if not chunk.choices:
|
|
continue
|
|
choice = chunk.choices[0]
|
|
fr = _finish_reason_value(choice.finish_reason)
|
|
if fr is not None:
|
|
finish_reason = fr
|
|
saw_terminal = True
|
|
delta = choice.delta
|
|
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
|
reasoning_parts.append(delta.reasoning_content)
|
|
yield ReasoningDeltaEvent(content=delta.reasoning_content)
|
|
if isinstance(delta.content, str) and delta.content:
|
|
content_parts.append(delta.content)
|
|
yield TextDeltaEvent(content=delta.content)
|
|
if delta.tool_calls is not UNSET and delta.tool_calls:
|
|
tool_deltas.extend(delta.tool_calls)
|
|
|
|
full_content = "".join(content_parts)
|
|
full_reasoning = "".join(reasoning_parts)
|
|
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
|
if tool_deltas:
|
|
calls = merge_stream_tool_calls(tool_deltas)
|
|
if calls:
|
|
tool_calls = cast("list[AnyAssistantToolCall]", calls)
|
|
|
|
assistant = AssistantChatMessage(
|
|
content=full_content or None,
|
|
tool_calls=tool_calls,
|
|
reasoning_content=full_reasoning or UNSET,
|
|
)
|
|
# Usage-only final chunks leave choices empty; a non-empty usage after
|
|
# content still is not a finish_reason. Prefer explicit finish_reason.
|
|
# If we got payload but no finish_reason, treat as terminal (some providers
|
|
# omit it); if empty and no finish_reason, flag as missing terminal.
|
|
stream_terminal = saw_terminal or _assistant_has_payload(assistant)
|
|
_validate_assistant_terminal(
|
|
assistant,
|
|
finish_reason=finish_reason,
|
|
stream_terminal=stream_terminal,
|
|
)
|
|
yield AssistantMessageEvent(message=assistant)
|
|
yield UsageEvent(
|
|
usage=resolve_round_usage(last_api_usage, param.messages, assistant),
|
|
)
|
|
|
|
|
|
async def _assistant_round(
|
|
client: ChatClient,
|
|
param: ChatCompletionsParam,
|
|
messages: list[AnyChatMessage],
|
|
*,
|
|
stream: bool,
|
|
) -> AsyncIterator[AgentEvent]:
|
|
"""One model turn: yield events and append the assistant message to ``messages``."""
|
|
if stream:
|
|
async for event in _stream_round(client, param):
|
|
if isinstance(event, AssistantMessageEvent):
|
|
messages.append(event.message)
|
|
yield event
|
|
return
|
|
async for event in _non_stream_round(client, param):
|
|
if isinstance(event, AssistantMessageEvent):
|
|
messages.append(event.message)
|
|
yield event
|
|
|
|
|
|
def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantChatMessage:
|
|
if len(messages) <= pre_len:
|
|
msg = "model round produced no assistant message"
|
|
raise RuntimeError(msg)
|
|
last = messages[-1]
|
|
if not isinstance(last, AssistantChatMessage):
|
|
msg = "model round did not end with an assistant message"
|
|
raise TypeError(msg)
|
|
return last
|
|
|
|
|
|
async def run_chat_loop(
|
|
client: ChatClient,
|
|
messages: list[AnyChatMessage],
|
|
*,
|
|
model: str,
|
|
tools: ToolRegistry | None = None,
|
|
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
|
temperature: float | None = None,
|
|
on_limit: LimitContinueHook | None = None,
|
|
stream: bool = True,
|
|
max_tool_result_chars: int = DEFAULT_TOOL_RESULT_MAX_CHARS,
|
|
parallel_tools: bool = True,
|
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
|
todo_stack: TodoStack | None = None,
|
|
) -> AsyncIterator[AgentEvent]:
|
|
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
|
|
|
When ``stream=True``, uses ``chat_completions(..., stream=True)`` and yields
|
|
text deltas as chunks arrive; tool calls are merged from stream deltas.
|
|
Multiple tool calls in one round run in parallel when ``parallel_tools``.
|
|
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
|
|
|
When *todo_stack* is set and still has open items after a natural stop with
|
|
no ``todo_*`` tool use this turn, injects a review user message and continues
|
|
once (so the model must check or update the stack).
|
|
"""
|
|
tool_items: Sequence[AnyToolItem] | None = None
|
|
if tools is not None and len(tools) > 0:
|
|
tool_items = tools.tool_items()
|
|
|
|
rounds_used = 0
|
|
allowance = max_rounds
|
|
# Calibrate soft-compact from last model call's prompt_tokens (API preferred).
|
|
prompt_tokens_hint: int | None = None
|
|
sent_estimate_tokens: int | None = None
|
|
todo_review_injected = False
|
|
|
|
while True:
|
|
while rounds_used < allowance:
|
|
rounds_used += 1
|
|
request_messages = compact_messages_for_request(
|
|
messages,
|
|
max_tokens=max_context_tokens,
|
|
prompt_tokens_hint=prompt_tokens_hint,
|
|
sent_estimate_tokens=sent_estimate_tokens,
|
|
)
|
|
sent_est = estimate_messages_tokens(request_messages)
|
|
param = ChatCompletionsParam(
|
|
messages=request_messages,
|
|
model=model,
|
|
temperature=temperature if temperature is not None else UNSET,
|
|
tools=list(tool_items) if tool_items is not None else UNSET,
|
|
)
|
|
|
|
pre_len = len(messages)
|
|
async for event in _assistant_round(client, param, messages, stream=stream):
|
|
if isinstance(event, UsageEvent):
|
|
# Next rounds scale char-estimates by real/resolved prompt size.
|
|
prompt_tokens_hint = event.usage.prompt_tokens
|
|
sent_estimate_tokens = sent_est
|
|
yield event
|
|
assistant = _last_assistant(messages, pre_len)
|
|
tool_calls = assistant.tool_calls
|
|
if tool_calls is UNSET or not tool_calls or tools is None:
|
|
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
|
todo_review_injected = True
|
|
# Non-user control identity so retry/history don't treat this as human input.
|
|
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
|
|
continue
|
|
return
|
|
async for event in _execute_tool_calls(
|
|
tools,
|
|
tool_calls,
|
|
messages,
|
|
max_result_chars=max_tool_result_chars,
|
|
parallel=parallel_tools,
|
|
):
|
|
yield event
|
|
|
|
reason = f"tool loop reached {allowance} rounds (used {rounds_used})"
|
|
if on_limit is not None and await _call_on_limit(on_limit, reason):
|
|
yield MaxRoundsEvent(rounds=allowance, continued=True)
|
|
allowance += max_rounds
|
|
continue
|
|
yield MaxRoundsEvent(rounds=allowance, continued=False)
|
|
return
|