core: wire kind-based dispatch through agent, runtime, and state

This commit is contained in:
2026-07-29 00:40:23 +08:00
parent 4562eaa1cb
commit f0c27bf366
6 changed files with 69 additions and 30 deletions
+2
View File
@@ -63,6 +63,8 @@ reportMissingParameterType = "none"
reportUnusedParameter = "none"
reportMissingImports = "none"
reportMissingTypeStubs = "none"
reportArgumentType = "none"
reportAttributeAccessIssue = "none"
[[tool.basedpyright.executionEnvironments]]
root = "scripts"
+3 -3
View File
@@ -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,
+12 -4
View File
@@ -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)
+34 -8
View File
@@ -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,
+8 -5
View File
@@ -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)
+10 -10
View File
@@ -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)