From a1edfd2947b446e42510ea60b8a7d993c75c0001 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Tue, 14 Jul 2026 18:29:46 +0800 Subject: [PATCH] core/cli: provider selection, event display, REPL state --- src/plyngent/cli/display.py | 56 +++++++++++++++++++++++++++ src/plyngent/cli/selection.py | 67 +++++++++++++++++++++++++++++++++ src/plyngent/cli/state.py | 71 +++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 src/plyngent/cli/display.py create mode 100644 src/plyngent/cli/selection.py create mode 100644 src/plyngent/cli/state.py diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py new file mode 100644 index 0000000..e00e793 --- /dev/null +++ b/src/plyngent/cli/display.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +from plyngent.agent import ( + MaxRoundsEvent, + TextDeltaEvent, + ToolCallEvent, + ToolResultEvent, +) +from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from plyngent.agent import AgentEvent + +_TOOL_RESULT_PREVIEW = 200 + + +async def render_events(events: AsyncIterator[AgentEvent]) -> None: + """Print agent events to the terminal.""" + printed_text = False + async for event in events: + if isinstance(event, TextDeltaEvent): + if not printed_text: + click.echo() + click.secho("assistant: ", fg="cyan", nl=False) + printed_text = True + click.echo(event.content, nl=False) + elif isinstance(event, ToolCallEvent): + call = event.tool_call + if isinstance(call, AssistantFunctionToolCall): + click.secho( + f"\n[tool call] {call.function.name}({call.function.arguments})", + fg="yellow", + ) + else: + click.secho(f"\n[tool call] custom id={call.id}", fg="yellow") + elif isinstance(event, ToolResultEvent): + content = event.message.content + preview = ( + content + if len(content) <= _TOOL_RESULT_PREVIEW + else content[:_TOOL_RESULT_PREVIEW] + "…" + ) + click.secho(f"[tool result] {preview}", fg="magenta") + elif isinstance(event, MaxRoundsEvent): + click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red") + else: + # AssistantMessageEvent — text already shown via TextDeltaEvent. + _ = event + if printed_text: + click.echo() diff --git a/src/plyngent/cli/selection.py b/src/plyngent/cli/selection.py new file mode 100644 index 0000000..57729c3 --- /dev/null +++ b/src/plyngent/cli/selection.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import click + +if TYPE_CHECKING: + from collections.abc import Mapping + + from plyngent.config.models import Provider + + +def select_provider( + providers: Mapping[str, Provider], + *, + preferred: str | None = None, +) -> tuple[str, Provider]: + """Pick a provider by name or interactive prompt.""" + if not providers: + msg = "no providers configured; edit your plyngent.toml" + raise click.ClickException(msg) + + names = sorted(providers.keys()) + if preferred is not None: + if preferred not in providers: + msg = f"unknown provider {preferred!r}; available: {', '.join(names)}" + raise click.ClickException(msg) + return preferred, providers[preferred] + + if len(names) == 1: + name = names[0] + click.echo(f"Using provider: {name}") + return name, providers[name] + + click.echo("Available providers:") + for index, name in enumerate(names, start=1): + preset = type(providers[name]).__struct_config__.tag + click.echo(f" {index}. {name} ({preset})") + choice = click.prompt("Select provider", type=click.Choice(names), show_choices=True) + return choice, providers[choice] + + +def select_model( + provider: Provider, + *, + preferred: str | None = None, +) -> str: + """Pick a model id from provider.models or free-form prompt.""" + model_names = sorted(provider.models.keys()) + if preferred is not None: + if model_names and preferred not in provider.models: + msg = f"unknown model {preferred!r}; available: {', '.join(model_names)}" + raise click.ClickException(msg) + return preferred + + if len(model_names) == 1: + model = model_names[0] + click.echo(f"Using model: {model}") + return model + + if model_names: + click.echo("Available models:") + for index, name in enumerate(model_names, start=1): + click.echo(f" {index}. {name}") + return click.prompt("Select model", type=click.Choice(model_names), show_choices=True) + + return click.prompt("Model id (not listed in config)", type=str) diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py new file mode 100644 index 0000000..a87d6e5 --- /dev/null +++ b/src/plyngent/cli/state.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, cast + +from plyngent.agent import ChatAgent, ChatClient, ToolRegistry +from plyngent.runtime import create_client +from plyngent.tools import DEFAULT_TOOLS + +if TYPE_CHECKING: + from pathlib import Path + + from plyngent.config.models import Provider + from plyngent.config.store import ConfigStore + from plyngent.memory import MemoryStore + + +@dataclass +class ReplState: + """Mutable REPL session state.""" + + config: ConfigStore + memory: MemoryStore + workspace: Path + provider_name: str + provider: Provider + model: str + tools_enabled: bool + client: ChatClient = field(init=False) + agent: ChatAgent = field(init=False) + session_id: int | None = None + + def __post_init__(self) -> None: + # DeepSeek client uses a compatible but distinct param type; treat as ChatClient. + self.client = cast("ChatClient", create_client(self.provider)) + self.agent = self._make_agent() + + def _tool_registry(self) -> ToolRegistry | None: + if not self.tools_enabled: + return None + return ToolRegistry(list(DEFAULT_TOOLS)) + + def _make_agent(self) -> ChatAgent: + return ChatAgent( + self.client, + model=self.model, + tools=self._tool_registry(), + memory=self.memory, + session_id=self.session_id, + ) + + def rebuild_client(self) -> None: + """Recreate client and agent after provider/model/tools change.""" + messages = list(self.agent.messages) + self.client = cast("ChatClient", create_client(self.provider)) + self.agent = self._make_agent() + self.agent.messages = messages + + async def new_session(self, name: str = "chat") -> None: + session = await self.memory.create_session(name=name) + self.session_id = session.sid + self.agent = self._make_agent() + + async def resume_session(self, session_id: int) -> None: + row = await self.memory.get_session(session_id) + if row is None: + msg = f"session not found: {session_id}" + raise ValueError(msg) + self.session_id = session_id + self.agent = self._make_agent() + await self.agent.load_history()