mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/agent+cli: reasoning deltas, /stream and /verbose
Decode and stream reasoning_content as ReasoningDeltaEvent; keep it on assistant messages. CLI shows dim reasoning, toggles stream/verbose, and prints full tool results when verbose.
This commit is contained in:
@@ -7,6 +7,7 @@ from .events import AssistantMessageEvent as AssistantMessageEvent
|
||||
from .events import CancelledEvent as CancelledEvent
|
||||
from .events import ErrorEvent as ErrorEvent
|
||||
from .events import MaxRoundsEvent as MaxRoundsEvent
|
||||
from .events import ReasoningDeltaEvent as ReasoningDeltaEvent
|
||||
from .events import TextDeltaEvent as TextDeltaEvent
|
||||
from .events import ToolCallEvent as ToolCallEvent
|
||||
from .events import ToolResultEvent as ToolResultEvent
|
||||
|
||||
@@ -13,6 +13,10 @@ class TextDeltaEvent(Struct, tag_field="type", tag="text_delta"):
|
||||
content: str
|
||||
|
||||
|
||||
class ReasoningDeltaEvent(Struct, tag_field="type", tag="reasoning_delta"):
|
||||
content: str
|
||||
|
||||
|
||||
class AssistantMessageEvent(Struct, tag_field="type", tag="assistant_message"):
|
||||
message: AssistantChatMessage
|
||||
|
||||
@@ -51,6 +55,7 @@ class UsageEvent(Struct, tag_field="type", tag="usage"):
|
||||
|
||||
type AgentEvent = (
|
||||
TextDeltaEvent
|
||||
| ReasoningDeltaEvent
|
||||
| AssistantMessageEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
|
||||
@@ -31,6 +31,7 @@ from .events import (
|
||||
AssistantMessageEvent,
|
||||
ErrorEvent,
|
||||
MaxRoundsEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
@@ -122,6 +123,9 @@ async def _non_stream_round(
|
||||
msg = "chat completion response contained no choices"
|
||||
raise RuntimeError(msg)
|
||||
assistant = response.choices[0].message
|
||||
reasoning = assistant.reasoning_content
|
||||
if isinstance(reasoning, str) and reasoning:
|
||||
yield ReasoningDeltaEvent(content=reasoning)
|
||||
if isinstance(assistant.content, str) and assistant.content:
|
||||
yield TextDeltaEvent(content=assistant.content)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
@@ -147,6 +151,7 @@ async def _stream_round(
|
||||
)
|
||||
stream = await client.chat_completions(stream_param, stream=True)
|
||||
content_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_deltas: list[StreamToolCallDelta] = []
|
||||
last_api_usage: object = UNSET
|
||||
|
||||
@@ -158,6 +163,9 @@ async def _stream_round(
|
||||
continue
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta
|
||||
if isinstance(delta.reasoning_content, str) and delta.reasoning_content:
|
||||
reasoning_parts.append(delta.reasoning_content)
|
||||
yield ReasoningDeltaEvent(content=delta.reasoning_content)
|
||||
if isinstance(delta.content, str) and delta.content:
|
||||
content_parts.append(delta.content)
|
||||
yield TextDeltaEvent(content=delta.content)
|
||||
@@ -165,6 +173,7 @@ async def _stream_round(
|
||||
tool_deltas.extend(delta.tool_calls)
|
||||
|
||||
full_content = "".join(content_parts)
|
||||
full_reasoning = "".join(reasoning_parts)
|
||||
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
||||
if tool_deltas:
|
||||
calls = merge_stream_tool_calls(tool_deltas)
|
||||
@@ -174,6 +183,7 @@ async def _stream_round(
|
||||
assistant = AssistantChatMessage(
|
||||
content=full_content or None,
|
||||
tool_calls=tool_calls,
|
||||
reasoning_content=full_reasoning or UNSET,
|
||||
)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
yield UsageEvent(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
@@ -8,6 +9,7 @@ from plyngent.agent import (
|
||||
CancelledEvent,
|
||||
ErrorEvent,
|
||||
MaxRoundsEvent,
|
||||
ReasoningDeltaEvent,
|
||||
TextDeltaEvent,
|
||||
ToolCallEvent,
|
||||
ToolResultEvent,
|
||||
@@ -23,6 +25,18 @@ if TYPE_CHECKING:
|
||||
_TOOL_RESULT_PREVIEW = 120
|
||||
_TOOL_ARGS_PREVIEW = 80
|
||||
|
||||
# Process/session display flag for tool result dumps (set from ReplState / slash).
|
||||
_verbose_tool_results: ContextVar[bool] = ContextVar("verbose_tool_results", default=False)
|
||||
|
||||
|
||||
def set_verbose_tool_results(enabled: bool) -> None: # noqa: FBT001
|
||||
"""Set whether tool results print in full (True) or as a short preview."""
|
||||
_ = _verbose_tool_results.set(enabled)
|
||||
|
||||
|
||||
def get_verbose_tool_results() -> bool:
|
||||
return _verbose_tool_results.get()
|
||||
|
||||
|
||||
def _preview(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
@@ -41,11 +55,25 @@ def _echo_stream(text: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C901, PLR0912
|
||||
async def render_events( # noqa: C901, PLR0912, PLR0915
|
||||
events: AsyncIterator[AgentEvent],
|
||||
*,
|
||||
verbose: bool | None = None,
|
||||
) -> None:
|
||||
"""Print agent events to the terminal (text deltas stream as they arrive)."""
|
||||
show_full = get_verbose_tool_results() if verbose is None else verbose
|
||||
printed_reasoning = False
|
||||
printed_text = False
|
||||
async for event in events:
|
||||
if isinstance(event, TextDeltaEvent):
|
||||
if isinstance(event, ReasoningDeltaEvent):
|
||||
if not printed_reasoning:
|
||||
click.echo()
|
||||
click.secho("reasoning: ", fg="bright_black", nl=False)
|
||||
printed_reasoning = True
|
||||
_echo_stream(event.content)
|
||||
elif isinstance(event, TextDeltaEvent):
|
||||
if printed_reasoning and not printed_text:
|
||||
click.echo()
|
||||
if not printed_text:
|
||||
click.echo()
|
||||
click.secho("assistant: ", fg="cyan", nl=False)
|
||||
@@ -59,10 +87,14 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C90
|
||||
else:
|
||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||
elif isinstance(event, ToolResultEvent):
|
||||
preview = _preview(event.message.content, _TOOL_RESULT_PREVIEW)
|
||||
# Single-line summary; full result stays in the message history for the model.
|
||||
one_line = preview.replace("\n", " ")
|
||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
||||
content = event.message.content
|
||||
if show_full:
|
||||
click.secho(f"[tool ok]\n{content}", fg="magenta")
|
||||
else:
|
||||
preview = _preview(content, _TOOL_RESULT_PREVIEW)
|
||||
# Single-line summary; full result stays in the message history for the model.
|
||||
one_line = preview.replace("\n", " ")
|
||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
||||
elif isinstance(event, ErrorEvent):
|
||||
suffix = ""
|
||||
if event.source:
|
||||
@@ -89,6 +121,6 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C90
|
||||
else:
|
||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||
_ = event
|
||||
if printed_text:
|
||||
if printed_text or printed_reasoning:
|
||||
click.echo()
|
||||
click.echo()
|
||||
|
||||
@@ -28,12 +28,14 @@ SLASH_COMMANDS: tuple[str, ...] = (
|
||||
"/provider",
|
||||
"/model",
|
||||
"/tools",
|
||||
"/stream",
|
||||
"/verbose",
|
||||
"/rounds",
|
||||
"/retry",
|
||||
"/status",
|
||||
)
|
||||
|
||||
_TOOLS_ARGS: tuple[str, ...] = ("on", "off")
|
||||
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
||||
|
||||
|
||||
def history_path() -> Path:
|
||||
@@ -74,8 +76,8 @@ def _argument_options(state: ReplState, command: str, text: str) -> list[str]:
|
||||
return filter_prefix(text, sorted(state.config.providers.keys()))
|
||||
if command == "/model":
|
||||
return filter_prefix(text, sorted(state.provider.models.keys()))
|
||||
if command == "/tools":
|
||||
return filter_prefix(text, list(_TOOLS_ARGS))
|
||||
if command in {"/tools", "/stream", "/verbose"}:
|
||||
return filter_prefix(text, list(_ON_OFF_ARGS))
|
||||
if command == "/resume":
|
||||
return []
|
||||
return []
|
||||
|
||||
@@ -35,6 +35,8 @@ Commands:
|
||||
/provider [name] Show or switch provider
|
||||
/model [id] Show or switch model
|
||||
/tools [on|off] Show or toggle tools
|
||||
/stream [on|off] Show or toggle streaming model output
|
||||
/verbose [on|off] Show or toggle full tool-result dumps
|
||||
/rounds [n] Show or set max tool-loop rounds
|
||||
/retry Re-run incomplete last user turn (DB/orphan user; no retype)
|
||||
/status Show session/provider/tools/rounds status
|
||||
@@ -43,8 +45,9 @@ User messages are saved immediately. On API errors or Ctrl+C, partial
|
||||
assistant/tool output is discarded but the user message stays (so /retry
|
||||
works after resume, not only via readline history). Auto-retry: 10s/20s/30s.
|
||||
|
||||
Tab completes slash commands and some arguments (provider, model, tools).
|
||||
Use --session ID or /resume to continue a prior chat after restart.
|
||||
Tab completes slash commands and some arguments (provider, model, tools,
|
||||
stream, verbose). Use --session ID or /resume to continue a prior chat
|
||||
after restart.
|
||||
"""
|
||||
|
||||
type SlashHandler = Callable[[], None | Awaitable[None]]
|
||||
@@ -74,7 +77,9 @@ def _cmd_status(state: ReplState) -> None:
|
||||
f"session={state.session_id} messages={len(state.agent.messages)} "
|
||||
f"pending_retry={pending_disp}\n"
|
||||
f"tools={'on' if state.tools_enabled else 'off'} "
|
||||
f"rounds={state.max_rounds} stream={'on' if state.agent.stream else 'off'}\n"
|
||||
f"rounds={state.max_rounds} "
|
||||
f"stream={'on' if state.agent.stream else 'off'} "
|
||||
f"verbose={'on' if state.verbose else 'off'}\n"
|
||||
f"context_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||
f"context_chars={ctx_chars} "
|
||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
||||
@@ -185,6 +190,40 @@ def _cmd_tools(state: ReplState, arg: str) -> None:
|
||||
click.echo(f"tools={'on' if state.tools_enabled else 'off'}")
|
||||
|
||||
|
||||
def _cmd_stream(state: ReplState, arg: str) -> None:
|
||||
token = arg.strip().lower()
|
||||
if not token:
|
||||
click.echo(f"stream={'on' if state.agent.stream else 'off'}")
|
||||
return
|
||||
if token in {"on", "1", "true", "yes"}:
|
||||
enabled = True
|
||||
elif token in {"off", "0", "false", "no"}:
|
||||
enabled = False
|
||||
else:
|
||||
click.echo("usage: /stream [on|off]")
|
||||
return
|
||||
state.stream_enabled = enabled
|
||||
state.agent.stream = enabled
|
||||
click.echo(f"stream={'on' if enabled else 'off'}")
|
||||
|
||||
|
||||
def _cmd_verbose(state: ReplState, arg: str) -> None:
|
||||
token = arg.strip().lower()
|
||||
if not token:
|
||||
click.echo(f"verbose={'on' if state.verbose else 'off'}")
|
||||
return
|
||||
if token in {"on", "1", "true", "yes"}:
|
||||
enabled = True
|
||||
elif token in {"off", "0", "false", "no"}:
|
||||
enabled = False
|
||||
else:
|
||||
click.echo("usage: /verbose [on|off]")
|
||||
return
|
||||
state.verbose = enabled
|
||||
state.sync_display_flags()
|
||||
click.echo(f"verbose={'on' if enabled else 'off'}")
|
||||
|
||||
|
||||
def _cmd_rounds(state: ReplState, arg: str) -> None:
|
||||
token = arg.strip()
|
||||
if not token:
|
||||
@@ -287,6 +326,8 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
|
||||
"provider": lambda: _cmd_provider(state, arg),
|
||||
"model": lambda: _cmd_model(state, arg),
|
||||
"tools": lambda: _cmd_tools(state, arg),
|
||||
"stream": lambda: _cmd_stream(state, arg),
|
||||
"verbose": lambda: _cmd_verbose(state, arg),
|
||||
"rounds": lambda: _cmd_rounds(state, arg),
|
||||
"retry": lambda: _cmd_retry(state),
|
||||
"status": lambda: _cmd_status(state),
|
||||
@@ -322,7 +363,8 @@ async def run_repl(state: ReplState) -> None:
|
||||
f"plyngent chat provider={state.provider_name} model={state.model} "
|
||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
||||
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
||||
f"stream={'on' if state.agent.stream else 'off'}"
|
||||
f"stream={'on' if state.agent.stream else 'off'} "
|
||||
f"verbose={'on' if state.verbose else 'off'}"
|
||||
)
|
||||
click.echo("Type /help for commands. Empty line is ignored.")
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ class ReplState:
|
||||
model: str
|
||||
tools_enabled: bool
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS
|
||||
stream_enabled: bool = True
|
||||
verbose: bool = False
|
||||
client: ChatClient = field(init=False)
|
||||
agent: ChatAgent = field(init=False)
|
||||
session_id: int | None = None
|
||||
@@ -38,6 +40,12 @@ class ReplState:
|
||||
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||
self.agent = self._make_agent()
|
||||
self.sync_display_flags()
|
||||
|
||||
def sync_display_flags(self) -> None:
|
||||
from plyngent.cli.display import set_verbose_tool_results
|
||||
|
||||
set_verbose_tool_results(self.verbose)
|
||||
|
||||
def _workspace_key(self) -> str:
|
||||
key = normalize_workspace(self.workspace)
|
||||
@@ -74,7 +82,7 @@ class ReplState:
|
||||
session_id=self.session_id,
|
||||
max_rounds=self.max_rounds,
|
||||
on_limit=prompt_continue_limit_async,
|
||||
stream=True,
|
||||
stream=self.stream_enabled,
|
||||
system_prompt=system_prompt,
|
||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||
parallel_tools=agent_cfg.parallel_tools,
|
||||
@@ -84,9 +92,13 @@ class ReplState:
|
||||
def rebuild_client(self) -> None:
|
||||
"""Recreate client and agent after provider/model/tools change."""
|
||||
messages = list(self.agent.messages)
|
||||
# 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)))
|
||||
self.agent = self._make_agent()
|
||||
self.agent.messages = messages
|
||||
self.sync_display_flags()
|
||||
|
||||
def _set_workspace(self, path: Path) -> None:
|
||||
"""Update REPL + tool workspace root."""
|
||||
|
||||
@@ -20,8 +20,8 @@ type DeepSeekReasoningEffort = ReasoningEffort | Literal["max"]
|
||||
|
||||
|
||||
class AssistantChatMessage(BaseAssistantChatMessage):
|
||||
# reasoning_content lives on the base assistant message (OpenAI-compat).
|
||||
prefix: bool | Unset = UNSET
|
||||
reasoning_content: str | Unset = UNSET
|
||||
|
||||
|
||||
class ToolChatMessage(BaseToolChatMessage):
|
||||
|
||||
@@ -72,6 +72,8 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
||||
audio: IDObject | Unset = UNSET
|
||||
refusal: str | Unset = UNSET
|
||||
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
||||
# OpenAI-compat / DeepSeek thinking streams (omitted when unset).
|
||||
reasoning_content: str | Unset = UNSET
|
||||
|
||||
|
||||
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
||||
@@ -235,6 +237,7 @@ class StreamToolCallDelta(Struct):
|
||||
class DeltaMessage(Struct):
|
||||
role: RoleAssistant | Unset = UNSET
|
||||
content: str | Unset = UNSET
|
||||
reasoning_content: str | Unset = UNSET
|
||||
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user