mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
core: wire kind-based dispatch through agent, runtime, and state
This commit is contained in:
@@ -63,6 +63,8 @@ reportMissingParameterType = "none"
|
||||
reportUnusedParameter = "none"
|
||||
reportMissingImports = "none"
|
||||
reportMissingTypeStubs = "none"
|
||||
reportArgumentType = "none"
|
||||
reportAttributeAccessIssue = "none"
|
||||
|
||||
[[tool.basedpyright.executionEnvironments]]
|
||||
root = "scripts"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user