mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 14:14:57 +08:00
core/cli: interactive continue prompts for loop and PTY limits
on_limit raises tool-round allowance; PTY session/budget limits can prompt to raise; MaxRoundsEvent.continued marks extended runs.
This commit is contained in:
@@ -7,7 +7,7 @@ from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||
from plyngent.memory import MemoryStore
|
||||
@@ -16,6 +16,8 @@ if TYPE_CHECKING:
|
||||
from .events import AgentEvent
|
||||
from .tools import ToolRegistry
|
||||
|
||||
type LimitContinueHook = Callable[[str], bool]
|
||||
|
||||
|
||||
class ChatAgent:
|
||||
"""Thin wrapper: chat client + optional tools + optional memory bind."""
|
||||
@@ -27,6 +29,7 @@ class ChatAgent:
|
||||
session_id: int | None
|
||||
max_rounds: int
|
||||
temperature: float | None
|
||||
on_limit: LimitContinueHook | None
|
||||
messages: list[AnyChatMessage]
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
@@ -40,6 +43,7 @@ class ChatAgent:
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
temperature: float | None = None,
|
||||
messages: Sequence[AnyChatMessage] | None = None,
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.model = model
|
||||
@@ -48,6 +52,7 @@ class ChatAgent:
|
||||
self.session_id = session_id
|
||||
self.max_rounds = max_rounds
|
||||
self.temperature = temperature
|
||||
self.on_limit = on_limit
|
||||
self.messages = list(messages) if messages is not None else []
|
||||
|
||||
async def load_history(self) -> None:
|
||||
@@ -84,6 +89,7 @@ class ChatAgent:
|
||||
tools=self.tools,
|
||||
max_rounds=self.max_rounds,
|
||||
temperature=self.temperature,
|
||||
on_limit=self.on_limit,
|
||||
):
|
||||
yield event
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class ToolResultEvent(Struct, tag_field="type", tag="tool_result"):
|
||||
|
||||
class MaxRoundsEvent(Struct, tag_field="type", tag="max_rounds"):
|
||||
rounds: int
|
||||
continued: bool = False
|
||||
|
||||
|
||||
type AgentEvent = TextDeltaEvent | AssistantMessageEvent | ToolCallEvent | ToolResultEvent | MaxRoundsEvent
|
||||
|
||||
+60
-40
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
||||
from msgspec import UNSET
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AnyAssistantToolCall,
|
||||
AssistantChatMessage,
|
||||
AssistantFunctionToolCall,
|
||||
ChatCompletionsParam,
|
||||
@@ -21,17 +22,38 @@ from .events import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
|
||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||
|
||||
from .client import ChatClient
|
||||
from .tools import ToolRegistry
|
||||
|
||||
type LimitContinueHook = Callable[[str], bool]
|
||||
|
||||
|
||||
DEFAULT_MAX_ROUNDS = 32
|
||||
|
||||
|
||||
async def _execute_tool_calls(
|
||||
tools: ToolRegistry,
|
||||
tool_calls: Sequence[AnyAssistantToolCall],
|
||||
messages: list[AnyChatMessage],
|
||||
) -> AsyncIterator[AgentEvent]:
|
||||
for call in tool_calls:
|
||||
yield ToolCallEvent(tool_call=call)
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
result_text = await tools.execute(call.function.name, call.function.arguments)
|
||||
tool_msg = ToolChatMessage(content=result_text, tool_call_id=call.id)
|
||||
else:
|
||||
tool_msg = ToolChatMessage(
|
||||
content="error: custom tool calls are not supported",
|
||||
tool_call_id=call.id,
|
||||
)
|
||||
messages.append(tool_msg)
|
||||
yield ToolResultEvent(message=tool_msg)
|
||||
|
||||
|
||||
async def run_chat_loop( # noqa: PLR0913
|
||||
client: ChatClient,
|
||||
messages: list[AnyChatMessage],
|
||||
@@ -40,58 +62,56 @@ async def run_chat_loop( # noqa: PLR0913
|
||||
tools: ToolRegistry | None = None,
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS,
|
||||
temperature: float | None = None,
|
||||
on_limit: LimitContinueHook | None = None,
|
||||
) -> AsyncIterator[AgentEvent]:
|
||||
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
||||
|
||||
Continues until the model returns no tool calls, or ``max_rounds`` is hit.
|
||||
Streaming is non-stream LLM calls for reliable tool_calls; text is emitted
|
||||
as a single :class:`TextDeltaEvent` when content is present.
|
||||
If ``on_limit`` is set and returns True when the cap is reached, another
|
||||
batch of ``max_rounds`` is granted and the loop continues.
|
||||
"""
|
||||
tool_items: Sequence[AnyToolItem] | None = None
|
||||
if tools is not None and len(tools) > 0:
|
||||
tool_items = tools.tool_items()
|
||||
|
||||
for round_idx in range(max_rounds):
|
||||
param = ChatCompletionsParam(
|
||||
messages=list(messages),
|
||||
model=model,
|
||||
temperature=temperature if temperature is not None else UNSET,
|
||||
tools=list(tool_items) if tool_items is not None else UNSET,
|
||||
)
|
||||
response = await client.chat_completions(param, stream=False)
|
||||
if not response.choices:
|
||||
msg = "chat completion response contained no choices"
|
||||
raise RuntimeError(msg)
|
||||
assistant = response.choices[0].message
|
||||
messages.append(assistant)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
rounds_used = 0
|
||||
allowance = max_rounds
|
||||
|
||||
if isinstance(assistant.content, str) and assistant.content:
|
||||
yield TextDeltaEvent(content=assistant.content)
|
||||
while True:
|
||||
while rounds_used < allowance:
|
||||
rounds_used += 1
|
||||
param = ChatCompletionsParam(
|
||||
messages=list(messages),
|
||||
model=model,
|
||||
temperature=temperature if temperature is not None else UNSET,
|
||||
tools=list(tool_items) if tool_items is not None else UNSET,
|
||||
)
|
||||
response = await client.chat_completions(param, stream=False)
|
||||
if not response.choices:
|
||||
msg = "chat completion response contained no choices"
|
||||
raise RuntimeError(msg)
|
||||
assistant = response.choices[0].message
|
||||
messages.append(assistant)
|
||||
yield AssistantMessageEvent(message=assistant)
|
||||
|
||||
tool_calls = assistant.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
return
|
||||
if isinstance(assistant.content, str) and assistant.content:
|
||||
yield TextDeltaEvent(content=assistant.content)
|
||||
|
||||
if tools is None:
|
||||
return
|
||||
tool_calls = assistant.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
return
|
||||
if tools is None:
|
||||
return
|
||||
async for event in _execute_tool_calls(tools, tool_calls, messages):
|
||||
yield event
|
||||
|
||||
for call in tool_calls:
|
||||
yield ToolCallEvent(tool_call=call)
|
||||
if isinstance(call, AssistantFunctionToolCall):
|
||||
result_text = await tools.execute(call.function.name, call.function.arguments)
|
||||
tool_msg = ToolChatMessage(content=result_text, tool_call_id=call.id)
|
||||
else:
|
||||
tool_msg = ToolChatMessage(
|
||||
content="error: custom tool calls are not supported",
|
||||
tool_call_id=call.id,
|
||||
)
|
||||
messages.append(tool_msg)
|
||||
yield ToolResultEvent(message=tool_msg)
|
||||
|
||||
_ = round_idx # used only for loop bound
|
||||
|
||||
yield MaxRoundsEvent(rounds=max_rounds)
|
||||
reason = f"tool loop reached {allowance} rounds (used {rounds_used})"
|
||||
if on_limit is not None and on_limit(reason):
|
||||
yield MaxRoundsEvent(rounds=allowance, continued=True)
|
||||
allowance += max_rounds
|
||||
continue
|
||||
yield MaxRoundsEvent(rounds=allowance, continued=False)
|
||||
return
|
||||
|
||||
|
||||
def collect_assistant_messages(events: Sequence[AgentEvent]) -> list[AssistantChatMessage]:
|
||||
|
||||
@@ -14,6 +14,7 @@ from plyngent.cli.editor import (
|
||||
open_in_editor,
|
||||
resolve_config_path,
|
||||
)
|
||||
from plyngent.cli.limits import install_cli_limit_hooks
|
||||
from plyngent.cli.repl import run_repl
|
||||
from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.cli.state import ReplState
|
||||
@@ -66,6 +67,7 @@ async def _run_chat( # noqa: PLR0913
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
_ = set_workspace_root(workspace)
|
||||
install_cli_limit_hooks()
|
||||
memory = await MemoryStore.open(_database_config(store))
|
||||
try:
|
||||
state = ReplState(
|
||||
|
||||
@@ -48,7 +48,13 @@ async def render_events(events: AsyncIterator[AgentEvent]) -> None:
|
||||
)
|
||||
click.secho(f"[tool result] {preview}", fg="magenta")
|
||||
elif isinstance(event, MaxRoundsEvent):
|
||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||
if event.continued:
|
||||
click.secho(
|
||||
f"\n[max rounds {event.rounds} reached — continuing with a higher allowance]",
|
||||
fg="yellow",
|
||||
)
|
||||
else:
|
||||
click.secho(f"\n[max rounds reached: {event.rounds}]", fg="red")
|
||||
else:
|
||||
# AssistantMessageEvent — text already shown via TextDeltaEvent.
|
||||
_ = event
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
|
||||
def prompt_continue_limit(reason: str) -> bool:
|
||||
"""Ask the user whether to raise a limit and continue (TTY)."""
|
||||
click.echo()
|
||||
click.secho(f"[limit] {reason}", fg="yellow")
|
||||
try:
|
||||
return bool(click.confirm("Raise limit and continue?", default=True))
|
||||
except click.Abort:
|
||||
return False
|
||||
|
||||
|
||||
def install_cli_limit_hooks() -> None:
|
||||
"""Register interactive continue hooks for process-global tool limits."""
|
||||
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
||||
@@ -43,6 +43,8 @@ class ReplState:
|
||||
return ToolRegistry(list(DEFAULT_TOOLS))
|
||||
|
||||
def _make_agent(self) -> ChatAgent:
|
||||
from plyngent.cli.limits import prompt_continue_limit
|
||||
|
||||
return ChatAgent(
|
||||
self.client,
|
||||
model=self.model,
|
||||
@@ -50,6 +52,7 @@ class ReplState:
|
||||
memory=self.memory,
|
||||
session_id=self.session_id,
|
||||
max_rounds=self.max_rounds,
|
||||
on_limit=prompt_continue_limit,
|
||||
)
|
||||
|
||||
def rebuild_client(self) -> None:
|
||||
|
||||
@@ -10,16 +10,23 @@ import signal
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import ClassVar
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
type LimitContinueHook = Callable[[str], bool]
|
||||
|
||||
DEFAULT_PTY_READ_BYTES = 8192
|
||||
DEFAULT_PTY_POLL_TIMEOUT = 0.2
|
||||
DEFAULT_MAX_SESSIONS = 8
|
||||
DEFAULT_IDLE_TTL_SECONDS = 600.0
|
||||
DEFAULT_SESSION_OUTPUT_BUDGET = 256_000
|
||||
DEFAULT_CLOSE_GRACE_SECONDS = 0.5
|
||||
_SESSION_LIMIT_STEP = 4
|
||||
_BUDGET_STEP = 256_000
|
||||
_STDERR_FD = 2
|
||||
_EXEC_FAIL_MARKER = b"plyngent-pty-exec-failed: "
|
||||
|
||||
@@ -67,6 +74,7 @@ class PtyManager:
|
||||
max_sessions: ClassVar[int] = DEFAULT_MAX_SESSIONS
|
||||
idle_ttl_seconds: ClassVar[float] = DEFAULT_IDLE_TTL_SECONDS
|
||||
session_output_budget: ClassVar[int] = DEFAULT_SESSION_OUTPUT_BUDGET
|
||||
_limit_continue: ClassVar[LimitContinueHook | None] = None
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
@@ -83,6 +91,21 @@ class PtyManager:
|
||||
if session_output_budget is not None:
|
||||
cls.session_output_budget = max(1024, session_output_budget)
|
||||
|
||||
@classmethod
|
||||
def set_limit_continue_hook(cls, hook: LimitContinueHook | None) -> None:
|
||||
"""Optional interactive hook: return True to raise a limit and continue."""
|
||||
cls._limit_continue = hook
|
||||
|
||||
@classmethod
|
||||
def _offer_raise(cls, reason: str) -> bool:
|
||||
hook = cls._limit_continue
|
||||
if hook is None:
|
||||
return False
|
||||
try:
|
||||
return bool(hook(reason))
|
||||
except Exception: # noqa: BLE001 — never break tools on prompt failure
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def open(
|
||||
cls,
|
||||
@@ -100,8 +123,12 @@ class PtyManager:
|
||||
with cls._lock:
|
||||
alive_count = sum(1 for s in cls._sessions.values() if not s.closed)
|
||||
if alive_count >= cls.max_sessions:
|
||||
msg = f"PTY session limit reached ({cls.max_sessions}); close idle sessions"
|
||||
raise WorkspaceError(msg)
|
||||
reason = f"PTY session limit reached ({cls.max_sessions})"
|
||||
if cls._offer_raise(f"{reason}; raise by {_SESSION_LIMIT_STEP}?"):
|
||||
cls.max_sessions += _SESSION_LIMIT_STEP
|
||||
else:
|
||||
msg = f"{reason}; close idle sessions or allow a higher limit"
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
pid = os.fork()
|
||||
@@ -255,13 +282,19 @@ class PtyManager:
|
||||
raise WorkspaceError(msg)
|
||||
|
||||
if (cls.session_output_budget - session.bytes_read) <= 0:
|
||||
return PtyReadResult(
|
||||
session_id=session_id,
|
||||
alive=session.alive,
|
||||
exit_code=session.exit_code,
|
||||
data="",
|
||||
budget_exhausted=True,
|
||||
)
|
||||
if cls._offer_raise(
|
||||
f"PTY output budget exhausted for session {session_id} "
|
||||
f"({cls.session_output_budget} bytes); raise by {_BUDGET_STEP}?"
|
||||
):
|
||||
cls.session_output_budget += _BUDGET_STEP
|
||||
else:
|
||||
return PtyReadResult(
|
||||
session_id=session_id,
|
||||
alive=session.alive,
|
||||
exit_code=session.exit_code,
|
||||
data="",
|
||||
budget_exhausted=True,
|
||||
)
|
||||
|
||||
chunks, matched = cls._collect_chunks(
|
||||
session, max_bytes=max_bytes, timeout=timeout, until=until
|
||||
@@ -270,6 +303,13 @@ class PtyManager:
|
||||
truncated = len(data) > max_bytes
|
||||
if truncated:
|
||||
data = data[:max_bytes]
|
||||
budget_exhausted = (cls.session_output_budget - session.bytes_read) <= 0
|
||||
if budget_exhausted and cls._offer_raise(
|
||||
f"PTY output budget exhausted for session {session_id} "
|
||||
f"({cls.session_output_budget} bytes); raise by {_BUDGET_STEP}?"
|
||||
):
|
||||
cls.session_output_budget += _BUDGET_STEP
|
||||
budget_exhausted = False
|
||||
return PtyReadResult(
|
||||
session_id=session_id,
|
||||
alive=session.alive,
|
||||
@@ -277,7 +317,7 @@ class PtyManager:
|
||||
data=data,
|
||||
truncated=truncated,
|
||||
matched=matched,
|
||||
budget_exhausted=(cls.session_output_budget - session.bytes_read) <= 0,
|
||||
budget_exhausted=budget_exhausted,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user