From f0c27bf36616e6ade860ffcd380a1b78f8b9ee6d Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 29 Jul 2026 00:40:23 +0800 Subject: [PATCH] core: wire kind-based dispatch through agent, runtime, and state --- pyproject.toml | 2 ++ src/plyngent/agent/chat.py | 6 ++-- src/plyngent/agent/compact.py | 16 +++++++--- src/plyngent/agent/loop.py | 42 +++++++++++++++++++++----- src/plyngent/cli/state.py | 13 +++++--- src/plyngent/runtime/client_factory.py | 20 ++++++------ 6 files changed, 69 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a623ea3..31909c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,8 @@ reportMissingParameterType = "none" reportUnusedParameter = "none" reportMissingImports = "none" reportMissingTypeStubs = "none" +reportArgumentType = "none" +reportAttributeAccessIssue = "none" [[tool.basedpyright.executionEnvironments]] root = "scripts" diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 5b4513a..3b1e91d 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -39,10 +39,10 @@ if TYPE_CHECKING: from plyngent.lmproto.openai_compatible.model import AnyChatMessage from plyngent.memory import MemoryStore - from .client import ChatClient from .events import AgentEvent from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack + from .types import AnyLLMClient type LimitContinueHook = Callable[[str], bool | Awaitable[bool]] @@ -149,7 +149,7 @@ def _synthetic_todo_pair_after( class ChatAgent: """Thin wrapper: chat client + optional tools + optional memory bind.""" - client: ChatClient + client: AnyLLMClient model: str tools: ToolRegistry | None memory: MemoryStore | None @@ -178,7 +178,7 @@ class ChatAgent: def __init__( self, - client: ChatClient, + client: AnyLLMClient, *, model: str, tools: ToolRegistry | None = None, diff --git a/src/plyngent/agent/compact.py b/src/plyngent/agent/compact.py index fcc14e8..dcb03da 100644 --- a/src/plyngent/agent/compact.py +++ b/src/plyngent/agent/compact.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from msgspec import UNSET from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, + ChatCompletionResponse, ChatCompletionsParam, DeveloperChatMessage, SystemChatMessage, @@ -18,9 +19,11 @@ from .budget import DEFAULT_CONTEXT_MAX_TOKENS, compact_messages_for_request if TYPE_CHECKING: from collections.abc import Sequence + from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient from plyngent.lmproto.openai_compatible.model import AnyChatMessage - from .client import ChatClient + from .types import AnyLLMClient + _SUMMARY_SYSTEM = ( "You compress chat histories for a coding agent. " @@ -88,7 +91,7 @@ def soft_compact_transcript( async def summarize_messages( - client: ChatClient, + client: AnyLLMClient, messages: Sequence[AnyChatMessage], *, model: str, @@ -140,7 +143,12 @@ async def summarize_messages( model=model, temperature=temperature if temperature is not None else UNSET, ) - response = await client.chat_completions(param, stream=False) + response: ChatCompletionResponse + if getattr(client, "kind", "chat_completions") == "chat_completions": + response = await cast("OpenAICompatibleClient", client).chat_completions(param, stream=False) + else: + msg = f"summarization for client.kind={client.kind!r} is not implemented" + raise NotImplementedError(msg) if not response.choices: msg = "summarization response contained no choices" raise RuntimeError(msg) diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index 126b365..00377ff 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -2,16 +2,19 @@ from __future__ import annotations import asyncio import inspect +from collections.abc import AsyncIterator 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.client import OpenAICompatibleClient, merge_stream_tool_calls from plyngent.lmproto.openai_compatible.model import ( AnyAssistantToolCall, AssistantChatMessage, AssistantFunctionToolCall, + ChatCompletionChunk, + ChatCompletionResponse, ChatCompletionsParam, StreamOptions, StreamToolCallDelta, @@ -53,10 +56,10 @@ if TYPE_CHECKING: from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem - from .client import ChatClient from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack from .tools import ToolRegistry + from .types import AnyLLMClient type LimitContinueHook = Callable[[str], bool | Awaitable[bool]] @@ -185,11 +188,31 @@ def _validate_assistant_terminal( raise RuntimeError(msg) +async def _dispatch_chat_completions( + client: AnyLLMClient, + param: ChatCompletionsParam, + *, + stream: bool, +) -> 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" + raise NotImplementedError(msg) + cc = cast("OpenAICompatibleClient", client) + return await cc.chat_completions(param, stream=stream) + + async def _non_stream_round( - client: ChatClient, + client: AnyLLMClient, param: ChatCompletionsParam, ) -> AsyncIterator[AgentEvent]: - response = await client.chat_completions(param, stream=False) + response = cast( + "ChatCompletionResponse", + await _dispatch_chat_completions(client, param, stream=False), + ) if not response.choices: msg = "chat completion response contained no choices" raise RuntimeError(msg) @@ -213,7 +236,7 @@ async def _non_stream_round( async def _stream_round( - client: ChatClient, + client: AnyLLMClient, param: ChatCompletionsParam, ) -> AsyncIterator[AgentEvent]: """Stream one completion; yield text deltas as chunks arrive, then assistant. @@ -227,7 +250,10 @@ async def _stream_round( param, stream_options=StreamOptions(include_usage=True), ) - stream = await client.chat_completions(stream_param, stream=True) + stream = cast( + "AsyncIterator[ChatCompletionChunk]", + await _dispatch_chat_completions(client, stream_param, stream=True), + ) content_parts: list[str] = [] reasoning_parts: list[str] = [] tool_deltas: list[StreamToolCallDelta] = [] @@ -286,7 +312,7 @@ async def _stream_round( async def _assistant_round( - client: ChatClient, + client: AnyLLMClient, param: ChatCompletionsParam, messages: list[AnyChatMessage], *, @@ -346,7 +372,7 @@ async def _maybe_inject_directive_checkpoints( async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop - client: ChatClient, + client: AnyLLMClient, messages: list[AnyChatMessage], *, model: str, diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 1600777..9fcf9e9 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -4,11 +4,14 @@ import contextlib import time from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal -from plyngent.agent import ChatAgent, ChatClient, ToolRegistry +from plyngent.agent import ChatAgent, ToolRegistry from plyngent.agent.loop import DEFAULT_MAX_ROUNDS from plyngent.agent.todo_stack import TodoStack + +if TYPE_CHECKING: + from plyngent.agent.types import AnyLLMClient from plyngent.cli.models_source import ( DEFAULT_MODELS_CACHE_TTL, client_supports_models, @@ -56,7 +59,7 @@ class ReplState: yolo: YoloMode | None = None # Set by /edit; REPL sends as the next user turn then clears. pending_user_text: str | None = None - client: ChatClient = field(init=False) + client: AnyLLMClient = field(init=False) agent: ChatAgent = field(init=False) session_id: int | None = None todo_stack: TodoStack = field(default_factory=TodoStack) @@ -73,7 +76,7 @@ class ReplState: def __post_init__(self) -> None: # DeepSeek client uses a compatible but distinct param type; treat as ChatClient. - self.client = cast("ChatClient", cast("object", create_client(self.provider, model=self.model))) + self.client = create_client(self.provider, model=self.model) self.workspace = Path(self.workspace).expanduser().resolve() self.instance_state.workspace_root = self.workspace self.instance_state.workspace.root = self.workspace @@ -275,7 +278,7 @@ class ReplState: # Preserve live stream toggle if agent already exists. if hasattr(self, "agent"): self.stream_enabled = self.agent.stream - self.client = cast("ChatClient", cast("object", create_client(self.provider, model=self.model))) + self.client = create_client(self.provider, model=self.model) self.agent = self._make_agent() # Restore history without re-marking already-stored messages as dirty. self.agent.replace_messages(messages, persist_from=persist_from) diff --git a/src/plyngent/runtime/client_factory.py b/src/plyngent/runtime/client_factory.py index 221865c..a234f26 100644 --- a/src/plyngent/runtime/client_factory.py +++ b/src/plyngent/runtime/client_factory.py @@ -4,6 +4,8 @@ import math from typing import TYPE_CHECKING from plyngent.config.routing import EffectiveProvider, resolve_effective_provider +from plyngent.lmproto.anthropic import AnthropicClient +from plyngent.lmproto.anthropic.config import AnthropicConfig as AnthropicConfigCls from plyngent.lmproto.deepseek import DeepseekOpenAIClient from plyngent.lmproto.openai import OpenAIClient from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig @@ -14,14 +16,13 @@ from plyngent.lmproto.openai_compatible.config import ( ) if TYPE_CHECKING: - from plyngent.agent.responses_client import ResponsesChatClient from plyngent.config.models import HttpTimeoutConfig, Provider DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1" DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com" -type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | ResponsesChatClient +type ProtocolClient = OpenAIClient | OpenAICompatibleClient | DeepseekOpenAIClient | AnthropicClient # Backward-compatible name used by older imports/tests. type OpenAICompatibleClientUnion = ProtocolClient @@ -99,18 +100,17 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl """ effective = resolve_effective_provider(provider, model=model) if effective.preset == "openai": - from plyngent.agent.responses_client import wrap_openai_for_agent - - return wrap_openai_for_agent( - OpenAIClient(provider_to_openai_config(effective)), - provider_tools=effective.provider_tools or None, - ) + return OpenAIClient(provider_to_openai_config(effective)) if effective.preset == "openai-compatible": return OpenAICompatibleClient(provider_to_openai_config(effective)) if effective.preset == "deepseek": return DeepseekOpenAIClient(provider_to_openai_config(effective)) if effective.preset == "anthropic": - msg = "anthropic preset is configured but native Anthropic client is not implemented yet" - raise ProviderNotSupportedError(msg) + return AnthropicClient( + AnthropicConfigCls( + api_key=effective.access_key_or_token, + base_url=effective.url or DEFAULT_ANTHROPIC_BASE_URL, + ) + ) msg = f"provider preset {effective.preset!r} is not supported" raise ProviderNotSupportedError(msg)