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.
|
||||
# IM channels key on session_id (no per-turn request_id here).
|
||||
from agent.protocol import get_cancel_registry
|
||||
from agent.protocol import get_cancel_registry, get_steer_registry
|
||||
registry = get_cancel_registry()
|
||||
steer_registry = get_steer_registry()
|
||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||
steer_inbox = (
|
||||
steer_registry.register(session_id)
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
|
||||
executor = AgentStreamExecutor(
|
||||
agent=agent,
|
||||
@@ -197,6 +203,7 @@ class ChatService:
|
||||
messages=messages_copy,
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -217,6 +224,8 @@ class ChatService:
|
||||
registry.unregister(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
steer_registry.unregister(session_id, steer_inbox)
|
||||
|
||||
# Sync executor messages back to agent (thread-safe).
|
||||
# The executor may have trimmed context, making its list shorter than
|
||||
|
||||
@@ -8,6 +8,13 @@ from .cancel import (
|
||||
CancelTokenRegistry,
|
||||
get_cancel_registry,
|
||||
)
|
||||
from .steer import (
|
||||
SteerInbox,
|
||||
SteerRegistry,
|
||||
SteerResult,
|
||||
SteerStatus,
|
||||
get_steer_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
@@ -25,4 +32,9 @@ __all__ = [
|
||||
'AgentCancelledError',
|
||||
'CancelTokenRegistry',
|
||||
'get_cancel_registry',
|
||||
'SteerInbox',
|
||||
'SteerRegistry',
|
||||
'SteerResult',
|
||||
'SteerStatus',
|
||||
'get_steer_registry',
|
||||
]
|
||||
|
||||
@@ -381,7 +381,7 @@ class Agent:
|
||||
return action
|
||||
|
||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
||||
skill_filter=None, cancel_event=None) -> str:
|
||||
skill_filter=None, cancel_event=None, steer_inbox=None) -> str:
|
||||
"""
|
||||
Execute single agent task with streaming (based on tool-call)
|
||||
|
||||
@@ -391,6 +391,7 @@ class Agent:
|
||||
- Event callbacks
|
||||
- Persistent conversation history across calls
|
||||
- User-initiated cancellation via ``cancel_event``
|
||||
- Explicit active-turn guidance via ``steer_inbox``
|
||||
|
||||
Args:
|
||||
user_message: User message
|
||||
@@ -403,6 +404,8 @@ class Agent:
|
||||
"[Interrupted by user]" assistant note, and returns the
|
||||
partial response. ``messages`` stays in a valid state
|
||||
(tool_use/tool_result pairs preserved).
|
||||
steer_inbox: Optional SteerInbox drained at safe checkpoints. New
|
||||
instructions guide this run without entering the normal queue.
|
||||
|
||||
Returns:
|
||||
Final response text
|
||||
@@ -448,6 +451,7 @@ class Agent:
|
||||
messages=messages_copy, # Pass copied message history
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
# Execute
|
||||
@@ -484,4 +488,4 @@ class Agent:
|
||||
def clear_history(self):
|
||||
"""Clear conversation history and captured actions"""
|
||||
self.messages = []
|
||||
self.captured_actions = []
|
||||
self.captured_actions = []
|
||||
|
||||
@@ -99,6 +99,7 @@ class AgentStreamExecutor:
|
||||
messages: Optional[List[Dict]] = None,
|
||||
max_context_turns: int = 30,
|
||||
cancel_event=None,
|
||||
steer_inbox=None,
|
||||
):
|
||||
"""
|
||||
Initialize stream executor
|
||||
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
|
||||
Checked at every safe point (turn boundary, before tool execution,
|
||||
during LLM streaming). When set, raises AgentCancelledError which
|
||||
run_stream catches to gracefully wind down.
|
||||
steer_inbox: Optional SteerInbox for explicit instructions sent to
|
||||
this active run. Drained only at message-safe checkpoints.
|
||||
"""
|
||||
self.agent = agent
|
||||
self.model = model
|
||||
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
|
||||
self.on_event = on_event
|
||||
self.max_context_turns = max_context_turns
|
||||
self.cancel_event = cancel_event
|
||||
self.steer_inbox = steer_inbox
|
||||
|
||||
# Message history - use provided messages or create new list
|
||||
self.messages = messages if messages is not None else []
|
||||
@@ -145,6 +149,72 @@ class AgentStreamExecutor:
|
||||
if self.cancel_event is not None and self.cancel_event.is_set():
|
||||
raise AgentCancelledError("agent cancelled by user")
|
||||
|
||||
def _drain_steering(self) -> List[str]:
|
||||
if self.steer_inbox is None:
|
||||
return []
|
||||
return self.steer_inbox.drain()
|
||||
|
||||
@staticmethod
|
||||
def _steering_text(updates: List[str]) -> str:
|
||||
if len(updates) == 1:
|
||||
body = updates[0]
|
||||
else:
|
||||
body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1))
|
||||
return (
|
||||
"[Steering update for the active task]\n"
|
||||
"Use this new instruction for the current task before continuing.\n\n"
|
||||
f"{body}"
|
||||
)
|
||||
|
||||
def _append_steering(
|
||||
self,
|
||||
updates: List[str],
|
||||
pending_tool_calls: Optional[List[Dict]] = None,
|
||||
content_blocks: Optional[List[Dict]] = None,
|
||||
) -> None:
|
||||
"""Append guidance, closing any tool_use blocks that will be skipped."""
|
||||
if not updates:
|
||||
return
|
||||
blocks = content_blocks if content_blocks is not None else []
|
||||
for tool_call in pending_tool_calls or []:
|
||||
blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call["id"],
|
||||
"content": "Skipped because the user redirected the active task.",
|
||||
"is_error": True,
|
||||
})
|
||||
blocks.append({"type": "text", "text": self._steering_text(updates)})
|
||||
if content_blocks is None:
|
||||
self.messages.append({"role": "user", "content": blocks})
|
||||
self._emit_event("agent_steered", {"count": len(updates)})
|
||||
logger.info(f"[Agent] Applied {len(updates)} steering update(s)")
|
||||
|
||||
def _close_or_apply_final_steering(self) -> bool:
|
||||
"""Return True only when the run can finish without losing a steer."""
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
if self.steer_inbox is None:
|
||||
return True
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return True
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
|
||||
def _drain_and_close_steering(self) -> None:
|
||||
"""Preserve any final guidance before the max-step summary call."""
|
||||
if self.steer_inbox is None:
|
||||
return
|
||||
while True:
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return
|
||||
|
||||
def _handle_cancelled(self, partial_response: str) -> None:
|
||||
"""Wind down ``self.messages`` after a user-initiated cancel.
|
||||
|
||||
@@ -395,6 +465,10 @@ class AgentStreamExecutor:
|
||||
# between turns short-circuits cleanly.
|
||||
self._check_cancelled()
|
||||
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
|
||||
turn += 1
|
||||
logger.info(f"[Agent] Turn {turn}")
|
||||
self._emit_event("turn_start", {"turn": turn})
|
||||
@@ -403,6 +477,24 @@ class AgentStreamExecutor:
|
||||
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
||||
final_response = assistant_msg
|
||||
|
||||
# A steer that arrived while the model was streaming takes
|
||||
# precedence over its proposed continuation. Tool calls have
|
||||
# already been written to history, so close every one with a
|
||||
# synthetic result before asking the model to reconsider.
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls,
|
||||
)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": bool(tool_calls),
|
||||
"tool_count": len(tool_calls),
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
|
||||
# No tool calls, end loop
|
||||
if not tool_calls:
|
||||
# 检查是否返回了空响应
|
||||
@@ -467,6 +559,22 @@ class AgentStreamExecutor:
|
||||
# If the explicit-response retry produced tool_calls, skip the break
|
||||
# and continue down to the tool execution branch in this same iteration.
|
||||
if not tool_calls:
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
if not self._close_or_apply_final_steering():
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
logger.debug(f"✅ Done (no tool calls)")
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
@@ -499,9 +607,17 @@ class AgentStreamExecutor:
|
||||
tool_result_blocks = []
|
||||
|
||||
try:
|
||||
for tool_call in tool_calls:
|
||||
for tool_index, tool_call in enumerate(tool_calls):
|
||||
# Honour cancel between tool invocations within the same turn
|
||||
self._check_cancelled()
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls[tool_index:],
|
||||
content_blocks=tool_result_blocks,
|
||||
)
|
||||
break
|
||||
result = self._execute_tool(tool_call)
|
||||
tool_results.append(result)
|
||||
|
||||
@@ -641,6 +757,7 @@ class AgentStreamExecutor:
|
||||
|
||||
if turn >= self.max_turns:
|
||||
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
|
||||
self._drain_and_close_steering()
|
||||
|
||||
# Force model to summarize without tool calls
|
||||
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
||||
@@ -699,6 +816,8 @@ class AgentStreamExecutor:
|
||||
raise
|
||||
|
||||
finally:
|
||||
if self.steer_inbox is not None:
|
||||
self.steer_inbox.close()
|
||||
final_response = final_response.strip() if final_response else final_response
|
||||
if cancelled:
|
||||
# Emit before agent_end so channels can mark UI as cancelled
|
||||
|
||||
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
|
||||
from typing import Optional, List
|
||||
|
||||
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
|
||||
from agent.protocol import (
|
||||
Agent,
|
||||
LLMModel,
|
||||
LLMRequest,
|
||||
get_cancel_registry,
|
||||
get_steer_registry,
|
||||
)
|
||||
from bridge.agent_event_handler import AgentEventHandler
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
from bridge.bridge import Bridge
|
||||
@@ -360,6 +366,10 @@ class AgentBridge:
|
||||
|
||||
return agent
|
||||
|
||||
def steer_session(self, session_id: str, instruction: str):
|
||||
"""Inject an explicit instruction into one active session."""
|
||||
return get_steer_registry().submit(session_id, instruction)
|
||||
|
||||
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
||||
"""
|
||||
Get agent instance for the given session
|
||||
@@ -452,6 +462,8 @@ class AgentBridge:
|
||||
agent = None
|
||||
request_id = None
|
||||
cancel_event = None
|
||||
token_key = None
|
||||
steer_inbox = None
|
||||
try:
|
||||
# Extract session_id from context for user isolation
|
||||
if context:
|
||||
@@ -534,12 +546,15 @@ class AgentBridge:
|
||||
pass
|
||||
|
||||
try:
|
||||
if session_id:
|
||||
steer_inbox = get_steer_registry().register(session_id)
|
||||
# Use agent's run_stream method with event handler
|
||||
response = agent.run_stream(
|
||||
user_message=query,
|
||||
on_event=event_handler.handle_event,
|
||||
clear_history=clear_history,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
finally:
|
||||
# Clear the mid-run flag so idle scans can review this session.
|
||||
@@ -562,6 +577,8 @@ class AgentBridge:
|
||||
registry.unregister(token_key)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
|
||||
# Persist new messages generated during this run
|
||||
if session_id:
|
||||
@@ -643,6 +660,11 @@ class AgentBridge:
|
||||
get_cancel_registry().unregister(request_id or session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
try:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
except Exception:
|
||||
pass
|
||||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||
|
||||
def _schedule_mcp_hot_reload(self, agent):
|
||||
|
||||
@@ -453,6 +453,10 @@ class ChatChannel(Channel):
|
||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||
self._handle_cancel_command(context, session_id)
|
||||
return
|
||||
if re.match(r"^/steer(?:\s|$)", stripped):
|
||||
instruction = context.content.strip()[len("/steer"):].strip()
|
||||
self._handle_steer_command(context, session_id, instruction)
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||
|
||||
def _handle_steer_command(
|
||||
self,
|
||||
context: Context,
|
||||
session_id: str,
|
||||
instruction: str,
|
||||
) -> None:
|
||||
"""Send explicit guidance to the active run without queueing it."""
|
||||
try:
|
||||
from agent.protocol import SteerStatus
|
||||
from bridge.bridge import Bridge
|
||||
|
||||
result = Bridge().get_agent_bridge().steer_session(session_id, instruction)
|
||||
messages = {
|
||||
SteerStatus.ACCEPTED: _t(
|
||||
"↪️ 已引导当前任务。", "↪️ Active task redirected."
|
||||
),
|
||||
SteerStatus.INACTIVE: _t(
|
||||
"当前没有可引导的任务。", "No active task to steer."
|
||||
),
|
||||
SteerStatus.CLOSING: _t(
|
||||
"当前任务已结束,无法再引导。", "The active task is already finishing."
|
||||
),
|
||||
SteerStatus.AMBIGUOUS: _t(
|
||||
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||
),
|
||||
SteerStatus.FULL: _t(
|
||||
"引导指令过多,请等待当前任务处理后再试。",
|
||||
"Too many steering updates are pending; try again after the agent processes them.",
|
||||
),
|
||||
SteerStatus.INVALID: _t(
|
||||
"用法:/steer <引导指令>", "Usage: /steer <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):
|
||||
while True:
|
||||
|
||||
@@ -42,6 +42,7 @@ TELEGRAM_BOT_COMMANDS = [
|
||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||
("config", "Show current config"),
|
||||
("cancel", "Cancel running agent task"),
|
||||
("steer", "Guide the running agent task"),
|
||||
("logs", "Show recent logs"),
|
||||
("version", "Show version"),
|
||||
]
|
||||
|
||||
@@ -485,6 +485,20 @@
|
||||
<i class="fas fa-microphone text-sm"></i>
|
||||
</button>
|
||||
</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"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
|
||||
@@ -123,6 +123,8 @@ const I18N = {
|
||||
slash_knowledge_off: '关闭知识库',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_steer: '向当前正在运行的 Agent 任务注入引导指令',
|
||||
steer_active: '引导当前任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
input_placeholder: '输入消息,或输入 / 使用指令',
|
||||
@@ -375,6 +377,8 @@ const I18N = {
|
||||
slash_knowledge_off: '關閉知識庫',
|
||||
slash_config: '檢視當前設定',
|
||||
slash_cancel: '中止當前正在執行的 Agent 任務',
|
||||
slash_steer: '向當前正在執行的 Agent 任務注入引導指令',
|
||||
steer_active: '引導當前任務',
|
||||
slash_logs: '檢視最近日誌',
|
||||
slash_version: '檢視版本',
|
||||
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
||||
@@ -626,6 +630,8 @@ const I18N = {
|
||||
slash_knowledge_off: 'Disable knowledge base',
|
||||
slash_config: 'Show current config',
|
||||
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_version: 'Show version',
|
||||
input_placeholder: 'Type a message, or press / for commands',
|
||||
@@ -816,6 +822,9 @@ function applyI18n() {
|
||||
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||
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 => {
|
||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||
});
|
||||
@@ -1415,6 +1424,7 @@ startPolling();
|
||||
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const steerBtn = document.getElementById('steer-btn');
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const folderInput = document.getElementById('folder-input');
|
||||
@@ -1749,6 +1759,7 @@ function setSendBtnCancelMode(requestId) {
|
||||
sendBtn.classList.add('send-btn-cancel');
|
||||
sendBtn.title = (currentLang === 'zh' ? '中止' : 'Cancel');
|
||||
sendBtn.innerHTML = '<i class="fas fa-stop text-sm"></i>';
|
||||
updateSteerBtnState();
|
||||
}
|
||||
|
||||
function resetSendBtnSendMode() {
|
||||
@@ -1757,9 +1768,61 @@ function resetSendBtnSendMode() {
|
||||
sendBtn.classList.remove('send-btn-cancel');
|
||||
sendBtn.title = '';
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane text-sm"></i>';
|
||||
steerBtn.classList.add('hidden');
|
||||
steerBtn.classList.remove('flex');
|
||||
steerBtn.disabled = true;
|
||||
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() {
|
||||
const reqId = activeRequestId;
|
||||
if (!reqId) return;
|
||||
@@ -1795,10 +1858,12 @@ function updateSendBtnState() {
|
||||
resetSendBtnSendMode();
|
||||
} else {
|
||||
// Don't downgrade a genuinely active Cancel button on input edits.
|
||||
updateSteerBtnState();
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendBtn.disabled = uploadingCount > 0 || (!chatInput.value.trim() && pendingAttachments.length === 0);
|
||||
updateSteerBtnState();
|
||||
}
|
||||
|
||||
function renderAttachmentPreview() {
|
||||
@@ -2117,6 +2182,7 @@ const SLASH_COMMANDS = [
|
||||
{ cmd: '/knowledge off', desc: 'slash_knowledge_off' },
|
||||
{ cmd: '/config', desc: 'slash_config' },
|
||||
{ cmd: '/cancel', desc: 'slash_cancel' },
|
||||
{ cmd: '/steer ', desc: 'slash_steer' },
|
||||
{ cmd: '/logs', desc: 'slash_logs' },
|
||||
{ cmd: '/version', desc: 'slash_version' },
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -134,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
||||
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:
|
||||
from common.utils import expand_path
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
@@ -912,6 +943,37 @@ class WebChannel(ChatChannel):
|
||||
"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)
|
||||
if attachments:
|
||||
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`) |
|
||||
| `/config` | View current config |
|
||||
| `/cancel` | Cancel the running Agent task |
|
||||
| `/steer` | Guide the running Agent task (`/steer <instruction>`) |
|
||||
| `/logs` | View recent logs |
|
||||
| `/version` | Show version |
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ Abort the agent task currently running in this session. When the agent is busy w
|
||||
/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
|
||||
|
||||
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 |
|
||||
| `/status` | View service status and configuration |
|
||||
| `/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 |
|
||||
| `/skill` | Manage skills (install, uninstall, enable, disable, etc.) |
|
||||
| `/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`) |
|
||||
| `/config` | 現在の設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中断 |
|
||||
| `/steer` | 実行中の Agent タスクを方向修正(`/steer <指示>`) |
|
||||
| `/logs` | 最近のログを表示 |
|
||||
| `/version` | バージョンを表示 |
|
||||
|
||||
|
||||
@@ -33,6 +33,16 @@ description: ステータスの確認、設定管理、コンテキスト制御
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
現在のセッションで実行中の Agent タスクを、中止せずに方向修正します。指示は次の安全なチェックポイントで注入されます。すでに実行中のツールは完了することがありますが、まだ開始していないツールはスキップされます。実行中のタスクがない場合、`/steer` は新しいタスクを開始せず、キューにも追加しません。すべてのチャットチャネルで利用できます。
|
||||
|
||||
```text
|
||||
/steer 失敗しているテストを先に確認して
|
||||
```
|
||||
|
||||
Web コンソールでは、応答の実行中に指示を入力して「Steer active task」ボタンを押します。通常のメッセージは引き続きセッションキューに入ります。
|
||||
|
||||
## config
|
||||
|
||||
実行時設定の表示または変更を行います。変更は即座に反映され、再起動は不要です。
|
||||
|
||||
@@ -58,6 +58,7 @@ Web コンソールや接続されたチャネルの会話で `/` を入力す
|
||||
| `/help` | コマンドヘルプを表示 |
|
||||
| `/status` | サービスの状態と設定を表示 |
|
||||
| `/cancel` | 実行中の Agent タスクを中止 |
|
||||
| `/steer <指示>` | 新しいターンをキューに追加せず、実行中の Agent タスクを方向修正 |
|
||||
| `/config` | 実行時設定の表示・変更 |
|
||||
| `/skill` | スキル管理(インストール、アンインストール、有効化、無効化など) |
|
||||
| `/memory dream [N]` | 記憶蒸留を手動トリガー(デフォルト 3 日、最大 30) |
|
||||
|
||||
@@ -95,6 +95,7 @@ description: 将 CowAgent 接入 Telegram Bot
|
||||
| `/knowledge` | 知识库管理(`/knowledge list` / `on` / `off`) |
|
||||
| `/config` | 查看当前配置 |
|
||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||
| `/steer` | 引导当前正在运行的 Agent 任务(`/steer <指令>`) |
|
||||
| `/logs` | 查看最近日志 |
|
||||
| `/version` | 查看版本 |
|
||||
|
||||
|
||||
@@ -47,6 +47,16 @@ Session: 12 messages | 8 skills loaded
|
||||
/cancel
|
||||
```
|
||||
|
||||
## steer
|
||||
|
||||
在不中止任务的情况下,引导当前会话里正在运行的 Agent。指令会在下一个安全检查点注入:已经开始的工具可以执行完,尚未开始的工具会跳过。若当前没有运行中的任务,`/steer` 不会新建任务,也不会进入队列。所有聊天渠道均可使用。
|
||||
|
||||
```text
|
||||
/steer 先处理失败的测试
|
||||
```
|
||||
|
||||
Web 控制台在回复进行中会显示“引导当前任务”按钮。普通消息仍按原有方式进入会话队列。
|
||||
|
||||
## config
|
||||
|
||||
查看或修改运行时配置。修改后立即生效,无需重启服务。
|
||||
|
||||
@@ -62,6 +62,7 @@ Others:
|
||||
| `/help` | 显示命令帮助 |
|
||||
| `/status` | 查看服务状态和配置 |
|
||||
| `/cancel` | 中止当前正在运行的 Agent 任务 |
|
||||
| `/steer <指令>` | 引导当前正在运行的 Agent 任务,不新建排队回合 |
|
||||
| `/config` | 查看或修改运行时配置 |
|
||||
| `/skill` | 管理技能(安装、卸载、启用、禁用等) |
|
||||
| `/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