diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index 7d1ef1d..c5a72a2 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -603,19 +603,24 @@ class ChatAgent: tools: ToolRegistry | bool | None = False, max_rounds: int | None = None, system_prompt: str | None = None, + notice: Notice | 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). + - ``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). - 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). A caller-built :class:`ToolRegistry` (e.g. a ``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() if not text: @@ -640,6 +645,8 @@ class ChatAgent: session_state=aside_session, ) 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 if aside_tools is None and max_rounds is None: rounds = 1 diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index fde2eb5..9240b73 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -980,10 +980,13 @@ def btw_cmd( return 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.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.echo(text) if tools_mode == "no": @@ -1008,6 +1011,7 @@ def btw_cmd( text, include_history=not fresh, tools=tools_arg, + notice=aside, instance_state=aside_instance, session_state=aside_session, ) diff --git a/tests/test_agent/test_run_aside.py b/tests/test_agent/test_run_aside.py index a442613..0245c9f 100644 --- a/tests/test_agent/test_run_aside.py +++ b/tests/test_agent/test_run_aside.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast, overload 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.config.models import DatabaseConfig from plyngent.lmproto.openai_compatible.model import ( @@ -16,6 +16,7 @@ from plyngent.lmproto.openai_compatible.model import ( ChatCompletionChunk, ChatCompletionResponse, ChatCompletionsParam, + DeveloperChatMessage, UserChatMessage, ) from plyngent.memory import MemoryStore @@ -120,6 +121,47 @@ async def test_run_aside_fresh_skips_history() -> None: 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 async def test_run_aside_tools_clones_registry_with_fresh_session() -> None: hits: list[str] = [] diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 73e9968..37ce306 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Literal, overload import pytest import tomlkit -from plyngent.agent import ChatAgent +from plyngent.agent import ChatAgent, Notice from plyngent.cli.slash import handle_slash from plyngent.cli.state import ReplState 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["session_state"] is not state.session_state 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