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
+59 -7
View File
@@ -12,7 +12,7 @@ from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.session.goal_state import goal_state_runtime_lines, sustained_goal_active
from nanobot.utils.helpers import (
current_time_str,
detect_image_mime,
@@ -56,7 +56,11 @@ class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_GOAL_RUNTIME_GUIDANCE_TAG = "[Goal Runtime Guidance — host instructions]"
_GOAL_RUNTIME_GUIDANCE_END = "[/Goal Runtime Guidance]"
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_HOST_TEXT_SUFFIX_META_KEY = "host_text_suffix"
_HOST_BLOCK_META_KEY = "nanobot_host_content"
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
@@ -153,6 +157,23 @@ class ContextBuilder:
lines.extend(supplemental_lines)
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
@staticmethod
def _build_goal_runtime_guidance(
session_metadata: Mapping[str, Any] | None,
*,
goal_start_requested: bool,
) -> str:
"""Return turn-scoped goal guidance without changing the system prompt."""
goal_active = sustained_goal_active(session_metadata)
if not goal_start_requested and not goal_active:
return ""
return render_template(
"agent/goal_runtime.md",
strip=True,
goal_start_requested=goal_start_requested,
goal_active=goal_active,
)
@staticmethod
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
if isinstance(left, str) and isinstance(right, str):
@@ -208,6 +229,7 @@ class ContextBuilder:
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
goal_start_requested: bool = False,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
@@ -226,15 +248,38 @@ class ContextBuilder:
supplemental_lines=extra or None,
)
user_content = self._build_user_content(current_message, media)
goal_guidance = (
self._build_goal_runtime_guidance(
session_metadata,
goal_start_requested=goal_start_requested,
)
if current_role == "user"
else ""
)
# Merge runtime context and user content into a single user message
# Merge runtime guidance, context, and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
# Runtime context is appended to keep the user-content prefix stable
# for prompt-cache hits (the context changes every turn due to time).
# Volatile content is appended to keep the user-content prefix stable for
# prompt-cache hits. Goal guidance precedes the metadata-only runtime block.
host_parts = [part for part in (goal_guidance, runtime_ctx) if part]
host_text_suffix = "\n\n".join(host_parts)
if isinstance(user_content, str):
merged = f"{user_content}\n\n{runtime_ctx}"
merged = "\n\n".join(
part for part in (user_content, host_text_suffix) if part
)
else:
merged = user_content + [{"type": "text", "text": runtime_ctx}]
merged = list(user_content)
if goal_guidance:
merged.append({
"type": "text",
"text": goal_guidance,
"_meta": {self._HOST_BLOCK_META_KEY: True},
})
merged.append({
"type": "text",
"text": runtime_ctx,
"_meta": {self._HOST_BLOCK_META_KEY: True},
})
messages = [
{
"role": "system",
@@ -253,9 +298,16 @@ class ContextBuilder:
if messages[-1].get("role") == current_role:
last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged)
if current_role == "user" and isinstance(user_content, str):
internal_meta = dict(last.get("_meta") or {})
internal_meta[self._HOST_TEXT_SUFFIX_META_KEY] = host_text_suffix
last["_meta"] = internal_meta
messages[-1] = last
return messages
messages.append({"role": current_role, "content": merged})
current = {"role": current_role, "content": merged}
if current_role == "user" and isinstance(user_content, str):
current["_meta"] = {self._HOST_TEXT_SUFFIX_META_KEY: host_text_suffix}
messages.append(current)
return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
+29
View File
@@ -0,0 +1,29 @@
"""Turn-local permission for explicit sustained-goal mutations."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
_GOAL_MUTATION_ALLOWED: ContextVar[bool] = ContextVar(
"nanobot_goal_mutation_allowed",
default=False,
)
def goal_mutation_allowed() -> bool:
return _GOAL_MUTATION_ALLOWED.get()
def revoke_goal_mutation_permission() -> None:
_GOAL_MUTATION_ALLOWED.set(False)
@contextmanager
def goal_mutation_permission(allowed: bool):
"""Bind goal permission for one agent-run or direct tool execution scope."""
token = _GOAL_MUTATION_ALLOWED.set(allowed)
try:
yield
finally:
_GOAL_MUTATION_ALLOWED.reset(token)
+40 -9
View File
@@ -7,7 +7,7 @@ import dataclasses
import os
import time
from collections.abc import Mapping
from contextlib import nullcontext, suppress
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
from dataclasses import dataclass, field
from enum import Enum, auto
from functools import partial
@@ -60,6 +60,7 @@ from nanobot.security.workspace_access import (
from nanobot.session import turn_continuation
from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
explicit_goal_requested,
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
sustained_goal_active,
@@ -147,6 +148,7 @@ class TurnContext:
run_extra_hooks_for_ephemeral: bool = False
hooks: list[AgentHook] = field(default_factory=list)
hook_factories: list[AgentTurnHookFactory] = field(default_factory=list)
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
tools: ToolRegistry | None = None
turn_wall_started_at: float = field(default_factory=time.time)
@@ -637,6 +639,7 @@ class AgentLoop:
history: list[dict[str, Any]],
pending_summary: str | None,
include_memory_recent_history: bool = True,
goal_start_requested: bool = False,
) -> list[dict[str, Any]]:
"""Build the initial message list for the LLM turn."""
scope = self.workspace_scopes.for_message(msg, session.metadata)
@@ -652,6 +655,7 @@ class AgentLoop:
workspace=scope.project_path,
runtime_state=self,
inbound_message=msg,
goal_start_requested=goal_start_requested,
include_memory_recent_history=include_memory_recent_history,
session_key=session.key,
unified_session=self._unified_session,
@@ -725,6 +729,7 @@ class AgentLoop:
run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
hook_factories: list[AgentTurnHookFactory] | None = None,
turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]:
"""Run the agent iteration loop.
@@ -826,7 +831,8 @@ class AgentLoop:
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
request_token = bind_request_context(request_ctx)
workspace_token = bind_workspace_scope(effective_scope)
# Compute lazily because long_task may create goal metadata during this run.
turn_scope_stack = ExitStack()
# Compute lazily because create_goal may create goal metadata during this run.
def _goal_continue() -> str | None:
_goal_lines = goal_state_runtime_lines(session.metadata if session is not None else None)
if not _goal_lines:
@@ -835,11 +841,13 @@ class AgentLoop:
"You have an active sustained goal:\n\n"
+ "\n".join(_goal_lines)
+ "\n\nPlease continue working toward the objective using your tools, "
"or call complete_goal if the work is truly finished."
"or call update_goal with action='complete' if the work is truly finished."
)
session_metadata = session.metadata if session is not None else None
try:
for scope in turn_scopes or ():
turn_scope_stack.enter_context(scope)
hook = build_agent_turn_hook(AgentTurnHookSpec(
on_progress=on_progress,
on_stream=on_stream,
@@ -894,6 +902,7 @@ class AgentLoop:
),
))
finally:
turn_scope_stack.close()
reset_workspace_scope(workspace_token)
reset_request_context(request_token)
reset_file_states(file_state_token)
@@ -1490,6 +1499,13 @@ class AgentLoop:
async def _state_command(self, ctx: TurnContext) -> str:
raw = ctx.msg.content.strip()
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
is_user_turn = (
ctx.original_user_text is not None
and not automation_metadata
and ctx.msg.channel != "system"
and ctx.msg.sender_id != "subagent"
)
cmd_ctx = CommandContext(
msg=ctx.msg,
session=ctx.session,
@@ -1497,6 +1513,8 @@ class AgentLoop:
raw=raw,
loop=self,
runtime=ctx.runtime,
is_user_turn=is_user_turn,
turn_scopes=ctx.turn_scopes,
)
result = await self.commands.dispatch(cmd_ctx)
if result is not None:
@@ -1549,6 +1567,7 @@ class AgentLoop:
ctx.history,
ctx.pending_summary,
include_memory_recent_history=not ctx.ephemeral,
goal_start_requested=explicit_goal_requested(ctx.msg.metadata),
)
ctx.user_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session
@@ -1589,6 +1608,7 @@ class AgentLoop:
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
hooks=ctx.hooks,
hook_factories=ctx.hook_factories,
turn_scopes=ctx.turn_scopes,
tools=ctx.tools,
)
final_content, tools_used, all_msgs, stop_reason, had_injections = result
@@ -1676,8 +1696,8 @@ class AgentLoop:
if (
drop_runtime
and block.get("type") == "text"
and isinstance(block.get("text"), str)
and block["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG)
and isinstance(block.get("_meta"), dict)
and block["_meta"].get(ContextBuilder._HOST_BLOCK_META_KEY) is True
):
continue
@@ -1720,6 +1740,12 @@ class AgentLoop:
last_assistant_idx: int | None = None
for m in messages[skip:]:
entry = dict(m)
internal_meta = entry.pop("_meta", None)
host_text_suffix = (
internal_meta.get(ContextBuilder._HOST_TEXT_SUFFIX_META_KEY)
if isinstance(internal_meta, dict)
else None
)
role, content = entry.get("role"), entry.get("content")
if role == "assistant" and not content and not entry.get("tool_calls"):
continue # skip empty assistant messages — they poison session context
@@ -1744,10 +1770,15 @@ class AgentLoop:
]
entry["content"] = filtered
elif role == "user":
if isinstance(content, str) and ContextBuilder._RUNTIME_CONTEXT_TAG in content:
# Strip the runtime-context block appended at the end.
tag_pos = content.find(ContextBuilder._RUNTIME_CONTEXT_TAG)
before = content[:tag_pos].rstrip("\n ")
if (
isinstance(content, str)
and isinstance(host_text_suffix, str)
and host_text_suffix
and content.endswith(host_text_suffix)
):
before = content[: -len(host_text_suffix)]
if before.endswith("\n\n"):
before = before[:-2]
if before:
entry["content"] = before
else:
+225 -125
View File
@@ -1,45 +1,49 @@
"""Sustained goal tools on the main agent (Codex-style).
Follow the built-in **long-goal** skill for lifecycle rules and how to phrase
objectives (especially **idempotent**, compaction-safe goals). Load that skill
from the skills listing (path shown there) before composing ``long_task.goal`` text.
``long_task`` registers an objective on the session (JSON-serializable metadata).
Active objectives are mirrored each turn into the Runtime Context block (see
``nanobot.session.goal_state.goal_state_runtime_lines``) so compaction cannot hide them.
Work proceeds in ordinary agent turns (same runner, compaction as configured).
Call ``complete_goal`` when the sustained objective should stop being tracked:
finished successfully, or cancelled / superseded / redirected—in every case the recap should match reality.
There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui`` stream.
"""
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime
from typing import TYPE_CHECKING, Any
from nanobot.agent.goal_permission import (
goal_mutation_allowed,
revoke_goal_mutation_permission,
)
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
MAX_GOAL_OBJECTIVE_CHARS,
discard_legacy_goal_state_key,
goal_state_raw,
parse_goal_state,
)
from nanobot.session.turn_continuation import reset_goal_continuation_rounds
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
_GOAL_ACTIONS = ("complete", "cancel", "block", "replace")
_CREATE_UNAVAILABLE_ERROR = (
"Error: create_goal is unavailable for this turn. Ask the user to submit the complete "
"objective as `/goal <task>`."
)
_REPLACE_UNAVAILABLE_ERROR = (
"Error: replacing the goal is unavailable for this turn. Ask the user to submit the "
"replacement objective as `/goal <task>`."
)
def _iso_now() -> str:
return datetime.now().isoformat()
class _GoalToolsMixin:
"""Shared routing context + Session lookup."""
"""Shared routing context and session lookup."""
def __init__(
self,
@@ -58,8 +62,29 @@ class _GoalToolsMixin:
return None
return self._sessions.get_or_create(key)
def _goal_mutation_allowed(self) -> bool:
return current_request_context() is not None and goal_mutation_allowed()
def _save_goal_state(
self,
sess: Any,
blob: dict[str, Any],
*,
reset_continuation: bool = False,
) -> None:
previous_metadata = deepcopy(sess.metadata)
sess.metadata[GOAL_STATE_KEY] = blob
discard_legacy_goal_state_key(sess.metadata)
if reset_continuation:
reset_goal_continuation_rounds(sess.metadata)
try:
self._sessions.save(sess)
except BaseException:
sess.metadata.clear()
sess.metadata.update(previous_metadata)
raise
async def _publish_goal_state_changed(self, metadata: dict[str, Any]) -> None:
"""Publish authoritative goal metadata as a runtime event."""
runtime_events = self._runtime_events
rc = current_request_context()
if runtime_events is None or rc is None:
@@ -82,105 +107,23 @@ class _GoalToolsMixin:
@tool_parameters(
tool_parameters_schema(
goal=StringSchema(
"Sustained objective for this chat thread. First read the built-in **long-goal** skill, "
"especially its Start fast section, then call this promptly once the user's intent is clear. "
"The goal must still be idempotent, self-contained, bounded, and explicit about done-ness; "
"do not delay this tool call to over-plan, research, or decide execution details.",
max_length=12_000,
objective=StringSchema(
"The sustained objective for this session. It may consolidate a plan from earlier "
"discussion, but must be self-contained, bounded, safe under repetition, and "
"explicit about done-ness.",
min_length=1,
max_length=MAX_GOAL_OBJECTIVE_CHARS,
),
ui_summary=StringSchema(
"Optional one-line label for session lists / logs (≤120 chars).",
"Optional one-line display label for session lists and logs. It is not load-bearing.",
max_length=120,
nullable=True,
),
required=["goal"],
required=["objective"],
)
)
class LongTaskTool(Tool, _GoalToolsMixin):
"""Begin or replace focus on a long-running objective stored on the session."""
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None # guarded by enabled()
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "long_task"
@property
def description(self) -> str:
return (
"Mark this thread as a sustained long-running task. "
"First read the built-in **long-goal** skill, especially its Start fast section; then call this "
"as soon as the user's intent is clear. Write a good idempotent goal, but do not delay the tool "
"call with long planning, research, or execution-detail thinking. "
"The active goal is mirrored in Runtime Context each turn. Use normal tools until done, then call "
"complete_goal when the objective is satisfied, cancelled, or replaced. "
"If a goal is already active, finish it or call complete_goal before registering another."
)
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return ToolResult.error(
"Error: long_task requires an active chat session (missing routing context)."
)
prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active":
return ToolResult.error(
"Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it."
)
summary = (ui_summary or "").strip()[:120]
blob = {
"status": "active",
"objective": goal.strip(),
"ui_summary": summary,
"started_at": _iso_now(),
}
sess.metadata[GOAL_STATE_KEY] = blob
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_changed(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return (
"Goal recorded. Keep working toward the objective using ordinary tools. "
"When fully done (verified against what was asked), call complete_goal with a "
f"short recap.{extra}"
)
@tool_parameters(
tool_parameters_schema(
recap=StringSchema(
"Brief recap for the user (plain text). When the goal succeeded, confirm outcomes; "
"if the user cancelled, pivoted, or replaced the objective, say so honestly.",
max_length=8000,
nullable=True,
),
required=[],
)
)
class CompleteGoalTool(Tool, _GoalToolsMixin):
"""Mark the active sustained goal finished after all required work is verified."""
class CreateGoalTool(Tool, _GoalToolsMixin):
"""Create one explicit sustained objective for the current session."""
def __init__(
self,
@@ -204,37 +147,194 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
@property
def name(self) -> str:
return "complete_goal"
return "create_goal"
@property
def description(self) -> str:
return (
"End bookkeeping for the active sustained goal. "
"Use when the objective is fully achieved and verified—recap what was delivered. "
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
"what actually happened (not necessarily success). "
"If no goal is active, the tool reports that and leaves metadata unchanged."
"Create one sustained goal for the current session when Goal Runtime Guidance asks "
"you to record it. Consolidate relevant prior discussion into a durable objective "
"that is self-contained, bounded, safe under repetition, and explicit about "
"completion criteria. Do not retry after a successful creation."
)
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
async def execute(
self,
objective: str,
ui_summary: str | None = None,
**kwargs: Any,
) -> str:
sess = self._session()
if sess is None:
return ToolResult.error("Error: complete_goal requires an active chat session.")
return ToolResult.error(
"Error: create_goal requires an active chat session (missing routing context)."
)
if not self._goal_mutation_allowed():
return ToolResult.error(_CREATE_UNAVAILABLE_ERROR)
prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active":
return ToolResult.error(
"Error: a sustained goal is already active. Use update_goal with "
"action='replace' only if the user explicitly changes the objective."
)
objective_text = objective.strip()
if not objective_text:
return ToolResult.error("Error: objective must not be empty.")
if len(objective_text) > MAX_GOAL_OBJECTIVE_CHARS:
return ToolResult.error(
f"Error: objective must not exceed {MAX_GOAL_OBJECTIVE_CHARS} characters."
)
summary = (ui_summary or "").strip()[:120]
blob = {
"status": "active",
"objective": objective_text,
"ui_summary": summary,
"started_at": _iso_now(),
}
self._save_goal_state(sess, blob, reset_continuation=True)
await self._publish_goal_state_changed(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return (
"Goal recorded. Keep working toward the objective using ordinary tools. "
"When fully done and verified, call update_goal with action='complete'."
f"{extra}"
)
@tool_parameters(
tool_parameters_schema(
action=StringSchema(
"How to update the active goal.",
enum=_GOAL_ACTIONS,
),
recap=StringSchema(
"Brief honest recap for the user. Required in practice for complete, cancel, and block.",
max_length=8000,
nullable=True,
),
objective=StringSchema(
"Replacement objective. Required only when action is 'replace'; make it durable, "
"self-contained, bounded, and explicit about done-ness.",
max_length=MAX_GOAL_OBJECTIVE_CHARS,
nullable=True,
),
ui_summary=StringSchema(
"Optional one-line display label for a replacement goal.",
max_length=120,
nullable=True,
),
required=["action"],
)
)
class UpdateGoalTool(Tool, _GoalToolsMixin):
"""Complete, cancel, block, or replace the active sustained goal."""
def __init__(
self,
sessions: Any,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "update_goal"
@property
def description(self) -> str:
return (
"Update the active sustained goal. Use action='complete' only after the objective "
"is actually achieved and verified. Use action='cancel' when the user cancels, "
"action='block' when progress is genuinely blocked, and action='replace' only when "
"the requested objective changes."
)
async def execute(
self,
action: str,
recap: str | None = None,
objective: str | None = None,
ui_summary: str | None = None,
**kwargs: Any,
) -> str:
sess = self._session()
if sess is None:
return ToolResult.error("Error: update_goal requires an active chat session.")
prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete."
return "No active goal to update."
normalized = (action or "").strip().lower()
if normalized not in _GOAL_ACTIONS:
return ToolResult.error(
"Error: action must be one of complete, cancel, block, or replace."
)
if normalized == "replace":
if not self._goal_mutation_allowed():
return ToolResult.error(_REPLACE_UNAVAILABLE_ERROR)
objective_text = (objective or "").strip()
if not objective_text:
return ToolResult.error(
"Error: update_goal action='replace' requires a replacement objective."
)
if len(objective_text) > MAX_GOAL_OBJECTIVE_CHARS:
return ToolResult.error(
f"Error: objective must not exceed {MAX_GOAL_OBJECTIVE_CHARS} characters."
)
summary = (ui_summary or "").strip()[:120]
blob = {
"status": "active",
"objective": objective_text,
"ui_summary": summary,
"started_at": _iso_now(),
"replaced_at": _iso_now(),
"previous_objective": str(prior.get("objective") or ""),
"recap": (recap or "").strip(),
}
self._save_goal_state(sess, blob, reset_continuation=True)
await self._publish_goal_state_changed(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return "Goal replaced. Continue toward the new objective using ordinary tools." + extra
ended = _iso_now()
sess.metadata[GOAL_STATE_KEY] = {
status = {
"complete": "completed",
"cancel": "cancelled",
"block": "blocked",
}[normalized]
blob = {
**prior,
"status": "completed",
"completed_at": ended,
"status": status,
"ended_at": ended,
"recap": (recap or "").strip(),
}
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
if normalized == "complete":
blob["completed_at"] = ended
self._save_goal_state(sess, blob)
revoke_goal_mutation_permission()
await self._publish_goal_state_changed(sess.metadata)
tail = (recap or "").strip()
label = {
"complete": "complete",
"cancel": "cancelled",
"block": "blocked",
}[normalized]
if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})."
return f"Goal marked {label} ({ended}). Recap:\n{tail}"
return f"Goal marked {label} ({ended})."
+12 -11
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass
from typing import Literal
from nanobot import __version__
from nanobot.agent.goal_permission import goal_mutation_permission
from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.utils.helpers import build_status_content
@@ -766,17 +767,8 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
)
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Goal:
{goal}
"""
async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
"""Rewrite /goal into a normal agent turn that nudges long_task use."""
"""Mark this turn as an explicit sustained-goal request."""
goal = ctx.args.strip()
if not goal:
return OutboundMessage(
@@ -795,14 +787,23 @@ async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
if not ctx.is_user_turn:
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Goal mode can only be started by a user `/goal <task>` command.",
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
ctx.turn_scopes.append(goal_mutation_permission(True))
ctx.msg.metadata = {
**dict(ctx.msg.metadata or {}),
"original_command": "/goal",
"original_content": ctx.raw,
"goal_requested": True,
"goal_started_at": time.time(),
}
ctx.msg.content = _GOAL_PROMPT_TEMPLATE.format(goal=goal)
ctx.msg.content = ctx.raw
return None
+4 -1
View File
@@ -3,7 +3,8 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from contextlib import AbstractContextManager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
@@ -45,6 +46,8 @@ class CommandContext:
args: str = ""
loop: Any = None
runtime: LLMRuntime | None = None
is_user_turn: bool = False
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
class CommandRouter:
+2 -1
View File
@@ -274,7 +274,8 @@ class LLMProvider(ABC):
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sanitize message content: fix empty blocks, strip internal _meta fields."""
result: list[dict[str, Any]] = []
for msg in messages:
for raw_msg in messages:
msg = {key: value for key, value in raw_msg.items() if key != "_meta"}
content = msg.get("content")
if isinstance(content, str) and not content:
+16 -10
View File
@@ -1,4 +1,4 @@
"""Session metadata helpers for sustained goals (e.g. ``long_task`` / ``complete_goal``).
"""Session metadata helpers for explicit sustained goals.
Tools set ``metadata[GOAL_STATE_KEY]``. Reads accept the legacy session key ``thread_goal``
for older sessions. Callers use ``goal_state_runtime_lines``, ``goal_state_ws_blob``, and
@@ -13,9 +13,10 @@ from typing import Any, Mapping, MutableMapping
from nanobot.session.manager import SessionManager
GOAL_STATE_KEY = "goal_state"
GOAL_COMMAND = "/goal"
MAX_GOAL_OBJECTIVE_CHARS = 4000
# Older builds stored the same JSON blob under this key.
_LEGACY_GOAL_STATE_SESSION_KEY = "thread_goal"
_MAX_OBJECTIVE_IN_RUNTIME = 4000
_MAX_OBJECTIVE_WS = 600
@@ -38,22 +39,27 @@ def goal_state_raw(metadata: Mapping[str, Any] | None) -> Any:
def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool:
"""True when this session has an active sustained objective (``long_task`` bookkeeping)."""
"""True when this session has an active sustained objective."""
goal = parse_goal_state(goal_state_raw(metadata))
return isinstance(goal, dict) and goal.get("status") == "active"
def explicit_goal_requested(message_metadata: Mapping[str, Any] | None) -> bool:
"""True when this turn was explicitly started by the ``/goal`` command."""
if not message_metadata:
return False
if message_metadata.get("goal_requested") is True:
return True
return str(message_metadata.get("original_command") or "").strip() == GOAL_COMMAND
def sustained_goal_turn(
metadata: Mapping[str, Any] | None,
*,
message_metadata: Mapping[str, Any] | None = None,
) -> bool:
"""True when this turn should use sustained-goal runtime limits."""
if sustained_goal_active(metadata):
return True
if not message_metadata:
return False
return str(message_metadata.get("original_command") or "").strip() == "/goal"
return sustained_goal_active(metadata) or explicit_goal_requested(message_metadata)
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
@@ -80,8 +86,8 @@ def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]:
objective = str(goal.get("objective") or "").strip()
if not objective:
return ["Goal: active (no objective text stored)."]
if len(objective) > _MAX_OBJECTIVE_IN_RUNTIME:
objective = objective[:_MAX_OBJECTIVE_IN_RUNTIME].rstrip() + "\n… (truncated)"
if len(objective) > MAX_GOAL_OBJECTIVE_CHARS:
objective = objective[:MAX_GOAL_OBJECTIVE_CHARS].rstrip() + "\n… (truncated)"
out = ["Goal (active):", objective]
hint = str(goal.get("ui_summary") or "").strip()
if hint:
+11 -4
View File
@@ -30,6 +30,8 @@ _GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
_MAX_GOAL_CONTINUATION_ROUNDS = 12
_STRIPPED_INBOUND_META_KEYS = {
INTERNAL_CONTINUATION_PENDING_META,
"goal_requested",
"original_command",
}
@@ -168,7 +170,12 @@ def _continuation_available(
def clear_internal_continuation_state(metadata: MutableMapping[str, Any]) -> None:
"""Reset policy bookkeeping once its owning runtime mode is inactive."""
if not sustained_goal_active(metadata):
metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
reset_goal_continuation_rounds(metadata)
def reset_goal_continuation_rounds(metadata: MutableMapping[str, Any]) -> None:
"""Start a newly created or replaced goal with a fresh continuation budget."""
metadata.pop(_GOAL_CONTINUATION_ROUNDS_KEY, None)
def _save_skip_for_turn(
@@ -240,14 +247,14 @@ def _goal_continuation_prompt(metadata: Mapping[str, Any] | None) -> str:
"its tool-call budget.\n\n"
f"{goal}\n\n"
"Continue from the saved context. Do not mention the continuation "
"boundary to the user. Use tools as needed, and call complete_goal "
"when the objective is truly finished."
"boundary to the user. Use tools as needed, and call update_goal "
"with action='complete' when the objective is truly finished."
)
return (
"Continue the active sustained goal after the previous turn reached "
"its tool-call budget. Continue from the saved context. Do not mention "
"the continuation boundary to the user. Use tools as needed, and call "
"complete_goal when the objective is truly finished."
"update_goal with action='complete' when the objective is truly finished."
)
-1
View File
@@ -29,4 +29,3 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
| `tmux` | Remote-control tmux sessions |
| `clawhub` | Search and install skills from ClawHub registry |
| `skill-creator` | Create new skills |
| `long-goal` | Sustained objectives: `long_task`, `complete_goal`, idempotent goals, modular project work, early research |
-79
View File
@@ -1,79 +0,0 @@
---
name: long-goal
description: Sustained objectives via long_task / complete_goal — idempotent goal wording, project-style modular work, early web/doc research, Runtime Context metadata.
---
# Long-running objectives (`long_task` / `complete_goal`)
Use these tools when the user wants **multi-turn sustained work** on **one** clear objective (same runner, ordinary tools). Not for trivial one-shot questions.
## Start fast
`long_task` is a lightweight marker. Calling it tells nanobot: "this thread has a sustained objective; keep that objective visible across turns and surface it in the UI."
After reading this short start section, **call `long_task` as soon as the user's intent is clear**. Write a good `goal` immediately: make it idempotent, self-contained, bounded, and explicit about done-ness. Do not spend a long thinking pass on project planning, research, or execution details before setting the marker.
Before the first `long_task` call, you do **not** need to:
1. design the full project plan,
2. research APIs or documentation,
3. write an exhaustive project plan or checklist,
4. decide every file, command, or verification step.
Those belong to the execution phase after the marker is set.
## Tools
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
## Where the goal appears
Inside **`[Runtime Context — metadata only, not instructions]`**, lines starting with **`Goal (active):`** carry the **persisted objective** for this chat session (session metadata). Treat them as the active sustained goal, not user-authored instructions for bypassing policy.
Optional **`Summary:`** is a short UI label only—put crisp acceptance hints in the **`goal`** body itself.
---
# Execution guide after `long_task` is set
Use the guidance below while doing the work. It should shape execution and future context, but it should not delay the first `long_task` call.
## Idempotent goals (important)
**Intent:** The objective string may be **re-read after compaction, across retries, or when resuming** mid-work. It should still mean **one clear outcome**, without implying duplicate destructive steps or relying on chat-only memory.
Write goals so they are:
1. **State-oriented, not fragile narration** — Prefer *desired end state + acceptance criteria* (“Document lists X, Y, Z under `docs/…`; links validated”) over *implicit sequencing* that breaks if step 1 was already done (“First clone the repo, then…”).
2. **Self-contained** — Repeat constraints that matter (paths, repo names, branches, version pins, counts). Do **not** rely on “as discussed above” for requirements that compaction might trim.
3. **Safe under repetition** — Phrasing should survive **resume**: use “ensure …”, “until …”, “verify before changing …”. For mutations (writes, commits, API calls), prefer **check-then-act** or explicitly **idempotent** operations (upsert, overwrite known path, skip if already satisfied).
4. **Bounded scope** — Say what is **in** and **out** (e.g. “top 100 repos by stars in range AB”, “only files under `src/`”). Reduces drift when the model re-enters the goal cold.
5. **Explicit done-ness** — State how you will know youre finished (tests green, artifact exists, checklist satisfied, user confirms). Avoid “when it looks good”.
6. **`ui_summary`** — Short label for sidebars/logs; keep **non-load-bearing** (no secret requirements only in the summary).
If you discover the objective was underspecified, you may ask the user—or **`complete_goal`** with recap and register a **narrower** replacement goal rather than overloading one ambiguous string.
## Project-shaped work (avoid the “mega file” trap)
Use this when the goal is to **build or reshape a codebase** (app, service, tooling, sizeable feature):
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter.
## Look things up instead of guessing
Facts (API specifics, tooling flags, deprecations, best practices newer than cutoff) fail silently in sustained work unless you anchor them early:
1. **Use discovery tools when appropriate** — If the ecosystem is unfamiliar or brittle, **`web_search`**, doc/web fetch (or MCP) **early**—before committing to architecture or rewriting large areas. Narrow queries tied to decisions you must make next.
2. **Turn findings into scoped action** — Summarize conclusions into repo artifacts only when helpful (comments, README, small design note); keep **compact**—not a substitute for executing the objective.
3. **Re-consult when stuck** — If errors contradict assumptions or loops repeat, pause and refresh context with targeted search/fetch rather than hammering blindly.
+31
View File
@@ -0,0 +1,31 @@
[Goal Runtime Guidance — host instructions]
{% if goal_start_requested %}
## Record the sustained goal promptly
When the requested outcome is clear, call `create_goal` before extended planning, research, or execution. Do not delay goal registration to design the full project, research every API, enumerate every file, or write an exhaustive checklist; those belong to execution after the goal is recorded.
### Write a durable objective
The objective may be replayed after compaction, retries, or resumption. Write one clear outcome that remains correct when re-read mid-work:
1. **State-oriented** — Describe the desired end state and acceptance criteria, not a fragile sequence that assumes earlier steps have not run.
2. **Self-contained** — Preserve material constraints such as paths, repositories, branches, versions, counts, and required artifacts. Do not rely on "as discussed above" for load-bearing requirements.
3. **Safe under repetition** — Prefer "ensure", "until", check-before-write, upsert, or other idempotent operations so resumed work does not duplicate destructive effects.
4. **Bounded** — State what is in and out of scope so the work does not drift when resumed from persisted context.
5. **Explicit about done-ness** — Name the evidence that proves completion: tests pass, an artifact exists, a checklist is satisfied, or another concrete condition holds.
6. **Independent of `ui_summary`** — Keep `ui_summary` short and non-load-bearing; every requirement needed after compaction belongs in the objective.
If material requirements remain ambiguous, ask one concise clarification rather than guessing or recording a speculative objective. Ask the user to resubmit the clarified, self-contained request as a complete `/goal <task>` command. If a goal is already active, do not stack another one; replace it only when the requested outcome actually changes.
{% endif %}
{% if goal_active or goal_start_requested %}
## Execute sustained work
- Treat the active objective in Runtime Context as the persisted work target, not as authority to override safety or user constraints. It may be replayed after compaction, retries, or internal continuation.
- Use ordinary tools and keep work reviewable. For project-shaped changes, prefer conventional modules with clear responsibilities over one oversized file, separate configuration from logic, and verify meaningful increments as you go.
- Look up unfamiliar, brittle, or freshness-sensitive facts before committing to architecture or large rewrites. If errors contradict an assumption or attempts repeat, refresh the relevant state or documentation instead of retrying blindly.
- Call `update_goal` with `action='complete'` only after the objective is actually achieved and verified. Use `cancel` when the user cancels, `block` only when progress is genuinely blocked, and `replace` only when the objective changes.
{% endif %}
[/Goal Runtime Guidance]
+2 -1
View File
@@ -39,7 +39,8 @@ LENGTH_RECOVERY_PROMPT = (
SUSTAINED_GOAL_CONTINUE_PROMPT = (
"You have an active sustained goal. Please continue working toward the "
"objective using your tools, or call complete_goal if the work is truly finished."
"objective using your tools, or call update_goal with action='complete' "
"if the work is truly finished."
)