mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/agent: run_aside side turns; CLI /btw with --tools
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] <question>")
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user