mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47: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"
|
reportUnusedParameter = "none"
|
||||||
reportMissingImports = "none"
|
reportMissingImports = "none"
|
||||||
reportMissingTypeStubs = "none"
|
reportMissingTypeStubs = "none"
|
||||||
|
reportArgumentType = "none"
|
||||||
|
reportAttributeAccessIssue = "none"
|
||||||
|
|
||||||
[[tool.basedpyright.executionEnvironments]]
|
[[tool.basedpyright.executionEnvironments]]
|
||||||
root = "scripts"
|
root = "scripts"
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
|
|
||||||
from .client import ChatClient
|
|
||||||
from .events import AgentEvent
|
from .events import AgentEvent
|
||||||
from .todo_nag import TodoNagStrategy
|
from .todo_nag import TodoNagStrategy
|
||||||
from .todo_stack import TodoStack
|
from .todo_stack import TodoStack
|
||||||
|
from .types import AnyLLMClient
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ def _synthetic_todo_pair_after(
|
|||||||
class ChatAgent:
|
class ChatAgent:
|
||||||
"""Thin wrapper: chat client + optional tools + optional memory bind."""
|
"""Thin wrapper: chat client + optional tools + optional memory bind."""
|
||||||
|
|
||||||
client: ChatClient
|
client: AnyLLMClient
|
||||||
model: str
|
model: str
|
||||||
tools: ToolRegistry | None
|
tools: ToolRegistry | None
|
||||||
memory: MemoryStore | None
|
memory: MemoryStore | None
|
||||||
@@ -178,7 +178,7 @@ class ChatAgent:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
*,
|
*,
|
||||||
model: str,
|
model: str,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from msgspec import UNSET
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
@@ -18,9 +19,11 @@ from .budget import DEFAULT_CONTEXT_MAX_TOKENS, compact_messages_for_request
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.client import OpenAICompatibleClient
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
from .client import ChatClient
|
from .types import AnyLLMClient
|
||||||
|
|
||||||
|
|
||||||
_SUMMARY_SYSTEM = (
|
_SUMMARY_SYSTEM = (
|
||||||
"You compress chat histories for a coding agent. "
|
"You compress chat histories for a coding agent. "
|
||||||
@@ -88,7 +91,7 @@ def soft_compact_transcript(
|
|||||||
|
|
||||||
|
|
||||||
async def summarize_messages(
|
async def summarize_messages(
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
messages: Sequence[AnyChatMessage],
|
messages: Sequence[AnyChatMessage],
|
||||||
*,
|
*,
|
||||||
model: str,
|
model: str,
|
||||||
@@ -140,7 +143,12 @@ async def summarize_messages(
|
|||||||
model=model,
|
model=model,
|
||||||
temperature=temperature if temperature is not None else UNSET,
|
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:
|
if not response.choices:
|
||||||
msg = "summarization response contained no choices"
|
msg = "summarization response contained no choices"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import inspect
|
import inspect
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from msgspec import UNSET
|
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 (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AnyAssistantToolCall,
|
AnyAssistantToolCall,
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
|
ChatCompletionChunk,
|
||||||
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
@@ -53,10 +56,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||||
|
|
||||||
from .client import ChatClient
|
|
||||||
from .todo_nag import TodoNagStrategy
|
from .todo_nag import TodoNagStrategy
|
||||||
from .todo_stack import TodoStack
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
from .types import AnyLLMClient
|
||||||
|
|
||||||
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
type LimitContinueHook = Callable[[str], bool | Awaitable[bool]]
|
||||||
|
|
||||||
@@ -185,11 +188,31 @@ def _validate_assistant_terminal(
|
|||||||
raise RuntimeError(msg)
|
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(
|
async def _non_stream_round(
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> 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:
|
if not response.choices:
|
||||||
msg = "chat completion response contained no choices"
|
msg = "chat completion response contained no choices"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
@@ -213,7 +236,7 @@ async def _non_stream_round(
|
|||||||
|
|
||||||
|
|
||||||
async def _stream_round(
|
async def _stream_round(
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Stream one completion; yield text deltas as chunks arrive, then assistant.
|
"""Stream one completion; yield text deltas as chunks arrive, then assistant.
|
||||||
@@ -227,7 +250,10 @@ async def _stream_round(
|
|||||||
param,
|
param,
|
||||||
stream_options=StreamOptions(include_usage=True),
|
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] = []
|
content_parts: list[str] = []
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
tool_deltas: list[StreamToolCallDelta] = []
|
tool_deltas: list[StreamToolCallDelta] = []
|
||||||
@@ -286,7 +312,7 @@ async def _stream_round(
|
|||||||
|
|
||||||
|
|
||||||
async def _assistant_round(
|
async def _assistant_round(
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
param: ChatCompletionsParam,
|
param: ChatCompletionsParam,
|
||||||
messages: list[AnyChatMessage],
|
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
|
async def run_chat_loop( # noqa: C901, PLR0912 — multi-phase tool loop
|
||||||
client: ChatClient,
|
client: AnyLLMClient,
|
||||||
messages: list[AnyChatMessage],
|
messages: list[AnyChatMessage],
|
||||||
*,
|
*,
|
||||||
model: str,
|
model: str,
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import contextlib
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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.loop import DEFAULT_MAX_ROUNDS
|
||||||
from plyngent.agent.todo_stack import TodoStack
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from plyngent.agent.types import AnyLLMClient
|
||||||
from plyngent.cli.models_source import (
|
from plyngent.cli.models_source import (
|
||||||
DEFAULT_MODELS_CACHE_TTL,
|
DEFAULT_MODELS_CACHE_TTL,
|
||||||
client_supports_models,
|
client_supports_models,
|
||||||
@@ -56,7 +59,7 @@ class ReplState:
|
|||||||
yolo: YoloMode | None = None
|
yolo: YoloMode | None = None
|
||||||
# Set by /edit; REPL sends as the next user turn then clears.
|
# Set by /edit; REPL sends as the next user turn then clears.
|
||||||
pending_user_text: str | None = None
|
pending_user_text: str | None = None
|
||||||
client: ChatClient = field(init=False)
|
client: AnyLLMClient = field(init=False)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
session_id: int | None = None
|
||||||
todo_stack: TodoStack = field(default_factory=TodoStack)
|
todo_stack: TodoStack = field(default_factory=TodoStack)
|
||||||
@@ -73,7 +76,7 @@ class ReplState:
|
|||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
# DeepSeek client uses a compatible but distinct param type; treat as ChatClient.
|
# 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.workspace = Path(self.workspace).expanduser().resolve()
|
||||||
self.instance_state.workspace_root = self.workspace
|
self.instance_state.workspace_root = self.workspace
|
||||||
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.
|
# Preserve live stream toggle if agent already exists.
|
||||||
if hasattr(self, "agent"):
|
if hasattr(self, "agent"):
|
||||||
self.stream_enabled = self.agent.stream
|
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()
|
self.agent = self._make_agent()
|
||||||
# Restore history without re-marking already-stored messages as dirty.
|
# Restore history without re-marking already-stored messages as dirty.
|
||||||
self.agent.replace_messages(messages, persist_from=persist_from)
|
self.agent.replace_messages(messages, persist_from=persist_from)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import math
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from plyngent.config.routing import EffectiveProvider, resolve_effective_provider
|
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.deepseek import DeepseekOpenAIClient
|
||||||
from plyngent.lmproto.openai import OpenAIClient
|
from plyngent.lmproto.openai import OpenAIClient
|
||||||
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
|
from plyngent.lmproto.openai_compatible import OpenAICompatibleClient, OpenAIConfig
|
||||||
@@ -14,14 +16,13 @@ from plyngent.lmproto.openai_compatible.config import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from plyngent.agent.responses_client import ResponsesChatClient
|
|
||||||
from plyngent.config.models import HttpTimeoutConfig, Provider
|
from plyngent.config.models import HttpTimeoutConfig, Provider
|
||||||
|
|
||||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||||
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
|
DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"
|
||||||
DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
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.
|
# Backward-compatible name used by older imports/tests.
|
||||||
type OpenAICompatibleClientUnion = ProtocolClient
|
type OpenAICompatibleClientUnion = ProtocolClient
|
||||||
|
|
||||||
@@ -99,18 +100,17 @@ def create_client(provider: Provider, *, model: str | None = None) -> ProtocolCl
|
|||||||
"""
|
"""
|
||||||
effective = resolve_effective_provider(provider, model=model)
|
effective = resolve_effective_provider(provider, model=model)
|
||||||
if effective.preset == "openai":
|
if effective.preset == "openai":
|
||||||
from plyngent.agent.responses_client import wrap_openai_for_agent
|
return OpenAIClient(provider_to_openai_config(effective))
|
||||||
|
|
||||||
return wrap_openai_for_agent(
|
|
||||||
OpenAIClient(provider_to_openai_config(effective)),
|
|
||||||
provider_tools=effective.provider_tools or None,
|
|
||||||
)
|
|
||||||
if effective.preset == "openai-compatible":
|
if effective.preset == "openai-compatible":
|
||||||
return OpenAICompatibleClient(provider_to_openai_config(effective))
|
return OpenAICompatibleClient(provider_to_openai_config(effective))
|
||||||
if effective.preset == "deepseek":
|
if effective.preset == "deepseek":
|
||||||
return DeepseekOpenAIClient(provider_to_openai_config(effective))
|
return DeepseekOpenAIClient(provider_to_openai_config(effective))
|
||||||
if effective.preset == "anthropic":
|
if effective.preset == "anthropic":
|
||||||
msg = "anthropic preset is configured but native Anthropic client is not implemented yet"
|
return AnthropicClient(
|
||||||
raise ProviderNotSupportedError(msg)
|
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"
|
msg = f"provider preset {effective.preset!r} is not supported"
|
||||||
raise ProviderNotSupportedError(msg)
|
raise ProviderNotSupportedError(msg)
|
||||||
|
|||||||
Reference in New Issue
Block a user