From c4e5d11da94054b5ea8746480469fa62d69d1d19 Mon Sep 17 00:00:00 2001 From: AaronZ345 <34849476+AaronZ345@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:03:07 +0800 Subject: [PATCH] feat(agent): add explicit active-turn steering --- agent/chat/service.py | 11 +- agent/protocol/__init__.py | 12 ++ agent/protocol/agent.py | 8 +- agent/protocol/agent_stream.py | 121 +++++++++++++- agent/protocol/steer.py | 127 +++++++++++++++ bridge/agent_bridge.py | 24 ++- channel/chat_channel.py | 47 ++++++ channel/telegram/telegram_channel.py | 1 + channel/web/chat.html | 14 ++ channel/web/static/js/console.js | 66 ++++++++ channel/web/web_channel.py | 62 ++++++++ docs/channels/telegram.mdx | 1 + docs/cli/general.mdx | 10 ++ docs/cli/index.mdx | 1 + docs/ja/channels/telegram.mdx | 1 + docs/ja/cli/general.mdx | 10 ++ docs/ja/cli/index.mdx | 1 + docs/zh/channels/telegram.mdx | 1 + docs/zh/cli/general.mdx | 10 ++ docs/zh/cli/index.mdx | 1 + tests/test_agent_steering.py | 229 +++++++++++++++++++++++++++ 21 files changed, 753 insertions(+), 5 deletions(-) create mode 100644 agent/protocol/steer.py create mode 100644 tests/test_agent_steering.py diff --git a/agent/chat/service.py b/agent/chat/service.py index 66e91792..fa05733b 100644 --- a/agent/chat/service.py +++ b/agent/chat/service.py @@ -183,9 +183,15 @@ class ChatService: # Register a cancel token so /cancel can abort this in-flight run. # IM channels key on session_id (no per-turn request_id here). - from agent.protocol import get_cancel_registry + from agent.protocol import get_cancel_registry, get_steer_registry registry = get_cancel_registry() + steer_registry = get_steer_registry() cancel_event = registry.register(session_id, session_id=session_id) if session_id else None + steer_inbox = ( + steer_registry.register(session_id) + if session_id + else None + ) executor = AgentStreamExecutor( agent=agent, @@ -197,6 +203,7 @@ class ChatService: messages=messages_copy, max_context_turns=max_context_turns, cancel_event=cancel_event, + steer_inbox=steer_inbox, ) try: @@ -217,6 +224,8 @@ class ChatService: registry.unregister(session_id) except Exception: pass + if session_id and steer_inbox is not None: + steer_registry.unregister(session_id, steer_inbox) # Sync executor messages back to agent (thread-safe). # The executor may have trimmed context, making its list shorter than diff --git a/agent/protocol/__init__.py b/agent/protocol/__init__.py index f0a7a4e2..7237755b 100644 --- a/agent/protocol/__init__.py +++ b/agent/protocol/__init__.py @@ -8,6 +8,13 @@ from .cancel import ( CancelTokenRegistry, get_cancel_registry, ) +from .steer import ( + SteerInbox, + SteerRegistry, + SteerResult, + SteerStatus, + get_steer_registry, +) __all__ = [ 'Agent', @@ -25,4 +32,9 @@ __all__ = [ 'AgentCancelledError', 'CancelTokenRegistry', 'get_cancel_registry', + 'SteerInbox', + 'SteerRegistry', + 'SteerResult', + 'SteerStatus', + 'get_steer_registry', ] diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py index 0b2e36ad..0dd80f7a 100644 --- a/agent/protocol/agent.py +++ b/agent/protocol/agent.py @@ -381,7 +381,7 @@ class Agent: return action def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, - skill_filter=None, cancel_event=None) -> str: + skill_filter=None, cancel_event=None, steer_inbox=None) -> str: """ Execute single agent task with streaming (based on tool-call) @@ -391,6 +391,7 @@ class Agent: - Event callbacks - Persistent conversation history across calls - User-initiated cancellation via ``cancel_event`` + - Explicit active-turn guidance via ``steer_inbox`` Args: user_message: User message @@ -403,6 +404,8 @@ class Agent: "[Interrupted by user]" assistant note, and returns the partial response. ``messages`` stays in a valid state (tool_use/tool_result pairs preserved). + steer_inbox: Optional SteerInbox drained at safe checkpoints. New + instructions guide this run without entering the normal queue. Returns: Final response text @@ -448,6 +451,7 @@ class Agent: messages=messages_copy, # Pass copied message history max_context_turns=max_context_turns, cancel_event=cancel_event, + steer_inbox=steer_inbox, ) # Execute @@ -484,4 +488,4 @@ class Agent: def clear_history(self): """Clear conversation history and captured actions""" self.messages = [] - self.captured_actions = [] \ No newline at end of file + self.captured_actions = [] diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py index 920112b2..fe402561 100644 --- a/agent/protocol/agent_stream.py +++ b/agent/protocol/agent_stream.py @@ -99,6 +99,7 @@ class AgentStreamExecutor: messages: Optional[List[Dict]] = None, max_context_turns: int = 30, cancel_event=None, + steer_inbox=None, ): """ Initialize stream executor @@ -116,6 +117,8 @@ class AgentStreamExecutor: Checked at every safe point (turn boundary, before tool execution, during LLM streaming). When set, raises AgentCancelledError which run_stream catches to gracefully wind down. + steer_inbox: Optional SteerInbox for explicit instructions sent to + this active run. Drained only at message-safe checkpoints. """ self.agent = agent self.model = model @@ -126,6 +129,7 @@ class AgentStreamExecutor: self.on_event = on_event self.max_context_turns = max_context_turns self.cancel_event = cancel_event + self.steer_inbox = steer_inbox # Message history - use provided messages or create new list self.messages = messages if messages is not None else [] @@ -145,6 +149,72 @@ class AgentStreamExecutor: if self.cancel_event is not None and self.cancel_event.is_set(): raise AgentCancelledError("agent cancelled by user") + def _drain_steering(self) -> List[str]: + if self.steer_inbox is None: + return [] + return self.steer_inbox.drain() + + @staticmethod + def _steering_text(updates: List[str]) -> str: + if len(updates) == 1: + body = updates[0] + else: + body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1)) + return ( + "[Steering update for the active task]\n" + "Use this new instruction for the current task before continuing.\n\n" + f"{body}" + ) + + def _append_steering( + self, + updates: List[str], + pending_tool_calls: Optional[List[Dict]] = None, + content_blocks: Optional[List[Dict]] = None, + ) -> None: + """Append guidance, closing any tool_use blocks that will be skipped.""" + if not updates: + return + blocks = content_blocks if content_blocks is not None else [] + for tool_call in pending_tool_calls or []: + blocks.append({ + "type": "tool_result", + "tool_use_id": tool_call["id"], + "content": "Skipped because the user redirected the active task.", + "is_error": True, + }) + blocks.append({"type": "text", "text": self._steering_text(updates)}) + if content_blocks is None: + self.messages.append({"role": "user", "content": blocks}) + self._emit_event("agent_steered", {"count": len(updates)}) + logger.info(f"[Agent] Applied {len(updates)} steering update(s)") + + def _close_or_apply_final_steering(self) -> bool: + """Return True only when the run can finish without losing a steer.""" + updates = self._drain_steering() + if updates: + self._append_steering(updates) + return False + if self.steer_inbox is None: + return True + if self.steer_inbox.close_if_empty(): + return True + updates = self._drain_steering() + if updates: + self._append_steering(updates) + return False + + def _drain_and_close_steering(self) -> None: + """Preserve any final guidance before the max-step summary call.""" + if self.steer_inbox is None: + return + while True: + updates = self._drain_steering() + if updates: + self._append_steering(updates) + if self.steer_inbox.close_if_empty(): + return + def _handle_cancelled(self, partial_response: str) -> None: """Wind down ``self.messages`` after a user-initiated cancel. @@ -395,6 +465,10 @@ class AgentStreamExecutor: # between turns short-circuits cleanly. self._check_cancelled() + steering_updates = self._drain_steering() + if steering_updates: + self._append_steering(steering_updates) + turn += 1 logger.info(f"[Agent] Turn {turn}") self._emit_event("turn_start", {"turn": turn}) @@ -403,6 +477,24 @@ class AgentStreamExecutor: assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True) final_response = assistant_msg + # A steer that arrived while the model was streaming takes + # precedence over its proposed continuation. Tool calls have + # already been written to history, so close every one with a + # synthetic result before asking the model to reconsider. + steering_updates = self._drain_steering() + if steering_updates: + self._append_steering( + steering_updates, + pending_tool_calls=tool_calls, + ) + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": bool(tool_calls), + "tool_count": len(tool_calls), + "steered": True, + }) + continue + # No tool calls, end loop if not tool_calls: # 检查是否返回了空响应 @@ -467,6 +559,22 @@ class AgentStreamExecutor: # If the explicit-response retry produced tool_calls, skip the break # and continue down to the tool execution branch in this same iteration. if not tool_calls: + steering_updates = self._drain_steering() + if steering_updates: + self._append_steering(steering_updates) + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": False, + "steered": True, + }) + continue + if not self._close_or_apply_final_steering(): + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": False, + "steered": True, + }) + continue logger.debug(f"✅ Done (no tool calls)") self._emit_event("turn_end", { "turn": turn, @@ -499,9 +607,17 @@ class AgentStreamExecutor: tool_result_blocks = [] try: - for tool_call in tool_calls: + for tool_index, tool_call in enumerate(tool_calls): # Honour cancel between tool invocations within the same turn self._check_cancelled() + steering_updates = self._drain_steering() + if steering_updates: + self._append_steering( + steering_updates, + pending_tool_calls=tool_calls[tool_index:], + content_blocks=tool_result_blocks, + ) + break result = self._execute_tool(tool_call) tool_results.append(result) @@ -641,6 +757,7 @@ class AgentStreamExecutor: if turn >= self.max_turns: logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}") + self._drain_and_close_steering() # Force model to summarize without tool calls logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") @@ -699,6 +816,8 @@ class AgentStreamExecutor: raise finally: + if self.steer_inbox is not None: + self.steer_inbox.close() final_response = final_response.strip() if final_response else final_response if cancelled: # Emit before agent_end so channels can mark UI as cancelled diff --git a/agent/protocol/steer.py b/agent/protocol/steer.py new file mode 100644 index 00000000..d21e1bc6 --- /dev/null +++ b/agent/protocol/steer.py @@ -0,0 +1,127 @@ +"""Thread-safe active-run steering primitives. + +Steering is deliberately separate from the normal per-session message queue. +An instruction is accepted only while exactly one run for the scoped session +is active; idle sessions never start a new run as a side effect. +""" + +from __future__ import annotations + +import threading +from collections import deque +from dataclasses import dataclass +from enum import Enum +from typing import Deque, Dict, List, Optional, Set + + +class SteerStatus(str, Enum): + ACCEPTED = "accepted" + INACTIVE = "inactive" + AMBIGUOUS = "ambiguous" + INVALID = "invalid" + FULL = "full" + CLOSING = "closing" + + +@dataclass(frozen=True) +class SteerResult: + status: SteerStatus + + @property + def accepted(self) -> bool: + return self.status == SteerStatus.ACCEPTED + + +class SteerInbox: + """Bounded inbox owned by one active agent run.""" + + def __init__(self, max_pending: int = 16, max_chars: int = 8000): + self.max_pending = max(1, int(max_pending)) + self.max_chars = max(1, int(max_chars)) + self._lock = threading.Lock() + self._pending: Deque[str] = deque() + self._accepting = True + + def submit(self, instruction: str) -> SteerResult: + text = (instruction or "").strip() + if not text or len(text) > self.max_chars: + return SteerResult(SteerStatus.INVALID) + with self._lock: + if not self._accepting: + return SteerResult(SteerStatus.CLOSING) + if len(self._pending) >= self.max_pending: + return SteerResult(SteerStatus.FULL) + self._pending.append(text) + return SteerResult(SteerStatus.ACCEPTED) + + def drain(self) -> List[str]: + with self._lock: + items = list(self._pending) + self._pending.clear() + return items + + def close_if_empty(self) -> bool: + """Atomically stop accepting when no instruction is pending. + + This closes the race between a final empty drain and an agent run + returning: after this method succeeds, submitters receive CLOSING. + """ + with self._lock: + if self._pending: + return False + self._accepting = False + return True + + def close(self) -> None: + with self._lock: + self._accepting = False + + +class SteerRegistry: + """Map a scoped agent/session key to its active run inboxes.""" + + def __init__(self): + self._lock = threading.Lock() + self._by_session: Dict[str, Set[SteerInbox]] = {} + + def register(self, session_id: str, inbox: Optional[SteerInbox] = None) -> SteerInbox: + inbox = inbox or SteerInbox() + if not session_id: + return inbox + with self._lock: + self._by_session.setdefault(session_id, set()).add(inbox) + return inbox + + def unregister(self, session_id: str, inbox: Optional[SteerInbox]) -> None: + if not session_id or inbox is None: + return + inbox.close() + with self._lock: + bucket = self._by_session.get(session_id) + if bucket is None: + return + bucket.discard(inbox) + if not bucket: + self._by_session.pop(session_id, None) + + def submit(self, session_id: str, instruction: str) -> SteerResult: + if not (instruction or "").strip(): + return SteerResult(SteerStatus.INVALID) + with self._lock: + inboxes = list(self._by_session.get(session_id, ())) + if not inboxes: + return SteerResult(SteerStatus.INACTIVE) + if len(inboxes) != 1: + return SteerResult(SteerStatus.AMBIGUOUS) + return inboxes[0].submit(instruction) + + def active_count(self, session_id: str) -> int: + with self._lock: + return len(self._by_session.get(session_id, ())) + + +_registry = SteerRegistry() + + +def get_steer_registry() -> SteerRegistry: + return _registry diff --git a/bridge/agent_bridge.py b/bridge/agent_bridge.py index 33c8ddaf..927e2028 100644 --- a/bridge/agent_bridge.py +++ b/bridge/agent_bridge.py @@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge import os from typing import Optional, List -from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry +from agent.protocol import ( + Agent, + LLMModel, + LLMRequest, + get_cancel_registry, + get_steer_registry, +) from bridge.agent_event_handler import AgentEventHandler from bridge.agent_initializer import AgentInitializer from bridge.bridge import Bridge @@ -360,6 +366,10 @@ class AgentBridge: return agent + def steer_session(self, session_id: str, instruction: str): + """Inject an explicit instruction into one active session.""" + return get_steer_registry().submit(session_id, instruction) + def get_agent(self, session_id: str = None) -> Optional[Agent]: """ Get agent instance for the given session @@ -452,6 +462,8 @@ class AgentBridge: agent = None request_id = None cancel_event = None + token_key = None + steer_inbox = None try: # Extract session_id from context for user isolation if context: @@ -534,12 +546,15 @@ class AgentBridge: pass try: + if session_id: + steer_inbox = get_steer_registry().register(session_id) # Use agent's run_stream method with event handler response = agent.run_stream( user_message=query, on_event=event_handler.handle_event, clear_history=clear_history, cancel_event=cancel_event, + steer_inbox=steer_inbox, ) finally: # Clear the mid-run flag so idle scans can review this session. @@ -562,6 +577,8 @@ class AgentBridge: registry.unregister(token_key) except Exception: pass + if session_id and steer_inbox is not None: + get_steer_registry().unregister(session_id, steer_inbox) # Persist new messages generated during this run if session_id: @@ -643,6 +660,11 @@ class AgentBridge: get_cancel_registry().unregister(request_id or session_id) except Exception: pass + if session_id and steer_inbox is not None: + try: + get_steer_registry().unregister(session_id, steer_inbox) + except Exception: + pass return Reply(ReplyType.ERROR, f"Agent error: {str(e)}") def _schedule_mcp_hot_reload(self, agent): diff --git a/channel/chat_channel.py b/channel/chat_channel.py index e2a65c5b..96c12d05 100644 --- a/channel/chat_channel.py +++ b/channel/chat_channel.py @@ -453,6 +453,10 @@ class ChatChannel(Channel): if stripped in self._BYPASS_QUEUE_COMMANDS: self._handle_cancel_command(context, session_id) return + if re.match(r"^/steer(?:\s|$)", stripped): + instruction = context.content.strip()[len("/steer"):].strip() + self._handle_steer_command(context, session_id, instruction) + return with self.lock: if session_id not in self.sessions: @@ -488,6 +492,49 @@ class ChatChannel(Channel): except Exception as e: logger.warning(f"[chat_channel] /cancel fast-path failed: {e}") + def _handle_steer_command( + self, + context: Context, + session_id: str, + instruction: str, + ) -> None: + """Send explicit guidance to the active run without queueing it.""" + try: + from agent.protocol import SteerStatus + from bridge.bridge import Bridge + + result = Bridge().get_agent_bridge().steer_session(session_id, instruction) + messages = { + SteerStatus.ACCEPTED: _t( + "↪️ 已引导当前任务。", "↪️ Active task redirected." + ), + SteerStatus.INACTIVE: _t( + "当前没有可引导的任务。", "No active task to steer." + ), + SteerStatus.CLOSING: _t( + "当前任务已结束,无法再引导。", "The active task is already finishing." + ), + SteerStatus.AMBIGUOUS: _t( + "当前会话有多个任务在运行,无法确定引导目标。", + "Multiple tasks are active in this session; the steering target is ambiguous.", + ), + SteerStatus.FULL: _t( + "引导指令过多,请等待当前任务处理后再试。", + "Too many steering updates are pending; try again after the agent processes them.", + ), + SteerStatus.INVALID: _t( + "用法:/steer <引导指令>", "Usage: /steer " + ), + } + text = messages[result.status] + logger.info( + f"[chat_channel] /steer fast-path: session={session_id}, " + f"status={result.status.value}" + ) + self._send_reply(context, Reply(ReplyType.TEXT, text)) + except Exception as e: + logger.warning(f"[chat_channel] /steer fast-path failed: {e}") + # 消费者函数,单独线程,用于从消息队列中取出消息并处理 def consume(self): while True: diff --git a/channel/telegram/telegram_channel.py b/channel/telegram/telegram_channel.py index 6b82b308..5584ad63 100644 --- a/channel/telegram/telegram_channel.py +++ b/channel/telegram/telegram_channel.py @@ -42,6 +42,7 @@ TELEGRAM_BOT_COMMANDS = [ ("knowledge", "Manage knowledge base (list/on/off)"), ("config", "Show current config"), ("cancel", "Cancel running agent task"), + ("steer", "Guide the running agent task"), ("logs", "Show recent logs"), ("version", "Show version"), ] diff --git a/channel/web/chat.html b/channel/web/chat.html index 6381ca08..14ce107a 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -485,6 +485,20 @@ +