mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +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:
@@ -57,7 +57,7 @@ Async SQLAlchemy + aiosqlite. `MemoryStore`: schema init (+ lightweight SQLite `
|
|||||||
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
- **`run_chat_loop`**: multi-round tool loop; default **streaming** text deltas + stream tool-call merge; parallel tools; tool-result char budget; soft context compact on request (**API-calibrated** after first usage when available); cooperative cancel points; optional `on_limit`.
|
||||||
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; assistant/tools on success); `stream`; system prompt; `retry()` when history ends with a user message (failed/cancelled turn or orphan after resume).
|
- **`ChatAgent`**: optional `MemoryStore` (user message persisted immediately; assistant/tools on success); `stream`; system prompt; `retry()` when history ends with a user message (failed/cancelled turn or orphan after resume).
|
||||||
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
- **`/compact`**: soft-compact tool dumps → model summary (no tools) → **new** session seeded with summary message.
|
||||||
- Events: text_delta, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
- Events: text_delta, **reasoning_delta**, assistant_message, tool_call/result, max_rounds, **error** (`retryable`/`source`), **cancelled** (`reason`), **usage** (`TokenUsage`).
|
||||||
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
- Usage: API `usage` from completions (stream with `include_usage`); **char≈token fallback** (~4 chars/token) when omitted; **context size** = last request ``prompt_tokens`` (API preferred); `last_turn_usage` / `session_usage` are **billed sums** (tool rounds re-send history); CLI end-of-turn + `/status`.
|
||||||
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
|
- Config ``[agent]``: `system_prompt`, `max_tool_result_chars`, `parallel_tools`, `confirm_destructive`, `path_denylist`, `max_context_tokens` (default 200k est. tokens).
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
|||||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
||||||
|
|
||||||
- **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`).
|
- **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`).
|
||||||
- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/compact`, `/status` (incl. context char estimate), `/rounds`, `/retry`, …
|
- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/compact`, `/status` (incl. context char estimate), `/rounds`, `/stream`, `/verbose`, `/retry`, …
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
|
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
|
||||||
- Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message).
|
- Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message).
|
||||||
- **`plyngent providers`**: list config providers.
|
- **`plyngent providers`**: list config providers.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .events import AssistantMessageEvent as AssistantMessageEvent
|
|||||||
from .events import CancelledEvent as CancelledEvent
|
from .events import CancelledEvent as CancelledEvent
|
||||||
from .events import ErrorEvent as ErrorEvent
|
from .events import ErrorEvent as ErrorEvent
|
||||||
from .events import MaxRoundsEvent as MaxRoundsEvent
|
from .events import MaxRoundsEvent as MaxRoundsEvent
|
||||||
|
from .events import ReasoningDeltaEvent as ReasoningDeltaEvent
|
||||||
from .events import TextDeltaEvent as TextDeltaEvent
|
from .events import TextDeltaEvent as TextDeltaEvent
|
||||||
from .events import ToolCallEvent as ToolCallEvent
|
from .events import ToolCallEvent as ToolCallEvent
|
||||||
from .events import ToolResultEvent as ToolResultEvent
|
from .events import ToolResultEvent as ToolResultEvent
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ class TextDeltaEvent(Struct, tag_field="type", tag="text_delta"):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningDeltaEvent(Struct, tag_field="type", tag="reasoning_delta"):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class AssistantMessageEvent(Struct, tag_field="type", tag="assistant_message"):
|
class AssistantMessageEvent(Struct, tag_field="type", tag="assistant_message"):
|
||||||
message: AssistantChatMessage
|
message: AssistantChatMessage
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ class UsageEvent(Struct, tag_field="type", tag="usage"):
|
|||||||
|
|
||||||
type AgentEvent = (
|
type AgentEvent = (
|
||||||
TextDeltaEvent
|
TextDeltaEvent
|
||||||
|
| ReasoningDeltaEvent
|
||||||
| AssistantMessageEvent
|
| AssistantMessageEvent
|
||||||
| ToolCallEvent
|
| ToolCallEvent
|
||||||
| ToolResultEvent
|
| ToolResultEvent
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from .events import (
|
|||||||
AssistantMessageEvent,
|
AssistantMessageEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
MaxRoundsEvent,
|
MaxRoundsEvent,
|
||||||
|
ReasoningDeltaEvent,
|
||||||
TextDeltaEvent,
|
TextDeltaEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
@@ -122,6 +123,9 @@ async def _non_stream_round(
|
|||||||
msg = "chat completion response contained no choices"
|
msg = "chat completion response contained no choices"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
assistant = response.choices[0].message
|
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:
|
if isinstance(assistant.content, str) and assistant.content:
|
||||||
yield TextDeltaEvent(content=assistant.content)
|
yield TextDeltaEvent(content=assistant.content)
|
||||||
yield AssistantMessageEvent(message=assistant)
|
yield AssistantMessageEvent(message=assistant)
|
||||||
@@ -147,6 +151,7 @@ async def _stream_round(
|
|||||||
)
|
)
|
||||||
stream = await client.chat_completions(stream_param, stream=True)
|
stream = await client.chat_completions(stream_param, stream=True)
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
|
reasoning_parts: list[str] = []
|
||||||
tool_deltas: list[StreamToolCallDelta] = []
|
tool_deltas: list[StreamToolCallDelta] = []
|
||||||
last_api_usage: object = UNSET
|
last_api_usage: object = UNSET
|
||||||
|
|
||||||
@@ -158,6 +163,9 @@ async def _stream_round(
|
|||||||
continue
|
continue
|
||||||
choice = chunk.choices[0]
|
choice = chunk.choices[0]
|
||||||
delta = choice.delta
|
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:
|
if isinstance(delta.content, str) and delta.content:
|
||||||
content_parts.append(delta.content)
|
content_parts.append(delta.content)
|
||||||
yield TextDeltaEvent(content=delta.content)
|
yield TextDeltaEvent(content=delta.content)
|
||||||
@@ -165,6 +173,7 @@ async def _stream_round(
|
|||||||
tool_deltas.extend(delta.tool_calls)
|
tool_deltas.extend(delta.tool_calls)
|
||||||
|
|
||||||
full_content = "".join(content_parts)
|
full_content = "".join(content_parts)
|
||||||
|
full_reasoning = "".join(reasoning_parts)
|
||||||
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
tool_calls: list[AnyAssistantToolCall] | Unset = UNSET
|
||||||
if tool_deltas:
|
if tool_deltas:
|
||||||
calls = merge_stream_tool_calls(tool_deltas)
|
calls = merge_stream_tool_calls(tool_deltas)
|
||||||
@@ -174,6 +183,7 @@ async def _stream_round(
|
|||||||
assistant = AssistantChatMessage(
|
assistant = AssistantChatMessage(
|
||||||
content=full_content or None,
|
content=full_content or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
|
reasoning_content=full_reasoning or UNSET,
|
||||||
)
|
)
|
||||||
yield AssistantMessageEvent(message=assistant)
|
yield AssistantMessageEvent(message=assistant)
|
||||||
yield UsageEvent(
|
yield UsageEvent(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -8,6 +9,7 @@ from plyngent.agent import (
|
|||||||
CancelledEvent,
|
CancelledEvent,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
MaxRoundsEvent,
|
MaxRoundsEvent,
|
||||||
|
ReasoningDeltaEvent,
|
||||||
TextDeltaEvent,
|
TextDeltaEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
@@ -23,6 +25,18 @@ if TYPE_CHECKING:
|
|||||||
_TOOL_RESULT_PREVIEW = 120
|
_TOOL_RESULT_PREVIEW = 120
|
||||||
_TOOL_ARGS_PREVIEW = 80
|
_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:
|
def _preview(text: str, limit: int) -> str:
|
||||||
if len(text) <= limit:
|
if len(text) <= limit:
|
||||||
@@ -41,11 +55,25 @@ def _echo_stream(text: str) -> None:
|
|||||||
pass
|
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)."""
|
"""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
|
printed_text = False
|
||||||
async for event in events:
|
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:
|
if not printed_text:
|
||||||
click.echo()
|
click.echo()
|
||||||
click.secho("assistant: ", fg="cyan", nl=False)
|
click.secho("assistant: ", fg="cyan", nl=False)
|
||||||
@@ -59,7 +87,11 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C90
|
|||||||
else:
|
else:
|
||||||
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
click.secho(f"\n[tool] custom id={call.id}", fg="yellow")
|
||||||
elif isinstance(event, ToolResultEvent):
|
elif isinstance(event, ToolResultEvent):
|
||||||
preview = _preview(event.message.content, _TOOL_RESULT_PREVIEW)
|
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.
|
# Single-line summary; full result stays in the message history for the model.
|
||||||
one_line = preview.replace("\n", " ")
|
one_line = preview.replace("\n", " ")
|
||||||
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
click.secho(f"[tool ok] {one_line}", fg="magenta")
|
||||||
@@ -89,6 +121,6 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None: # noqa: C90
|
|||||||
else:
|
else:
|
||||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||||
_ = event
|
_ = event
|
||||||
if printed_text:
|
if printed_text or printed_reasoning:
|
||||||
click.echo()
|
click.echo()
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ SLASH_COMMANDS: tuple[str, ...] = (
|
|||||||
"/provider",
|
"/provider",
|
||||||
"/model",
|
"/model",
|
||||||
"/tools",
|
"/tools",
|
||||||
|
"/stream",
|
||||||
|
"/verbose",
|
||||||
"/rounds",
|
"/rounds",
|
||||||
"/retry",
|
"/retry",
|
||||||
"/status",
|
"/status",
|
||||||
)
|
)
|
||||||
|
|
||||||
_TOOLS_ARGS: tuple[str, ...] = ("on", "off")
|
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
||||||
|
|
||||||
|
|
||||||
def history_path() -> Path:
|
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()))
|
return filter_prefix(text, sorted(state.config.providers.keys()))
|
||||||
if command == "/model":
|
if command == "/model":
|
||||||
return filter_prefix(text, sorted(state.provider.models.keys()))
|
return filter_prefix(text, sorted(state.provider.models.keys()))
|
||||||
if command == "/tools":
|
if command in {"/tools", "/stream", "/verbose"}:
|
||||||
return filter_prefix(text, list(_TOOLS_ARGS))
|
return filter_prefix(text, list(_ON_OFF_ARGS))
|
||||||
if command == "/resume":
|
if command == "/resume":
|
||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ Commands:
|
|||||||
/provider [name] Show or switch provider
|
/provider [name] Show or switch provider
|
||||||
/model [id] Show or switch model
|
/model [id] Show or switch model
|
||||||
/tools [on|off] Show or toggle tools
|
/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
|
/rounds [n] Show or set max tool-loop rounds
|
||||||
/retry Re-run incomplete last user turn (DB/orphan user; no retype)
|
/retry Re-run incomplete last user turn (DB/orphan user; no retype)
|
||||||
/status Show session/provider/tools/rounds status
|
/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
|
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.
|
works after resume, not only via readline history). Auto-retry: 10s/20s/30s.
|
||||||
|
|
||||||
Tab completes slash commands and some arguments (provider, model, tools).
|
Tab completes slash commands and some arguments (provider, model, tools,
|
||||||
Use --session ID or /resume to continue a prior chat after restart.
|
stream, verbose). Use --session ID or /resume to continue a prior chat
|
||||||
|
after restart.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type SlashHandler = Callable[[], None | Awaitable[None]]
|
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"session={state.session_id} messages={len(state.agent.messages)} "
|
||||||
f"pending_retry={pending_disp}\n"
|
f"pending_retry={pending_disp}\n"
|
||||||
f"tools={'on' if state.tools_enabled else 'off'} "
|
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_tokens={ctx_tilde}{ctx_tokens}/{ctx_budget} ({ctx_tag}) "
|
||||||
f"context_chars={ctx_chars} "
|
f"context_chars={ctx_chars} "
|
||||||
f"tool_result_max={state.agent.max_tool_result_chars}\n"
|
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'}")
|
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:
|
def _cmd_rounds(state: ReplState, arg: str) -> None:
|
||||||
token = arg.strip()
|
token = arg.strip()
|
||||||
if not token:
|
if not token:
|
||||||
@@ -287,6 +326,8 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
|
|||||||
"provider": lambda: _cmd_provider(state, arg),
|
"provider": lambda: _cmd_provider(state, arg),
|
||||||
"model": lambda: _cmd_model(state, arg),
|
"model": lambda: _cmd_model(state, arg),
|
||||||
"tools": lambda: _cmd_tools(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),
|
"rounds": lambda: _cmd_rounds(state, arg),
|
||||||
"retry": lambda: _cmd_retry(state),
|
"retry": lambda: _cmd_retry(state),
|
||||||
"status": lambda: _cmd_status(state),
|
"status": lambda: _cmd_status(state),
|
||||||
@@ -323,6 +364,7 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
f"session={state.session_id} tools={'on' if state.tools_enabled else 'off'} "
|
||||||
f"rounds={state.max_rounds} messages={len(state.agent.messages)} "
|
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.")
|
click.echo("Type /help for commands. Empty line is ignored.")
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ class ReplState:
|
|||||||
model: str
|
model: str
|
||||||
tools_enabled: bool
|
tools_enabled: bool
|
||||||
max_rounds: int = DEFAULT_MAX_ROUNDS
|
max_rounds: int = DEFAULT_MAX_ROUNDS
|
||||||
|
stream_enabled: bool = True
|
||||||
|
verbose: bool = False
|
||||||
client: ChatClient = field(init=False)
|
client: ChatClient = field(init=False)
|
||||||
agent: ChatAgent = field(init=False)
|
agent: ChatAgent = field(init=False)
|
||||||
session_id: int | None = None
|
session_id: int | None = None
|
||||||
@@ -38,6 +40,12 @@ class ReplState:
|
|||||||
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
self.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
||||||
self.workspace = Path(self.workspace).expanduser().resolve()
|
self.workspace = Path(self.workspace).expanduser().resolve()
|
||||||
self.agent = self._make_agent()
|
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:
|
def _workspace_key(self) -> str:
|
||||||
key = normalize_workspace(self.workspace)
|
key = normalize_workspace(self.workspace)
|
||||||
@@ -74,7 +82,7 @@ class ReplState:
|
|||||||
session_id=self.session_id,
|
session_id=self.session_id,
|
||||||
max_rounds=self.max_rounds,
|
max_rounds=self.max_rounds,
|
||||||
on_limit=prompt_continue_limit_async,
|
on_limit=prompt_continue_limit_async,
|
||||||
stream=True,
|
stream=self.stream_enabled,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||||
parallel_tools=agent_cfg.parallel_tools,
|
parallel_tools=agent_cfg.parallel_tools,
|
||||||
@@ -84,9 +92,13 @@ class ReplState:
|
|||||||
def rebuild_client(self) -> None:
|
def rebuild_client(self) -> None:
|
||||||
"""Recreate client and agent after provider/model/tools change."""
|
"""Recreate client and agent after provider/model/tools change."""
|
||||||
messages = list(self.agent.messages)
|
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.client = cast("ChatClient", cast("object", create_client(self.provider)))
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
self.agent.messages = messages
|
self.agent.messages = messages
|
||||||
|
self.sync_display_flags()
|
||||||
|
|
||||||
def _set_workspace(self, path: Path) -> None:
|
def _set_workspace(self, path: Path) -> None:
|
||||||
"""Update REPL + tool workspace root."""
|
"""Update REPL + tool workspace root."""
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ type DeepSeekReasoningEffort = ReasoningEffort | Literal["max"]
|
|||||||
|
|
||||||
|
|
||||||
class AssistantChatMessage(BaseAssistantChatMessage):
|
class AssistantChatMessage(BaseAssistantChatMessage):
|
||||||
|
# reasoning_content lives on the base assistant message (OpenAI-compat).
|
||||||
prefix: bool | Unset = UNSET
|
prefix: bool | Unset = UNSET
|
||||||
reasoning_content: str | Unset = UNSET
|
|
||||||
|
|
||||||
|
|
||||||
class ToolChatMessage(BaseToolChatMessage):
|
class ToolChatMessage(BaseToolChatMessage):
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class AssistantChatMessage(Struct, tag_field="role", tag="assistant"):
|
|||||||
audio: IDObject | Unset = UNSET
|
audio: IDObject | Unset = UNSET
|
||||||
refusal: str | Unset = UNSET
|
refusal: str | Unset = UNSET
|
||||||
tool_calls: list[AnyAssistantToolCall] | 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"):
|
class ToolChatMessage(ChatMessage, tag_field="role", tag="tool"):
|
||||||
@@ -235,6 +237,7 @@ class StreamToolCallDelta(Struct):
|
|||||||
class DeltaMessage(Struct):
|
class DeltaMessage(Struct):
|
||||||
role: RoleAssistant | Unset = UNSET
|
role: RoleAssistant | Unset = UNSET
|
||||||
content: str | Unset = UNSET
|
content: str | Unset = UNSET
|
||||||
|
reasoning_content: str | Unset = UNSET
|
||||||
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
tool_calls: list[StreamToolCallDelta] | Unset = UNSET
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from plyngent.agent import (
|
|||||||
AssistantMessageEvent,
|
AssistantMessageEvent,
|
||||||
ChatAgent,
|
ChatAgent,
|
||||||
MaxRoundsEvent,
|
MaxRoundsEvent,
|
||||||
|
ReasoningDeltaEvent,
|
||||||
TextDeltaEvent,
|
TextDeltaEvent,
|
||||||
ToolCallEvent,
|
ToolCallEvent,
|
||||||
ToolRegistry,
|
ToolRegistry,
|
||||||
@@ -338,6 +339,78 @@ async def test_stream_yields_deltas_incrementally() -> None:
|
|||||||
assert messages[-1].content == "ab"
|
assert messages[-1].content == "ab"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_yields_reasoning_deltas() -> None:
|
||||||
|
class ReasoningClient:
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||||
|
) -> ChatCompletionResponse: ...
|
||||||
|
|
||||||
|
@overload
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||||
|
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||||
|
|
||||||
|
async def chat_completions(
|
||||||
|
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||||
|
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||||
|
del param
|
||||||
|
if not stream:
|
||||||
|
return _response(AssistantChatMessage(content="ans", reasoning_content="think"))
|
||||||
|
|
||||||
|
async def chunks() -> AsyncIterator[ChatCompletionChunk]:
|
||||||
|
for part in ("th", "ink"):
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(reasoning_content=part),
|
||||||
|
finish_reason=None,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
yield ChatCompletionChunk(
|
||||||
|
id="1",
|
||||||
|
object="chat.completion.chunk",
|
||||||
|
created=0,
|
||||||
|
model="t",
|
||||||
|
choices=[
|
||||||
|
ChunkChoice(
|
||||||
|
index=0,
|
||||||
|
delta=DeltaMessage(content="ans"),
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks()
|
||||||
|
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
events = [e async for e in run_chat_loop(ReasoningClient(), messages, model="m", stream=True)]
|
||||||
|
reasoning = [e for e in events if isinstance(e, ReasoningDeltaEvent)]
|
||||||
|
assert [r.content for r in reasoning] == ["th", "ink"]
|
||||||
|
deltas = [e for e in events if isinstance(e, TextDeltaEvent)]
|
||||||
|
assert [d.content for d in deltas] == ["ans"]
|
||||||
|
assert isinstance(messages[-1], AssistantChatMessage)
|
||||||
|
assert messages[-1].content == "ans"
|
||||||
|
assert messages[-1].reasoning_content == "think"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_stream_yields_reasoning() -> None:
|
||||||
|
client = ScriptedClient([_response(AssistantChatMessage(content="ok", reasoning_content="plan"))])
|
||||||
|
messages: list[AnyChatMessage] = [UserChatMessage(content="hi")]
|
||||||
|
events = [e async for e in run_chat_loop(client, messages, model="m", stream=False)]
|
||||||
|
reasoning = [e for e in events if isinstance(e, ReasoningDeltaEvent)]
|
||||||
|
assert len(reasoning) == 1
|
||||||
|
assert reasoning[0].content == "plan"
|
||||||
|
assert isinstance(messages[-1], AssistantChatMessage)
|
||||||
|
assert messages[-1].reasoning_content == "plan"
|
||||||
|
|
||||||
|
|
||||||
async def test_run_chat_loop_with_tools() -> None:
|
async def test_run_chat_loop_with_tools() -> None:
|
||||||
@tool
|
@tool
|
||||||
def add(a: int, b: int) -> int:
|
def add(a: int, b: int) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from plyngent.agent import ReasoningDeltaEvent, TextDeltaEvent, ToolResultEvent
|
||||||
|
from plyngent.cli.display import render_events, set_verbose_tool_results
|
||||||
|
from plyngent.lmproto.openai_compatible.model import ToolChatMessage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plyngent.agent import AgentEvent
|
||||||
|
|
||||||
|
|
||||||
|
async def _aiter(events: list[AgentEvent]) -> AsyncIterator[AgentEvent]:
|
||||||
|
for event in events:
|
||||||
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
async def test_render_reasoning_and_text(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
await render_events(
|
||||||
|
_aiter(
|
||||||
|
[
|
||||||
|
ReasoningDeltaEvent(content="think"),
|
||||||
|
TextDeltaEvent(content="hello"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "reasoning: " in out
|
||||||
|
assert "think" in out
|
||||||
|
assert "assistant: " in out
|
||||||
|
assert "hello" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_result_preview_vs_verbose(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
long = "x" * 200
|
||||||
|
msg = ToolChatMessage(content=long, tool_call_id="1")
|
||||||
|
set_verbose_tool_results(False)
|
||||||
|
await render_events(_aiter([ToolResultEvent(message=msg)]))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "…" in out
|
||||||
|
assert long not in out
|
||||||
|
|
||||||
|
set_verbose_tool_results(True)
|
||||||
|
await render_events(_aiter([ToolResultEvent(message=msg)]), verbose=True)
|
||||||
|
out2 = capsys.readouterr().out
|
||||||
|
assert long in out2
|
||||||
|
set_verbose_tool_results(False)
|
||||||
@@ -77,6 +77,8 @@ def test_completer_commands(tmp_path: object, monkeypatch: object) -> None:
|
|||||||
found.append(item)
|
found.append(item)
|
||||||
index += 1
|
index += 1
|
||||||
assert "/help" in found
|
assert "/help" in found
|
||||||
|
assert "/stream" in SLASH_COMMANDS
|
||||||
|
assert "/verbose" in SLASH_COMMANDS
|
||||||
assert set(found) <= set(SLASH_COMMANDS)
|
assert set(found) <= set(SLASH_COMMANDS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,27 @@ async def test_tools_toggle(state: ReplState) -> None:
|
|||||||
assert state.tools_enabled is False
|
assert state.tools_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_toggle(state: ReplState) -> None:
|
||||||
|
assert state.agent.stream is True
|
||||||
|
assert await handle_slash(state, "/stream off") is True
|
||||||
|
assert state.agent.stream is False
|
||||||
|
assert state.stream_enabled is False
|
||||||
|
assert await handle_slash(state, "/stream on") is True
|
||||||
|
assert state.agent.stream is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verbose_toggle(state: ReplState) -> None:
|
||||||
|
from plyngent.cli.display import get_verbose_tool_results
|
||||||
|
|
||||||
|
assert state.verbose is False
|
||||||
|
assert await handle_slash(state, "/verbose on") is True
|
||||||
|
assert state.verbose is True
|
||||||
|
assert get_verbose_tool_results() is True
|
||||||
|
assert await handle_slash(state, "/verbose off") is True
|
||||||
|
assert state.verbose is False
|
||||||
|
assert get_verbose_tool_results() is False
|
||||||
|
|
||||||
|
|
||||||
async def test_resume(state: ReplState) -> None:
|
async def test_resume(state: ReplState) -> None:
|
||||||
sid = state.session_id
|
sid = state.session_id
|
||||||
assert sid is not None
|
assert sid is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user