mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-21 22:27:13 +08:00
feat(agent): add explicit active-turn steering
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
127
agent/protocol/steer.py
Normal 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
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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` | バージョンを表示 |
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 失敗しているテストを先に確認して
|
||||||
|
```
|
||||||
|
|
||||||
|
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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` | 查看版本 |
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
|
|||||||
/cancel
|
/cancel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## steer
|
||||||
|
|
||||||
|
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入:已经开始的工具可以执行完,尚未开始的工具会跳过。若当前没有运行中的任务,`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
|
||||||
|
|
||||||
|
```text
|
||||||
|
/steer 先处理失败的测试
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
|
||||||
|
|
||||||
## config
|
## config
|
||||||
|
|
||||||
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
229
tests/test_agent_steering.py
Normal file
229
tests/test_agent_steering.py
Normal 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."
|
||||||
Reference in New Issue
Block a user