feat(agent): add explicit active-turn steering

This commit is contained in:
AaronZ345
2026-07-19 16:03:07 +08:00
parent 0c76d851cb
commit c4e5d11da9
21 changed files with 753 additions and 5 deletions

View File

@@ -183,9 +183,15 @@ class ChatService:
# Register a cancel token so /cancel can abort this in-flight run. # Register a cancel token so /cancel can abort this in-flight run.
# IM channels key on session_id (no per-turn request_id here). # 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() registry = get_cancel_registry()
steer_registry = get_steer_registry()
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None 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( executor = AgentStreamExecutor(
agent=agent, agent=agent,
@@ -197,6 +203,7 @@ class ChatService:
messages=messages_copy, messages=messages_copy,
max_context_turns=max_context_turns, max_context_turns=max_context_turns,
cancel_event=cancel_event, cancel_event=cancel_event,
steer_inbox=steer_inbox,
) )
try: try:
@@ -217,6 +224,8 @@ class ChatService:
registry.unregister(session_id) registry.unregister(session_id)
except Exception: except Exception:
pass 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). # Sync executor messages back to agent (thread-safe).
# The executor may have trimmed context, making its list shorter than # The executor may have trimmed context, making its list shorter than

View File

@@ -8,6 +8,13 @@ from .cancel import (
CancelTokenRegistry, CancelTokenRegistry,
get_cancel_registry, get_cancel_registry,
) )
from .steer import (
SteerInbox,
SteerRegistry,
SteerResult,
SteerStatus,
get_steer_registry,
)
__all__ = [ __all__ = [
'Agent', 'Agent',
@@ -25,4 +32,9 @@ __all__ = [
'AgentCancelledError', 'AgentCancelledError',
'CancelTokenRegistry', 'CancelTokenRegistry',
'get_cancel_registry', 'get_cancel_registry',
'SteerInbox',
'SteerRegistry',
'SteerResult',
'SteerStatus',
'get_steer_registry',
] ]

View File

@@ -381,7 +381,7 @@ class Agent:
return action return action
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, 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) Execute single agent task with streaming (based on tool-call)
@@ -391,6 +391,7 @@ class Agent:
- Event callbacks - Event callbacks
- Persistent conversation history across calls - Persistent conversation history across calls
- User-initiated cancellation via ``cancel_event`` - User-initiated cancellation via ``cancel_event``
- Explicit active-turn guidance via ``steer_inbox``
Args: Args:
user_message: User message user_message: User message
@@ -403,6 +404,8 @@ class Agent:
"[Interrupted by user]" assistant note, and returns the "[Interrupted by user]" assistant note, and returns the
partial response. ``messages`` stays in a valid state partial response. ``messages`` stays in a valid state
(tool_use/tool_result pairs preserved). (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: Returns:
Final response text Final response text
@@ -448,6 +451,7 @@ class Agent:
messages=messages_copy, # Pass copied message history messages=messages_copy, # Pass copied message history
max_context_turns=max_context_turns, max_context_turns=max_context_turns,
cancel_event=cancel_event, cancel_event=cancel_event,
steer_inbox=steer_inbox,
) )
# Execute # Execute

View File

@@ -99,6 +99,7 @@ class AgentStreamExecutor:
messages: Optional[List[Dict]] = None, messages: Optional[List[Dict]] = None,
max_context_turns: int = 30, max_context_turns: int = 30,
cancel_event=None, cancel_event=None,
steer_inbox=None,
): ):
""" """
Initialize stream executor Initialize stream executor
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
Checked at every safe point (turn boundary, before tool execution, Checked at every safe point (turn boundary, before tool execution,
during LLM streaming). When set, raises AgentCancelledError which during LLM streaming). When set, raises AgentCancelledError which
run_stream catches to gracefully wind down. 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.agent = agent
self.model = model self.model = model
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
self.on_event = on_event self.on_event = on_event
self.max_context_turns = max_context_turns self.max_context_turns = max_context_turns
self.cancel_event = cancel_event self.cancel_event = cancel_event
self.steer_inbox = steer_inbox
# Message history - use provided messages or create new list # Message history - use provided messages or create new list
self.messages = messages if messages is not None else [] 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(): if self.cancel_event is not None and self.cancel_event.is_set():
raise AgentCancelledError("agent cancelled by user") 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: def _handle_cancelled(self, partial_response: str) -> None:
"""Wind down ``self.messages`` after a user-initiated cancel. """Wind down ``self.messages`` after a user-initiated cancel.
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
# between turns short-circuits cleanly. # between turns short-circuits cleanly.
self._check_cancelled() self._check_cancelled()
steering_updates = self._drain_steering()
if steering_updates:
self._append_steering(steering_updates)
turn += 1 turn += 1
logger.info(f"[Agent] Turn {turn}") logger.info(f"[Agent] Turn {turn}")
self._emit_event("turn_start", {"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) assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
final_response = assistant_msg 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 # No tool calls, end loop
if not tool_calls: if not tool_calls:
# 检查是否返回了空响应 # 检查是否返回了空响应
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
# If the explicit-response retry produced tool_calls, skip the break # If the explicit-response retry produced tool_calls, skip the break
# and continue down to the tool execution branch in this same iteration. # and continue down to the tool execution branch in this same iteration.
if not tool_calls: 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)") logger.debug(f"✅ Done (no tool calls)")
self._emit_event("turn_end", { self._emit_event("turn_end", {
"turn": turn, "turn": turn,
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
tool_result_blocks = [] tool_result_blocks = []
try: 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 # Honour cancel between tool invocations within the same turn
self._check_cancelled() 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) result = self._execute_tool(tool_call)
tool_results.append(result) tool_results.append(result)
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
if turn >= self.max_turns: if turn >= self.max_turns:
logger.warning(f"⚠️ Reached max decision step limit: {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 # Force model to summarize without tool calls
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
raise raise
finally: finally:
if self.steer_inbox is not None:
self.steer_inbox.close()
final_response = final_response.strip() if final_response else final_response final_response = final_response.strip() if final_response else final_response
if cancelled: if cancelled:
# Emit before agent_end so channels can mark UI as cancelled # Emit before agent_end so channels can mark UI as cancelled

127
agent/protocol/steer.py Normal file
View File

@@ -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

View File

@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
import os import os
from typing import Optional, List 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_event_handler import AgentEventHandler
from bridge.agent_initializer import AgentInitializer from bridge.agent_initializer import AgentInitializer
from bridge.bridge import Bridge from bridge.bridge import Bridge
@@ -360,6 +366,10 @@ class AgentBridge:
return agent 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]: def get_agent(self, session_id: str = None) -> Optional[Agent]:
""" """
Get agent instance for the given session Get agent instance for the given session
@@ -452,6 +462,8 @@ class AgentBridge:
agent = None agent = None
request_id = None request_id = None
cancel_event = None cancel_event = None
token_key = None
steer_inbox = None
try: try:
# Extract session_id from context for user isolation # Extract session_id from context for user isolation
if context: if context:
@@ -534,12 +546,15 @@ class AgentBridge:
pass pass
try: try:
if session_id:
steer_inbox = get_steer_registry().register(session_id)
# Use agent's run_stream method with event handler # Use agent's run_stream method with event handler
response = agent.run_stream( response = agent.run_stream(
user_message=query, user_message=query,
on_event=event_handler.handle_event, on_event=event_handler.handle_event,
clear_history=clear_history, clear_history=clear_history,
cancel_event=cancel_event, cancel_event=cancel_event,
steer_inbox=steer_inbox,
) )
finally: finally:
# Clear the mid-run flag so idle scans can review this session. # Clear the mid-run flag so idle scans can review this session.
@@ -562,6 +577,8 @@ class AgentBridge:
registry.unregister(token_key) registry.unregister(token_key)
except Exception: except Exception:
pass 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 # Persist new messages generated during this run
if session_id: if session_id:
@@ -643,6 +660,11 @@ class AgentBridge:
get_cancel_registry().unregister(request_id or session_id) get_cancel_registry().unregister(request_id or session_id)
except Exception: except Exception:
pass 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)}") return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
def _schedule_mcp_hot_reload(self, agent): def _schedule_mcp_hot_reload(self, agent):

View File

@@ -453,6 +453,10 @@ class ChatChannel(Channel):
if stripped in self._BYPASS_QUEUE_COMMANDS: if stripped in self._BYPASS_QUEUE_COMMANDS:
self._handle_cancel_command(context, session_id) self._handle_cancel_command(context, session_id)
return 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: with self.lock:
if session_id not in self.sessions: if session_id not in self.sessions:
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
except Exception as e: except Exception as e:
logger.warning(f"[chat_channel] /cancel fast-path failed: {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 <instruction>"
),
}
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): def consume(self):
while True: while True:

View File

@@ -42,6 +42,7 @@ TELEGRAM_BOT_COMMANDS = [
("knowledge", "Manage knowledge base (list/on/off)"), ("knowledge", "Manage knowledge base (list/on/off)"),
("config", "Show current config"), ("config", "Show current config"),
("cancel", "Cancel running agent task"), ("cancel", "Cancel running agent task"),
("steer", "Guide the running agent task"),
("logs", "Show recent logs"), ("logs", "Show recent logs"),
("version", "Show version"), ("version", "Show version"),
] ]

View File

@@ -485,6 +485,20 @@
<i class="fas fa-microphone text-sm"></i> <i class="fas fa-microphone text-sm"></i>
</button> </button>
</div> </div>
<button id="steer-btn"
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
border border-primary-300 dark:border-primary-700
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
disabled:text-slate-300 dark:disabled:text-slate-600
disabled:border-slate-200 dark:disabled:border-slate-700
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
type="button"
data-i18n-title="steer_active"
data-i18n-aria-label="steer_active"
aria-label="引导当前任务"
title="引导当前任务">
<i class="fas fa-arrow-turn-up text-sm"></i>
</button>
<button id="send-btn" <button id="send-btn"
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
bg-primary-400 text-white hover:bg-primary-500 bg-primary-400 text-white hover:bg-primary-500

View File

@@ -123,6 +123,8 @@ const I18N = {
slash_knowledge_off: '关闭知识库', slash_knowledge_off: '关闭知识库',
slash_config: '查看当前配置', slash_config: '查看当前配置',
slash_cancel: '中止当前正在运行的 Agent 任务', slash_cancel: '中止当前正在运行的 Agent 任务',
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
steer_active: '引导当前任务',
slash_logs: '查看最近日志', slash_logs: '查看最近日志',
slash_version: '查看版本', slash_version: '查看版本',
input_placeholder: '输入消息,或输入 / 使用指令', input_placeholder: '输入消息,或输入 / 使用指令',
@@ -375,6 +377,8 @@ const I18N = {
slash_knowledge_off: '關閉知識庫', slash_knowledge_off: '關閉知識庫',
slash_config: '檢視當前設定', slash_config: '檢視當前設定',
slash_cancel: '中止當前正在執行的 Agent 任務', slash_cancel: '中止當前正在執行的 Agent 任務',
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
steer_active: '引導當前任務',
slash_logs: '檢視最近日誌', slash_logs: '檢視最近日誌',
slash_version: '檢視版本', slash_version: '檢視版本',
input_placeholder: '輸入訊息,或輸入 / 使用指令', input_placeholder: '輸入訊息,或輸入 / 使用指令',
@@ -626,6 +630,8 @@ const I18N = {
slash_knowledge_off: 'Disable knowledge base', slash_knowledge_off: 'Disable knowledge base',
slash_config: 'Show current config', slash_config: 'Show current config',
slash_cancel: 'Abort the running Agent task', slash_cancel: 'Abort the running Agent task',
slash_steer: 'Inject guidance into the running Agent task',
steer_active: 'Steer active task',
slash_logs: 'Show recent logs', slash_logs: 'Show recent logs',
slash_version: 'Show version', slash_version: 'Show version',
input_placeholder: 'Type a message, or press / for commands', input_placeholder: 'Type a message, or press / for commands',
@@ -816,6 +822,9 @@ function applyI18n() {
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset['i18nTitle']); el.title = t(el.dataset['i18nTitle']);
}); });
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
el.setAttribute('aria-label', t(el.dataset['i18nAriaLabel']));
});
document.querySelectorAll('[data-tip-key]').forEach(el => { document.querySelectorAll('[data-tip-key]').forEach(el => {
el.setAttribute('data-tooltip', t(el.dataset.tipKey)); el.setAttribute('data-tooltip', t(el.dataset.tipKey));
}); });
@@ -1415,6 +1424,7 @@ startPolling();
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn'); const sendBtn = document.getElementById('send-btn');
const steerBtn = document.getElementById('steer-btn');
const messagesDiv = document.getElementById('chat-messages'); const messagesDiv = document.getElementById('chat-messages');
const fileInput = document.getElementById('file-input'); const fileInput = document.getElementById('file-input');
const folderInput = document.getElementById('folder-input'); const folderInput = document.getElementById('folder-input');
@@ -1749,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
sendBtn.classList.add('send-btn-cancel'); sendBtn.classList.add('send-btn-cancel');
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel'); sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>'; sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
updateSteerBtnState();
} }
function resetSendBtnSendMode() { function resetSendBtnSendMode() {
@@ -1757,9 +1768,61 @@ function resetSendBtnSendMode() {
sendBtn.classList.remove('send-btn-cancel'); sendBtn.classList.remove('send-btn-cancel');
sendBtn.title = ''; sendBtn.title = '';
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>'; sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
steerBtn.classList.add('hidden');
steerBtn.classList.remove('flex');
steerBtn.disabled = true;
updateSendBtnState(); updateSendBtnState();
} }
function updateSteerBtnState() {
const active = sendBtnMode === 'cancel' && !!activeRequestId;
steerBtn.classList.toggle('hidden', !active);
steerBtn.classList.toggle('flex', active);
steerBtn.disabled = !active || uploadingCount > 0 || !chatInput.value.trim();
}
function steerActiveTask() {
const instruction = chatInput.value.trim();
if (!instruction || sendBtnMode !== 'cancel' || !activeRequestId) return;
inputHistory.push(instruction);
historyIdx = -1;
historySavedDraft = '';
addUserMessage(`${instruction}`, new Date());
chatInput.value = '';
chatInput.style.height = '42px';
chatInput.style.overflowY = 'hidden';
updateSteerBtnState();
fetch('/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
message: instruction,
steer: true,
stream: false,
lang: currentLang,
}),
})
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.inline_reply) {
addBotMessage(data.inline_reply, new Date());
} else {
addBotMessage(t('error_send'), new Date());
}
})
.catch(err => {
console.warn('[steer] request failed', err);
addBotMessage(t('error_send'), new Date());
})
.finally(updateSteerBtnState);
}
steerBtn.addEventListener('click', steerActiveTask);
function requestCancel() { function requestCancel() {
const reqId = activeRequestId; const reqId = activeRequestId;
if (!reqId) return; if (!reqId) return;
@@ -1795,10 +1858,12 @@ function updateSendBtnState() {
resetSendBtnSendMode(); resetSendBtnSendMode();
} else { } else {
// Don't downgrade a genuinely active Cancel button on input edits. // Don't downgrade a genuinely active Cancel button on input edits.
updateSteerBtnState();
return; return;
} }
} }
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0); sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
updateSteerBtnState();
} }
function renderAttachmentPreview() { function renderAttachmentPreview() {
@@ -2117,6 +2182,7 @@ const SLASH_COMMANDS = [
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' }, { cmd: '/knowledge off', desc: 'slash_knowledge_off' },
{ cmd: '/config', desc: 'slash_config' }, { cmd: '/config', desc: 'slash_config' },
{ cmd: '/cancel', desc: 'slash_cancel' }, { cmd: '/cancel', desc: 'slash_cancel' },
{ cmd: '/steer ', desc: 'slash_steer' },
{ cmd: '/logs', desc: 'slash_logs' }, { cmd: '/logs', desc: 'slash_logs' },
{ cmd: '/version', desc: 'slash_version' }, { cmd: '/version', desc: 'slash_version' },
]; ];

View File

@@ -6,6 +6,7 @@ import logging
import mimetypes import mimetypes
import os import os
import random import random
import re
import shutil import shutil
import threading import threading
import time import time
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
return "Nothing to cancel." if en else "当前没有可中止的任务。" return "Nothing to cancel." if en else "当前没有可中止的任务。"
def _steer_reply_text(status, lang: str) -> str:
from agent.protocol import SteerStatus
en = (lang or "").lower().startswith("en")
messages = {
SteerStatus.ACCEPTED: (
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
),
SteerStatus.INACTIVE: (
"No active task to steer.", "当前没有可引导的任务。"
),
SteerStatus.CLOSING: (
"The active task is already finishing.", "当前任务已结束,无法再引导。"
),
SteerStatus.AMBIGUOUS: (
"Multiple tasks are active in this session; the steering target is ambiguous.",
"当前会话有多个任务在运行,无法确定引导目标。",
),
SteerStatus.FULL: (
"Too many steering updates are pending; try again after the agent processes them.",
"引导指令过多,请等待当前任务处理后再试。",
),
SteerStatus.INVALID: (
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
),
}
english, chinese = messages[status]
return english if en else chinese
def _get_upload_dir() -> str: def _get_upload_dir() -> str:
from common.utils import expand_path from common.utils import expand_path
ws_root = expand_path(conf().get("agent_workspace", "~/cow")) ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
"inline_reply": msg_text, "inline_reply": msg_text,
}) })
# Explicit steering also bypasses the normal session queue. The
# Web button sends ``steer: true`` with raw input; typed /steer
# commands use the same endpoint and semantics as IM channels.
steer_requested = bool(json_data.get("steer", False))
is_steer_command = (
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
)
if steer_requested or is_steer_command:
instruction = (
(prompt or "").strip()[len("/steer"):].strip()
if is_steer_command
else (prompt or "").strip()
)
from bridge.bridge import Bridge
result = Bridge().get_agent_bridge().steer_session(
session_id, instruction
)
lang = (json_data.get("lang") or "zh").lower()
msg_text = _steer_reply_text(result.status, lang)
logger.info(
f"[WebChannel] steer fast-path: session={session_id}, "
f"status={result.status.value}, lang={lang}"
)
return json.dumps({
"status": "success",
"request_id": "",
"stream": False,
"steered": result.accepted,
"inline_reply": msg_text,
}, ensure_ascii=False)
# Append file references to the prompt (same format as QQ channel) # Append file references to the prompt (same format as QQ channel)
if attachments: if attachments:
file_refs = [] file_refs = []

View File

@@ -94,6 +94,7 @@ On startup, the channel registers a command menu with BotFather. Typing `/` in T
| `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) | | `/knowledge` | Knowledge base (`/knowledge list` / `on` / `off`) |
| `/config` | View current config | | `/config` | View current config |
| `/cancel` | Cancel the running Agent task | | `/cancel` | Cancel the running Agent task |
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
| `/logs` | View recent logs | | `/logs` | View recent logs |
| `/version` | Show version | | `/version` | Show version |

View File

@@ -33,6 +33,16 @@ Abort the agent task currently running in this session. When the agent is busy w
/cancel /cancel
``` ```
## steer
Redirect the Agent task currently running in this session without cancelling it. The instruction is injected at the next safe checkpoint; a tool that is already running may finish, while tools that have not started are skipped. If no task is active, `/steer` does not start or queue a new one. Available across all chat channels.
```text
/steer focus on the failing tests first
```
In the Web console, enter an instruction while a reply is running and click **Steer active task**. Sending an ordinary message still uses the session queue.
## config ## config
View or modify runtime configuration. Changes take effect immediately without restarting. View or modify runtime configuration. Changes take effect immediately without restarting.

View File

@@ -62,6 +62,7 @@ In the Web console or any connected channel, type `/` to see command suggestions
| `/help` | Show command help | | `/help` | Show command help |
| `/status` | View service status and configuration | | `/status` | View service status and configuration |
| `/cancel` | Abort the currently running agent task | | `/cancel` | Abort the currently running agent task |
| `/steer <instruction>` | Guide the currently running agent task without queueing a new turn |
| `/config` | View or modify runtime configuration | | `/config` | View or modify runtime configuration |
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) | | `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
| `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) | | `/memory dream [N]` | Manually trigger memory distillation (default 3 days, max 30) |

View File

@@ -94,6 +94,7 @@ description: Telegram Bot API 経由で CowAgent を接続
| `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off` | | `/knowledge` | ナレッジベース管理(`/knowledge list` / `on` / `off` |
| `/config` | 現在の設定を表示 | | `/config` | 現在の設定を表示 |
| `/cancel` | 実行中の Agent タスクを中断 | | `/cancel` | 実行中の Agent タスクを中断 |
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>` |
| `/logs` | 最近のログを表示 | | `/logs` | 最近のログを表示 |
| `/version` | バージョンを表示 | | `/version` | バージョンを表示 |

View File

@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
/cancel /cancel
``` ```
## steer
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
```text
/steer 失敗しているテストを先に確認して
```
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
## config ## config
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。 実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。

View File

@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
| `/help` | コマンドヘルプを表示 | | `/help` | コマンドヘルプを表示 |
| `/status` | サービスの状態と設定を表示 | | `/status` | サービスの状態と設定を表示 |
| `/cancel` | 実行中の Agent タスクを中止 | | `/cancel` | 実行中の Agent タスクを中止 |
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
| `/config` | 実行時設定の表示・変更 | | `/config` | 実行時設定の表示・変更 |
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) | | `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30 | | `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30 |

View File

@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off` | | `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off` |
| `/config` | 查看当前配置 | | `/config` | 查看当前配置 |
| `/cancel` | 中止当前正在运行的 Agent 任务 | | `/cancel` | 中止当前正在运行的 Agent 任务 |
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>` |
| `/logs` | 查看最近日志 | | `/logs` | 查看最近日志 |
| `/version` | 查看版本 | | `/version` | 查看版本 |

View File

@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
/cancel /cancel
``` ```
## steer
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入已经开始的工具可以执行完尚未开始的工具会跳过。若当前没有运行中的任务`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
```text
/steer 先处理失败的测试
```
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
## config ## config
查看或修改运行时配置。修改后立即生效,无需重启服务。 查看或修改运行时配置。修改后立即生效,无需重启服务。

View File

@@ -62,6 +62,7 @@ Others:
| `/help` | 显示命令帮助 | | `/help` | 显示命令帮助 |
| `/status` | 查看服务状态和配置 | | `/status` | 查看服务状态和配置 |
| `/cancel` | 中止当前正在运行的 Agent 任务 | | `/cancel` | 中止当前正在运行的 Agent 任务 |
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
| `/config` | 查看或修改运行时配置 | | `/config` | 查看或修改运行时配置 |
| `/skill` | 管理技能(安装、卸载、启用、禁用等) | | `/skill` | 管理技能(安装、卸载、启用、禁用等) |
| `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30 | | `/memory dream [N]` | 手动触发记忆蒸馏(默认 3 天,最大 30 |

View File

@@ -0,0 +1,229 @@
import json
import threading
from types import SimpleNamespace
from unittest.mock import Mock
from agent.protocol.agent_stream import AgentStreamExecutor
from agent.protocol.steer import (
SteerInbox,
SteerRegistry,
SteerResult,
SteerStatus,
)
from bridge.context import Context, ContextType
from channel.chat_channel import ChatChannel
class _ScriptedExecutor(AgentStreamExecutor):
def __init__(self, responses, inbox, steer_after_tool=None):
super().__init__(
agent=SimpleNamespace(),
model=SimpleNamespace(model="test-model"),
system_prompt="",
tools=[],
max_turns=8,
messages=[],
steer_inbox=inbox,
)
self.responses = list(responses)
self.executed = []
self.steer_after_tool = steer_after_tool
def _is_thinking_enabled(self):
return False
def _trim_messages(self):
return None
def _validate_and_fix_messages(self):
return None
def _call_llm_stream(self, retry_on_empty=True):
text, tool_calls, callback = self.responses.pop(0)
content = []
if text:
content.append({"type": "text", "text": text})
content.extend({
"type": "tool_use",
"id": call["id"],
"name": call["name"],
"input": call.get("arguments", {}),
} for call in tool_calls)
self.messages.append({"role": "assistant", "content": content})
if callback:
callback()
return text, tool_calls
def _execute_tool(self, tool_call):
self.executed.append(tool_call["name"])
if self.steer_after_tool == tool_call["name"]:
self.steer_inbox.submit("use the new target")
return {
"status": "success",
"result": f"finished {tool_call['name']}",
"execution_time": 0.01,
}
def _tool(name):
return {"id": f"call-{name}", "name": name, "arguments": {}}
def _blocks(messages, block_type):
return [
block
for message in messages
for block in (message.get("content") or [])
if isinstance(block, dict) and block.get("type") == block_type
]
def test_registry_accepts_only_one_active_run_and_preserves_order():
registry = SteerRegistry()
inbox = registry.register("research::session")
assert registry.submit("other::session", "ignored").status == SteerStatus.INACTIVE
assert registry.submit("research::session", "first").accepted
assert registry.submit("research::session", "second").accepted
assert inbox.drain() == ["first", "second"]
second = registry.register("research::session")
assert registry.submit("research::session", "ambiguous").status == SteerStatus.AMBIGUOUS
registry.unregister("research::session", second)
registry.unregister("research::session", inbox)
assert registry.submit("research::session", "late").status == SteerStatus.INACTIVE
def test_inbox_bounds_and_atomic_close_gate():
inbox = SteerInbox(max_pending=1, max_chars=5)
assert inbox.submit("").status == SteerStatus.INVALID
assert inbox.submit("123456").status == SteerStatus.INVALID
assert inbox.submit("first").accepted
assert inbox.submit("again").status == SteerStatus.FULL
assert not inbox.close_if_empty()
assert inbox.drain() == ["first"]
assert inbox.close_if_empty()
assert inbox.submit("late").status == SteerStatus.CLOSING
def test_steer_arriving_during_model_skips_all_proposed_tools():
inbox = SteerInbox()
executor = _ScriptedExecutor([
("old plan", [_tool("one"), _tool("two")], lambda: inbox.submit("change course")),
("new answer", [], None),
], inbox)
assert executor.run_stream("start") == "new answer"
assert executor.executed == []
results = _blocks(executor.messages, "tool_result")
assert {block["tool_use_id"] for block in results} == {"call-one", "call-two"}
assert all(block.get("is_error") for block in results)
assert "change course" in "\n".join(
block["text"] for block in _blocks(executor.messages, "text")
)
def test_steer_between_tools_keeps_completed_result_and_skips_remaining_tool():
inbox = SteerInbox()
executor = _ScriptedExecutor([
("", [_tool("one"), _tool("two")], None),
("redirected answer", [], None),
], inbox, steer_after_tool="one")
assert executor.run_stream("start") == "redirected answer"
assert executor.executed == ["one"]
results = {block["tool_use_id"]: block for block in _blocks(executor.messages, "tool_result")}
assert not results["call-one"].get("is_error", False)
assert results["call-two"]["is_error"] is True
def _fake_agent_bridge(result):
bridge = SimpleNamespace(
steer_session=Mock(return_value=result),
)
return bridge, SimpleNamespace(get_agent_bridge=lambda: bridge)
def test_chat_steer_command_bypasses_the_normal_queue(monkeypatch):
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
channel = object.__new__(ChatChannel)
channel.sessions = {}
channel.lock = threading.Lock()
channel._send_reply = Mock()
context = Context(ContextType.TEXT, "/steer focus on tests", {
"session_id": "session",
})
ChatChannel.produce(channel, context)
assert channel.sessions == {}
bridge.steer_session.assert_called_once_with("session", "focus on tests")
reply_text = channel._send_reply.call_args.args[1].content
assert "redirect" in reply_text.lower() or "已引导" in reply_text
def test_ordinary_chat_message_keeps_using_the_session_queue(monkeypatch):
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr("channel.chat_channel.conf", lambda: {
"concurrency_in_session": 1,
})
channel = object.__new__(ChatChannel)
channel.sessions = {}
channel.lock = threading.Lock()
context = Context(ContextType.TEXT, "ordinary message", {
"session_id": "session",
})
ChatChannel.produce(channel, context)
assert list(channel.sessions) == ["session"]
assert channel.sessions["session"][0].get() is context
def test_web_steer_button_payload_is_handled_inline(monkeypatch):
from channel.web import web_channel
bridge, factory = _fake_agent_bridge(SteerResult(SteerStatus.ACCEPTED))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
"session_id": "session",
"message": "focus on tests",
"steer": True,
"lang": "en",
}).encode())
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
instance = object.__new__(raw_class)
response = json.loads(raw_class.post_message(instance))
assert response == {
"status": "success",
"request_id": "",
"stream": False,
"steered": True,
"inline_reply": "↪️ Active task redirected.",
}
bridge.steer_session.assert_called_once_with("session", "focus on tests")
def test_web_steer_does_not_start_a_run_when_session_is_idle(monkeypatch):
from channel.web import web_channel
_, factory = _fake_agent_bridge(SteerResult(SteerStatus.INACTIVE))
monkeypatch.setattr("bridge.bridge.Bridge", lambda: factory)
monkeypatch.setattr(web_channel.web, "data", lambda: json.dumps({
"session_id": "idle",
"message": "/steer change course",
"stream": True,
"lang": "en",
}).encode())
raw_class = web_channel.WebChannel.__closure__[0].cell_contents
instance = object.__new__(raw_class)
response = json.loads(raw_class.post_message(instance))
assert response["stream"] is False
assert response["steered"] is False
assert response["inline_reply"] == "No active task to steer."