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:
chengyongru
2026-07-12 00:35:17 +08:00
committed by Xubin Ren
parent edf78e7054
commit 7f8c3453e1
24 changed files with 1131 additions and 375 deletions
+143
View File
@@ -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)