core/agent: run_aside side turns; CLI /btw with --tools

This commit is contained in:
2026-07-24 17:42:34 +08:00
parent 559a3410bc
commit 908e68665b
4 changed files with 405 additions and 4 deletions
+93 -1
View File
@@ -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.
+27
View File
@@ -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
+74 -3
View File
@@ -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(