refactor(agent): gate sustained goals behind explicit /goal
Replace the legacy long-goal skill contract with command-scoped goal tools and runtime guidance. Keep goal state durable across continuations while restricting create and replace mutations to explicit user /goal turns.
This commit is contained in:
@@ -7,9 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse, ToolCallRequest
|
||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
@@ -31,6 +34,146 @@ def _make_loop(tmp_path):
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
return loop
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ephemeral_runner_enters_and_restores_turn_scopes(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
async def chat_with_retry(**_kwargs):
|
||||
assert goal_mutation_allowed() is True
|
||||
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
await loop._run_agent_loop(
|
||||
[],
|
||||
runtime=loop.llm_runtime(),
|
||||
ephemeral=True,
|
||||
turn_scopes=[goal_mutation_permission(True)],
|
||||
)
|
||||
|
||||
assert goal_mutation_allowed() is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_goal_command_can_implement_plan_from_prior_discussion(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="recording the agreed plan",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_create",
|
||||
name="create_goal",
|
||||
arguments={
|
||||
"objective": "Implement the agreed migration plan and run its tests.",
|
||||
},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(
|
||||
content="closing goal",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_update",
|
||||
name="update_goal",
|
||||
arguments={"action": "complete", "recap": "Implemented and tested."},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(
|
||||
content="trying to start another goal",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_create_again",
|
||||
name="create_goal",
|
||||
arguments={"objective": "Start an unrelated follow-up."},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(content="done", tool_calls=[], usage={}),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("cli:direct")
|
||||
session.add_message("user", "Let's agree on the migration implementation.")
|
||||
session.add_message("assistant", "Use the staged migration plan and run integration tests.")
|
||||
|
||||
result = await loop._process_message(
|
||||
InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="user",
|
||||
chat_id="direct",
|
||||
content="/goal implement the plan above",
|
||||
)
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.content == "done"
|
||||
assert goal_mutation_allowed() is False
|
||||
assert session.metadata[GOAL_STATE_KEY]["status"] == "completed"
|
||||
first_request = provider.chat_with_retry.await_args_list[0].kwargs["messages"]
|
||||
assert "staged migration plan" in str(first_request)
|
||||
assert "/goal implement the plan above" in str(first_request)
|
||||
assert ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG in str(first_request)
|
||||
final_request = provider.chat_with_retry.await_args_list[-1].kwargs["messages"]
|
||||
assert "create_goal is unavailable for this turn" in str(final_request)
|
||||
assert all(
|
||||
ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG not in str(message.get("content") or "")
|
||||
for message in session.messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_goal_direct_turn_cannot_reuse_prior_goal_command(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||
LLMResponse(
|
||||
content="trying to create a goal",
|
||||
tool_calls=[
|
||||
ToolCallRequest(
|
||||
id="call_create",
|
||||
name="create_goal",
|
||||
arguments={"objective": "Unauthorized persistent objective."},
|
||||
)
|
||||
],
|
||||
usage={},
|
||||
),
|
||||
LLMResponse(content="handled as a one-time task", tool_calls=[], usage={}),
|
||||
])
|
||||
loop = AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None)
|
||||
session = loop.sessions.get_or_create("api:default")
|
||||
session.add_message("user", "/goal old completed request")
|
||||
session.add_message("assistant", "The old request is complete.")
|
||||
|
||||
result = await loop.process_direct(
|
||||
"Handle this as an ordinary one-time task.",
|
||||
session_key=session.key,
|
||||
channel="api",
|
||||
chat_id="default",
|
||||
persist_user_message=False,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.content == "handled as a one-time task"
|
||||
assert GOAL_STATE_KEY not in session.metadata
|
||||
second_request = provider.chat_with_retry.await_args_list[1].kwargs["messages"]
|
||||
assert "create_goal is unavailable for this turn" in str(second_request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_max_iterations_message_stays_stable(tmp_path):
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user