mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
cli/btw: tell the model side questions are unsaved and tool-scoped
This commit is contained in:
@@ -603,19 +603,24 @@ class ChatAgent:
|
|||||||
tools: ToolRegistry | bool | None = False,
|
tools: ToolRegistry | bool | None = False,
|
||||||
max_rounds: int | None = None,
|
max_rounds: int | None = None,
|
||||||
system_prompt: str | None = None,
|
system_prompt: str | None = None,
|
||||||
|
notice: Notice | None = None,
|
||||||
instance_state: object | None = None,
|
instance_state: object | None = None,
|
||||||
session_state: object | None = None,
|
session_state: object | None = None,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Run a side turn that does not mutate this agent or its memory.
|
"""Run a side turn that does not mutate this agent or its memory.
|
||||||
|
|
||||||
- Message list is forked (optional history copy); never written back.
|
- Message list is forked (optional history copy); never written back.
|
||||||
- ``memory`` / ``session_id`` are unset on the side agent (no DB).
|
- ``memory`` / ``session_id`` are unset on the side agent (no DB), so a
|
||||||
|
*notice* stays local to this exchange.
|
||||||
- ``todo_stack`` is unset (no nags / main stack thrash).
|
- ``todo_stack`` is unset (no nags / main stack thrash).
|
||||||
- Tools default **off**. ``tools=True`` clones this agent's registry with
|
- Tools default **off**. ``tools=True`` clones this agent's registry with
|
||||||
a **fresh session** bag (unless *session_state* is passed) and the
|
a **fresh session** bag (unless *session_state* is passed) and the
|
||||||
given *instance_state* (CLI typically shares the host instance for
|
given *instance_state* (CLI typically shares the host instance for
|
||||||
workspace identity). A caller-built :class:`ToolRegistry` (e.g. a
|
workspace identity). A caller-built :class:`ToolRegistry` (e.g. a
|
||||||
``clone(read_only_only=True)`` subset) is used as-is.
|
``clone(read_only_only=True)`` subset) is used as-is.
|
||||||
|
- *notice* is appended after the forked history, i.e. right before the
|
||||||
|
side question, so the model knows this exchange's rules (unsaved,
|
||||||
|
read-only tools, …).
|
||||||
"""
|
"""
|
||||||
text = user_text.strip()
|
text = user_text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -640,6 +645,8 @@ class ChatAgent:
|
|||||||
session_state=aside_session,
|
session_state=aside_session,
|
||||||
)
|
)
|
||||||
history = list(self.messages) if include_history else []
|
history = list(self.messages) if include_history else []
|
||||||
|
if notice is not None:
|
||||||
|
history.append(notice.to_message())
|
||||||
rounds = self.max_rounds if max_rounds is None else max_rounds
|
rounds = self.max_rounds if max_rounds is None else max_rounds
|
||||||
if aside_tools is None and max_rounds is None:
|
if aside_tools is None and max_rounds is None:
|
||||||
rounds = 1
|
rounds = 1
|
||||||
|
|||||||
@@ -980,10 +980,13 @@ def btw_cmd(
|
|||||||
return
|
return
|
||||||
tools_mode = "no"
|
tools_mode = "no"
|
||||||
|
|
||||||
from plyngent.cli.display import render_events
|
from plyngent.agent import aside_notice
|
||||||
|
from plyngent.cli.display import echo_notice, render_events
|
||||||
from plyngent.cli.retry import run_cancellable
|
from plyngent.cli.retry import run_cancellable
|
||||||
from plyngent.tools.context import SessionState, read_only_context
|
from plyngent.tools.context import SessionState, read_only_context
|
||||||
|
|
||||||
|
aside = aside_notice(tools_mode=tools_mode)
|
||||||
|
echo_notice(aside)
|
||||||
click.secho("btw: ", fg="magenta", nl=False)
|
click.secho("btw: ", fg="magenta", nl=False)
|
||||||
click.echo(text)
|
click.echo(text)
|
||||||
if tools_mode == "no":
|
if tools_mode == "no":
|
||||||
@@ -1008,6 +1011,7 @@ def btw_cmd(
|
|||||||
text,
|
text,
|
||||||
include_history=not fresh,
|
include_history=not fresh,
|
||||||
tools=tools_arg,
|
tools=tools_arg,
|
||||||
|
notice=aside,
|
||||||
instance_state=aside_instance,
|
instance_state=aside_instance,
|
||||||
session_state=aside_session,
|
session_state=aside_session,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, tool
|
from plyngent.agent import ChatAgent, ToolRegistry, ToolTag, aside_notice, tool
|
||||||
from plyngent.agent.todo_stack import TodoStack
|
from plyngent.agent.todo_stack import TodoStack
|
||||||
from plyngent.config.models import DatabaseConfig
|
from plyngent.config.models import DatabaseConfig
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
@@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
|
DeveloperChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
@@ -120,6 +121,47 @@ async def test_run_aside_fresh_skips_history() -> None:
|
|||||||
assert users == ["q"]
|
assert users == ["q"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_aside_notice_reaches_model_but_not_history() -> None:
|
||||||
|
"""The aside notice is the model's last instruction; the host keeps none."""
|
||||||
|
client = ScriptedClient(["ok"])
|
||||||
|
agent = ChatAgent(cast("Any", client), model="m", stream=False)
|
||||||
|
agent.messages.append(UserChatMessage(content="prior main"))
|
||||||
|
before = list(agent.messages)
|
||||||
|
notice = aside_notice(tools_mode="read")
|
||||||
|
|
||||||
|
async for _ in agent.run_aside("side?", include_history=True, tools=False, notice=notice):
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = client.payloads[0]
|
||||||
|
assert isinstance(payload[-2], DeveloperChatMessage)
|
||||||
|
assert payload[-2].content == notice.to_message().content
|
||||||
|
assert "read-only" in payload[-2].content
|
||||||
|
assert isinstance(payload[-1], UserChatMessage)
|
||||||
|
assert payload[-1].content == "side?"
|
||||||
|
assert agent.messages == before
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_aside_fresh_history_still_carries_notice() -> None:
|
||||||
|
client = ScriptedClient(["ok"])
|
||||||
|
agent = ChatAgent(cast("Any", client), model="m", stream=False)
|
||||||
|
|
||||||
|
async for _ in agent.run_aside(
|
||||||
|
"q",
|
||||||
|
include_history=False,
|
||||||
|
tools=False,
|
||||||
|
notice=aside_notice(tools_mode="no"),
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
payload = client.payloads[0]
|
||||||
|
assert isinstance(payload[0], DeveloperChatMessage)
|
||||||
|
assert "Tools are disabled" in payload[0].content
|
||||||
|
assert isinstance(payload[-1], UserChatMessage)
|
||||||
|
assert payload[-1].content == "q"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
|
async def test_run_aside_tools_clones_registry_with_fresh_session() -> None:
|
||||||
hits: list[str] = []
|
hits: list[str] = []
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Literal, overload
|
|||||||
import pytest
|
import pytest
|
||||||
import tomlkit
|
import tomlkit
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent
|
from plyngent.agent import ChatAgent, Notice
|
||||||
from plyngent.cli.slash import handle_slash
|
from plyngent.cli.slash import handle_slash
|
||||||
from plyngent.cli.state import ReplState
|
from plyngent.cli.state import ReplState
|
||||||
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||||
@@ -764,3 +764,63 @@ async def test_btw_full_mode_clones_full_registry(
|
|||||||
assert captured["tools"] is True
|
assert captured["tools"] is True
|
||||||
assert captured["session_state"] is not state.session_state
|
assert captured["session_state"] is not state.session_state
|
||||||
assert captured["read_only"] is False
|
assert captured["read_only"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_sends_notice_and_shows_it(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""The aside request carries a notice; it never enters the main transcript."""
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
||||||
|
captured.update(kwargs)
|
||||||
|
if False:
|
||||||
|
yield None
|
||||||
|
|
||||||
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
||||||
|
before = list(state.agent.messages)
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/btw --tools no hello") is True
|
||||||
|
|
||||||
|
notice = captured["notice"]
|
||||||
|
assert isinstance(notice, Notice)
|
||||||
|
assert notice.kind == "aside"
|
||||||
|
assert "Tools are disabled" in notice.body
|
||||||
|
# Side turns are not persisted, so nothing is pushed into the main history.
|
||||||
|
assert state.agent.messages == before
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "[notice] aside:" in out
|
||||||
|
assert "btw: hello" in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_btw_read_notice_states_read_only_scope(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
from plyngent.agent import ToolRegistry, ToolTag, tool
|
||||||
|
|
||||||
|
@tool(tags=ToolTag.LOCAL | ToolTag.READ_ONLY, register=False)
|
||||||
|
async def ro_tool() -> str:
|
||||||
|
return "ro"
|
||||||
|
|
||||||
|
state.tools_enabled = True
|
||||||
|
state.agent.tools = ToolRegistry([ro_tool], auto_bind_state=True)
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
async def fake_run_aside(_text: str, **kwargs: object) -> AsyncIterator[object]:
|
||||||
|
captured.update(kwargs)
|
||||||
|
if False:
|
||||||
|
yield None
|
||||||
|
|
||||||
|
monkeypatch.setattr(state.agent, "run_aside", fake_run_aside)
|
||||||
|
|
||||||
|
assert await handle_slash(state, "/btw hi") is True
|
||||||
|
|
||||||
|
notice = captured["notice"]
|
||||||
|
assert isinstance(notice, Notice)
|
||||||
|
assert "read-only" in notice.body
|
||||||
|
assert "not saved" in notice.body
|
||||||
|
assert "[notice] aside: read tools" in capsys.readouterr().out
|
||||||
|
|||||||
Reference in New Issue
Block a user