From 908e68665b00fc87e142316d2f2cd276ffc1ab5b Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 17:42:34 +0800 Subject: [PATCH] core/agent: run_aside side turns; CLI /btw with --tools --- src/plyngent/agent/chat.py | 94 ++++++++++++- src/plyngent/agent/tools.py | 27 ++++ src/plyngent/cli/slash.py | 77 ++++++++++- tests/test_agent/test_run_aside.py | 211 +++++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 tests/test_agent/test_run_aside.py diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index dfebb6b..af7624c 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -25,6 +25,7 @@ from .todo_nag import ( parse_todo_nag_strategy, refresh_synthetic_todo_nags, ) +from .tools import ToolRegistry from .usage import TokenUsage if TYPE_CHECKING: @@ -37,7 +38,6 @@ if TYPE_CHECKING: from .events import AgentEvent from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack - from .tools import ToolRegistry type LimitContinueHook = Callable[[str], bool | Awaitable[bool]] @@ -374,6 +374,98 @@ class ChatAgent: async for event in self._run_from_user_message(user_msg): yield event + def _resolve_aside_tools( + self, + *, + tools: ToolRegistry | bool | None, + instance_state: object | None, + session_state: object | None, + ) -> ToolRegistry | None: + """Resolve the tools argument for :meth:`run_aside`.""" + if tools is False or tools is None: + return None + if tools is True: + if self.tools is None: + return None + return self.tools.clone( + instance_state=instance_state, + session_state=session_state, + ) + reg = tools + if instance_state is not None: + reg.set_instance_state(instance_state) + if session_state is not None: + reg.set_session_state(session_state) + return reg + + async def run_aside( + self, + user_text: str, + *, + include_history: bool = True, + tools: ToolRegistry | bool | None = False, + max_rounds: int | None = None, + system_prompt: str | None = None, + instance_state: object | None = None, + session_state: object | None = None, + ) -> AsyncIterator[AgentEvent]: + """Run a side turn that does not mutate this agent or its memory. + + - Message list is forked (optional history copy); never written back. + - ``memory`` / ``session_id`` are unset on the side agent (no DB). + - ``todo_stack`` is unset (no nags / main stack thrash). + - Tools default **off**. ``tools=True`` clones this agent's registry with + a **fresh session** bag (unless *session_state* is passed) and the + given *instance_state* (CLI typically shares the host instance for + workspace identity). + """ + text = user_text.strip() + if not text: + msg = "aside question must not be empty" + raise ValueError(msg) + + # Prefer explicit session fork; default empty SessionState when tools on. + aside_session = session_state + aside_instance = instance_state + if tools is True or isinstance(tools, ToolRegistry): + if aside_session is None: + from plyngent.tools.context import SessionState + + aside_session = SessionState() + if aside_instance is None and self.tools is not None: + # Inherit host instance when the main registry holds one. + aside_instance = getattr(self.tools, "_instance", None) + + aside_tools = self._resolve_aside_tools( + tools=tools, + instance_state=aside_instance, + session_state=aside_session, + ) + history = list(self.messages) if include_history else [] + rounds = self.max_rounds if max_rounds is None else max_rounds + if aside_tools is None and max_rounds is None: + rounds = 1 + aside = ChatAgent( + self.client, + model=self.model, + tools=aside_tools, + memory=None, + session_id=None, + max_rounds=rounds, + temperature=self.temperature, + on_limit=self.on_limit, + stream=self.stream, + system_prompt=self.system_prompt if system_prompt is None else system_prompt, + max_tool_result_chars=self.max_tool_result_chars, + parallel_tools=self.parallel_tools, + max_context_tokens=self.max_context_tokens, + todo_stack=None, + todo_nag_strategy="none", + messages=history, + ) + async for event in aside.run(text): + yield event + async def retry(self) -> AsyncIterator[AgentEvent]: """Continue an incomplete turn without re-appending the user message. diff --git a/src/plyngent/agent/tools.py b/src/plyngent/agent/tools.py index f6f4d59..1ac7254 100644 --- a/src/plyngent/agent/tools.py +++ b/src/plyngent/agent/tools.py @@ -255,6 +255,33 @@ class ToolRegistry: def __len__(self) -> int: return len(self._tools) + def definitions(self) -> list[ToolDefinition]: + """Return tool definitions in registration order (for cloning registries).""" + return list(self._tools.values()) + + def clone( + self, + *, + instance_state: object | None = None, + session_state: object | None = None, + yolo: bool | None = None, + auto_bind_state: bool | None = None, + ) -> ToolRegistry: + """New registry with the same tools/hooks and rebound host state. + + *instance_state* / *session_state* are applied as given (use the parent + registry's handles only when the caller passes them through). + """ + return ToolRegistry( + self.definitions(), + danger=self._danger, + on_confirm=self._on_confirm, + yolo=self._yolo if yolo is None else yolo, + auto_bind_state=self._auto_bind_state if auto_bind_state is None else auto_bind_state, + instance_state=instance_state, + session_state=session_state, + ) + def set_yolo(self, *, enabled: bool) -> None: self._yolo = enabled diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index a420a74..634fb51 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -82,9 +82,9 @@ HELP_FOOTER = ( "assistant/tool output is discarded but the user message stays (so /retry\n" "works after resume, not only via readline history). Auto-retry: 10s/20s/30s.\n" "\n" - "Tab completes slash commands and arguments (provider, model, session ids,\n" - "todos actions, on/off, yolo, export, flags). Use --session ID or /resume to\n" - "continue a prior chat after restart.\n" + "Side questions: /btw … answers without changing the main session\n" + "(optional --tools / --fresh). Tab completes slash commands and arguments.\n" + "Use --session ID or /resume to continue a prior chat after restart.\n" "\n" 'Multiline: start a message with """ then end a later line with """.\n' "Long prompts: /edit opens $VISUAL/$EDITOR (blocking).\n" @@ -920,6 +920,77 @@ def _format_tool_tags(tags: object) -> str: return "|".join(names) if names else str(tags) +@slash.command("btw") +@click.argument("question", nargs=-1, required=False) +@click.option( + "--tools/--no-tools", + "with_tools", + default=False, + show_default=True, + help="Allow tools on the side turn (shared workspace; forked session grants/todo).", +) +@click.option( + "--fresh", + is_flag=True, + default=False, + help="Ignore main history; system prompt only.", +) +@click.pass_obj +def btw_cmd( + state: ReplState, + question: tuple[str, ...], + *, + with_tools: bool, + fresh: bool, +) -> None: + """Ask a side question without changing the main session transcript or DB. + + Uses the current model and (unless ``--fresh``) a copy of main history. + Tools are **off** by default; ``--tools`` clones the main tool registry with + a fresh session bag and the shared instance workspace. + """ + text = " ".join(question).strip() + if not text: + click.echo("usage: /btw [--tools] [--fresh] ") + return + if state.agent.pending_retry_text is not None: + click.echo("error: finish or /retry the main turn before /btw") + return + if with_tools and (not state.tools_enabled or state.agent.tools is None): + click.echo("error: --tools requires tools on (/tools on)") + return + + from plyngent.cli.display import render_events + from plyngent.cli.retry import run_cancellable + from plyngent.tools.context import SessionState + + click.secho("btw: ", fg="magenta", nl=False) + click.echo(text) + tools_arg: bool = with_tools + aside_session = SessionState() if with_tools else None + aside_instance = state.instance_state if with_tools else None + + async def _run() -> None: + await run_cancellable( + render_events( + state.agent.run_aside( + text, + include_history=not fresh, + tools=tools_arg, + instance_state=aside_instance, + session_state=aside_session, + ) + ) + ) + + try: + _await(_run()) + except Exception as exc: # noqa: BLE001 — surface side-turn failures + click.secho(f"error: btw failed: {exc}", fg="red") + return + click.secho("(btw done — main session unchanged)", fg="bright_black") + + @slash.command("tools") @click.argument("enabled", required=False, type=ON_OFF, metavar="[on|off]") @click.option( diff --git a/tests/test_agent/test_run_aside.py b/tests/test_agent/test_run_aside.py new file mode 100644 index 0000000..b176fb9 --- /dev/null +++ b/tests/test_agent/test_run_aside.py @@ -0,0 +1,211 @@ +"""ChatAgent.run_aside: side turns leave main transcript/memory alone.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +import pytest + +from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, tool +from plyngent.agent.todo_stack import TodoStack +from plyngent.config.models import DatabaseConfig +from plyngent.lmproto.openai_compatible.model import ( + AnyChatMessage, + AssistantChatMessage, + ChatCompletionChoice, + ChatCompletionChunk, + ChatCompletionResponse, + ChatCompletionsParam, + UserChatMessage, +) +from plyngent.memory import MemoryStore +from plyngent.tools.context import InstanceState, SessionState + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + +class ScriptedClient: + """Returns a fixed assistant string each call.""" + + def __init__(self, replies: list[str] | None = None) -> None: + self.replies = list(replies or ["aside-answer"]) + self.calls = 0 + self.payloads: list[list[AnyChatMessage]] = [] + + @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 stream + self.calls += 1 + self.payloads.append(list(param.messages)) + text = self.replies[(self.calls - 1) % len(self.replies)] + return ChatCompletionResponse( + id="1", + object="chat.completion", + created=0, + model="m", + choices=[ + ChatCompletionChoice( + index=0, + message=AssistantChatMessage(content=text), + logprobs={}, + finish_reason="stop", + ) + ], + system_fingerprint="", + usage={}, + ) + + +@pytest.mark.asyncio +async def test_run_aside_does_not_mutate_main_or_memory() -> None: + memory = await MemoryStore.open(DatabaseConfig()) + try: + session = await memory.create_session(name="main") + client = ScriptedClient(["main-reply", "aside-reply", "main-again"]) + agent = ChatAgent( + cast("Any", client), + model="m", + memory=memory, + session_id=session.sid, + stream=False, + todo_stack=TodoStack(), + ) + async for _ in agent.run("hello main"): + pass + main_len = len(agent.messages) + main_snapshot = list(agent.messages) + db_before = await memory.list_messages(session.sid) + + texts: list[str] = [] + async for event in agent.run_aside("side question?", include_history=True, tools=False): + from plyngent.agent.events import AssistantMessageEvent + + if isinstance(event, AssistantMessageEvent) and event.message.content: + texts.append(str(event.message.content)) + + assert any("aside-reply" in t for t in texts) + assert len(agent.messages) == main_len + assert agent.messages == main_snapshot + db_after = await memory.list_messages(session.sid) + assert len(db_after) == len(db_before) + # Aside request saw main history + side user. + aside_payload = client.payloads[1] + assert any(isinstance(m, UserChatMessage) and "side question" in m.content for m in aside_payload) + assert any(isinstance(m, UserChatMessage) and "hello main" in m.content for m in aside_payload) + finally: + await memory.close() + + +@pytest.mark.asyncio +async def test_run_aside_fresh_skips_history() -> None: + client = ScriptedClient(["only"]) + agent = ChatAgent(cast("Any", client), model="m", stream=False) + agent.messages.append(UserChatMessage(content="prior")) + async for _ in agent.run_aside("q", include_history=False, tools=False): + pass + payload = client.payloads[0] + users = [m.content for m in payload if isinstance(m, UserChatMessage)] + assert users == ["q"] + + +@pytest.mark.asyncio +async def test_run_aside_tools_clones_registry_with_fresh_session() -> None: + hits: list[str] = [] + + @tool(tags=ToolTag.LOCAL | ToolTag.SESSION_STATE, register=False) + async def note_session() -> str: + from plyngent.tools.context import get_session + + session = get_session() + hits.append("ok" if session is not None else "none") + if session is not None: + session.extras["aside"] = True + return "noted" + + main_session = SessionState(session_id="main") + instance = InstanceState() + registry = ToolRegistry( + [note_session], + auto_bind_state=True, + instance_state=instance, + session_state=main_session, + ) + + class ToolThenStop: + def __init__(self) -> None: + self.calls = 0 + + @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 stream, param + self.calls += 1 + from plyngent.lmproto.openai_compatible.model import ( + AssistantFunctionTool, + AssistantFunctionToolCall, + ) + + if self.calls == 1: + message = AssistantChatMessage( + content="", + tool_calls=[ + AssistantFunctionToolCall( + id="c1", + function=AssistantFunctionTool(name="note_session", arguments="{}"), + ) + ], + ) + finish = "tool_calls" + else: + message = AssistantChatMessage(content="done") + finish = "stop" + return ChatCompletionResponse( + id="1", + object="chat.completion", + created=0, + model="m", + choices=[ + ChatCompletionChoice( + index=0, + message=message, + logprobs={}, + finish_reason=finish, + ) + ], + system_fingerprint="", + usage={}, + ) + + client = ToolThenStop() + agent = ChatAgent(cast("Any", client), model="m", tools=registry, stream=False) + async for _ in agent.run_aside( + "use tool", + tools=True, + instance_state=instance, + session_state=SessionState(session_id="aside"), + ): + pass + assert hits == ["ok"] + assert "aside" not in main_session.extras