core/cli: provider selection, event display, REPL state

This commit is contained in:
2026-07-14 18:29:46 +08:00
parent a64e3bf769
commit a1edfd2947
3 changed files with 194 additions and 0 deletions
+56
View File
@@ -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()
+67
View File
@@ -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)
+71
View File
@@ -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()