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
+73 -7
View File
@@ -18,7 +18,7 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY
@@ -48,6 +48,22 @@ def _mk_loop() -> AgentLoop:
return loop
def _host_text_message(content: str, suffix: str) -> dict:
return {
"role": "user",
"content": content,
"_meta": {ContextBuilder._HOST_TEXT_SUFFIX_META_KEY: suffix},
}
def _host_text_block(text: str) -> dict:
return {
"type": "text",
"text": text,
"_meta": {ContextBuilder._HOST_BLOCK_META_KEY: True},
}
def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
@@ -348,7 +364,7 @@ def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop._save_turn(
session,
[{"role": "user", "content": [{"type": "text", "text": runtime}]}],
[{"role": "user", "content": [_host_text_block(runtime)]}],
skip=0,
)
assert session.messages == []
@@ -365,7 +381,7 @@ def test_save_turn_keeps_image_placeholder_with_path_after_runtime_strip() -> No
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}, "_meta": {"path": "/media/feishu/photo.jpg"}},
{"type": "text", "text": runtime},
_host_text_block(runtime),
],
}],
skip=0,
@@ -384,7 +400,7 @@ def test_save_turn_keeps_image_placeholder_without_meta() -> None:
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
{"type": "text", "text": runtime},
_host_text_block(runtime),
],
}],
skip=0,
@@ -392,23 +408,73 @@ def test_save_turn_keeps_image_placeholder_without_meta() -> None:
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
def test_save_turn_strips_runtime_context_suffix_from_string() -> None:
def test_save_turn_strips_host_guidance_suffix_from_string() -> None:
loop = _mk_loop()
session = Session(key="test:suffix-strip")
guidance = ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG + "\ninternal guidance"
runtime = (
ContextBuilder._RUNTIME_CONTEXT_TAG
+ "\nCurrent Time: now\n"
+ ContextBuilder._RUNTIME_CONTEXT_END
)
suffix = f"{guidance}\n\n{runtime}"
loop._save_turn(
session,
[{"role": "user", "content": f"hello world\n\n{runtime}"}],
[_host_text_message(f"hello world\n\n{suffix}", suffix)],
skip=0,
)
assert session.messages[0]["content"] == "hello world"
def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_path: Path) -> None:
loop = _mk_loop()
session = Session(key="test:user-guidance-literal")
user_text = (
"Keep this prefix\n"
f"{ContextBuilder._GOAL_RUNTIME_GUIDANCE_TAG}\n"
"This label and everything after it are user-authored."
)
messages = ContextBuilder(tmp_path).build_messages(
[],
user_text,
channel="cli",
chat_id="direct",
goal_start_requested=True,
)
assert "_meta" in messages[-1]
assert "_meta" not in LLMProvider._sanitize_empty_content(messages)[-1]
loop._save_turn(session, messages, skip=1)
assert session.messages[0]["content"] == user_text
def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_tag(
tmp_path: Path,
) -> None:
loop = _mk_loop()
session = Session(key="test:user-runtime-literal-block")
image = tmp_path / "user-tag.png"
image.write_bytes(_PNG_1X1)
user_text = (
f"{ContextBuilder._RUNTIME_CONTEXT_TAG}\n"
"This entire block is user-authored and must remain in history."
)
messages = ContextBuilder(tmp_path).build_messages(
[],
user_text,
media=[str(image)],
channel="cli",
chat_id="direct",
goal_start_requested=True,
)
loop._save_turn(session, messages, skip=1)
assert {"type": "text", "text": user_text} in session.messages[0]["content"]
def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None:
loop = _mk_loop()
session = Session(key="test:suffix-only")
@@ -420,7 +486,7 @@ def test_save_turn_skips_string_user_when_only_runtime_context_suffix() -> None:
loop._save_turn(
session,
[{"role": "user", "content": runtime}],
[_host_text_message(runtime, runtime)],
skip=0,
)
assert session.messages == []