mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
2539 lines
112 KiB
Python
2539 lines
112 KiB
Python
"""
|
||
Agent Bridge - Integrates Agent system with existing COW bridge
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import threading
|
||
import uuid
|
||
from typing import Dict, Iterator, Optional, List, Tuple
|
||
|
||
from agent.protocol import (
|
||
Agent,
|
||
LLMModel,
|
||
LLMRequest,
|
||
get_cancel_registry,
|
||
get_steer_registry,
|
||
)
|
||
from agent.protocol.step_writer import StepWriter
|
||
from bridge.agent_event_handler import AgentEventHandler
|
||
from bridge.agent_initializer import AgentInitializer
|
||
from bridge.bridge import Bridge
|
||
from bridge.context import Context
|
||
from bridge.reply import Reply, ReplyType
|
||
from common import const
|
||
from common.log import logger
|
||
from common.utils import expand_path
|
||
from config import conf
|
||
from models.openai_compatible_bot import OpenAICompatibleBot
|
||
|
||
|
||
def add_openai_compatible_support(bot_instance):
|
||
"""
|
||
Dynamically add OpenAI-compatible tool calling support to a bot instance.
|
||
|
||
This allows any bot to gain tool calling capability without modifying its code,
|
||
as long as it uses OpenAI-compatible API format.
|
||
|
||
Note: Some bots like ZHIPUAIBot have native tool calling support and don't need enhancement.
|
||
"""
|
||
if hasattr(bot_instance, 'call_with_tools'):
|
||
# Bot already has tool calling support (e.g., ZHIPUAIBot)
|
||
logger.debug(f"[AgentBridge] {type(bot_instance).__name__} already has native tool calling support")
|
||
return bot_instance
|
||
|
||
# Create a temporary mixin class that combines the bot with OpenAI compatibility
|
||
class EnhancedBot(bot_instance.__class__, OpenAICompatibleBot):
|
||
"""Dynamically enhanced bot with OpenAI-compatible tool calling"""
|
||
|
||
def get_api_config(self):
|
||
"""
|
||
Infer API config from common configuration patterns.
|
||
Most OpenAI-compatible bots use similar configuration.
|
||
"""
|
||
from config import conf
|
||
|
||
return {
|
||
'api_key': conf().get("open_ai_api_key"),
|
||
'api_base': conf().get("open_ai_api_base"),
|
||
'model': conf().get("model") or const.DEFAULT_MODEL,
|
||
'default_temperature': conf().get("temperature", 0.9),
|
||
'default_top_p': conf().get("top_p", 1.0),
|
||
'default_frequency_penalty': conf().get("frequency_penalty", 0.0),
|
||
'default_presence_penalty': conf().get("presence_penalty", 0.0),
|
||
}
|
||
|
||
# Change the bot's class to the enhanced version
|
||
bot_instance.__class__ = EnhancedBot
|
||
logger.info(
|
||
f"[AgentBridge] Enhanced {bot_instance.__class__.__bases__[0].__name__} with OpenAI-compatible tool calling")
|
||
|
||
return bot_instance
|
||
|
||
|
||
class AgentLLMModel(LLMModel):
|
||
"""
|
||
LLM Model adapter that uses COW's existing bot infrastructure
|
||
"""
|
||
|
||
_MODEL_BOT_TYPE_MAP = {
|
||
"wenxin": const.BAIDU, "wenxin-4": const.BAIDU,
|
||
"xunfei": const.XUNFEI, const.QWEN: const.QWEN_DASHSCOPE,
|
||
const.QIANFAN: const.QIANFAN,
|
||
const.MODELSCOPE: const.MODELSCOPE,
|
||
}
|
||
_MODEL_PREFIX_MAP = [
|
||
("qwen", const.QWEN_DASHSCOPE), ("qwq", const.QWEN_DASHSCOPE), ("qvq", const.QWEN_DASHSCOPE),
|
||
("gemini", const.GEMINI), ("glm", const.ZHIPU_AI), ("claude", const.CLAUDEAPI),
|
||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||
("ernie", const.QIANFAN),
|
||
("mimo-", const.MIMO),
|
||
]
|
||
|
||
# Which model answers, most specific first. All default to None, meaning
|
||
# "follow the next one down" and ultimately the global config. Declared on
|
||
# the class so that resolving a model never depends on __init__ having run:
|
||
# these are read on every call, including from doubles built with __new__.
|
||
#
|
||
# Per-conversation, set from agent.workspace.session_prefs when the user
|
||
# picks a model for one conversation.
|
||
_session_model = None
|
||
_session_provider = None
|
||
# Per Agent, from its profile. Under the conversation's choice but above
|
||
# the global one: an Agent picked for its judgement should not answer on
|
||
# whatever the console was last set to. The default Agent never has one —
|
||
# it *is* the global choice.
|
||
_agent_model = None
|
||
_agent_provider = None
|
||
# Fallback routing, engaged by use_fallback() after the primary model
|
||
# failed a turn for good. Declared here (not in __init__) for the same
|
||
# reason as the fields above: `model` is read on every call, including on
|
||
# instances built with __new__.
|
||
#
|
||
# The fallback is a chain: `_fallback_depth` is the index of the link the
|
||
# run currently sits on, so a run that has burned through link 0 and is on
|
||
# link 1 can still advance to link 2 when that one fails too.
|
||
_fallback_model = None
|
||
_fallback_provider = None
|
||
_fallback_depth = 0
|
||
|
||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||
super().__init__(model=conf().get("model") or const.DEFAULT_MODEL)
|
||
self.bridge = bridge
|
||
self.bot_type = bot_type
|
||
self._bot = None
|
||
self._bot_model = None
|
||
|
||
@property
|
||
def model(self):
|
||
# A fallback that has been engaged outranks every normal choice: once
|
||
# the primary model has failed the turn, the whole point is to answer
|
||
# on something else.
|
||
if self._fallback_model:
|
||
return self._fallback_model
|
||
return (
|
||
self._session_model
|
||
or self._agent_model
|
||
or conf().get("model")
|
||
or const.DEFAULT_MODEL
|
||
)
|
||
|
||
@model.setter
|
||
def model(self, value):
|
||
pass
|
||
|
||
def fallback_config(self) -> dict:
|
||
"""Return the configured fallback chain, normalized.
|
||
|
||
A non-dict or disabled entry yields an empty chain so callers can treat
|
||
"not usable" as a single check. Links missing a provider or a model are
|
||
dropped — half a link could route the turn nowhere — as are duplicates
|
||
of an earlier link, which would only re-probe a model the run has
|
||
already proven is down. So is a link naming the primary's own route,
|
||
matched on provider *and* model: the same model name behind another
|
||
provider — a second account or endpoint of it — is a legitimate
|
||
backup, not a duplicate.
|
||
|
||
The chain is unbounded: however many links the user configured is how
|
||
many switches a turn gets. There is no separate cap.
|
||
"""
|
||
raw = conf().get("chat_fallback")
|
||
empty = {"chain": []}
|
||
if not isinstance(raw, dict) or not raw.get("enabled"):
|
||
return empty
|
||
raw_chain = raw.get("chain")
|
||
if not isinstance(raw_chain, list):
|
||
# Pre-chain shape ({provider, model}): config._migrate_chat_fallback
|
||
# normally upgrades it at load time, so reaching here means a
|
||
# caller handed us the raw dict. Honor it rather than dropping the
|
||
# user's backup model.
|
||
raw_chain = [raw]
|
||
primary = (self._session_model or self._agent_model
|
||
or conf().get("model") or const.DEFAULT_MODEL)
|
||
chain = []
|
||
# Seed the dedup with the primary's own route: its provider is
|
||
# resolved first (the global model config carries none), so a link
|
||
# is matched on the (provider, model) pair — not the model name
|
||
# alone, which would drop same-model links behind other providers.
|
||
seen = {(self._primary_provider_id(primary), (primary or "").strip())}
|
||
for item in raw_chain:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
provider = (item.get("provider") or "").strip()
|
||
model = (item.get("model") or "").strip()
|
||
if not provider or not model:
|
||
continue
|
||
key = (provider, model)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
chain.append({"provider": provider, "model": model})
|
||
return {"chain": chain}
|
||
|
||
def _primary_provider_id(self, primary_model: str) -> str:
|
||
"""The provider the primary model routes through, as a provider id.
|
||
|
||
Resolved in the id space the fallback chain's links are written in
|
||
("openai", "custom:<id>", ...), so the chain's dedup seed compares a
|
||
link against the route the primary actually takes rather than against
|
||
a provider-less model name. Follows the routing precedence — a session
|
||
or Agent pin, then use_linkai, the configured bot_type, and finally
|
||
the model-name inference _primary_bot_type applies when nothing is
|
||
configured ("chatGPT", the persisted spelling of "openai", maps back
|
||
onto it).
|
||
"""
|
||
if self._session_provider:
|
||
return self._session_provider
|
||
if self._agent_provider:
|
||
return self._agent_provider
|
||
bot_type = self._primary_bot_type(primary_model)
|
||
return "openai" if bot_type == const.CHATGPT else bot_type
|
||
|
||
# How many times one turn may walk the whole chain before the failure is
|
||
# reported. Two passes rather than one because a pass takes real time: by
|
||
# the time the walk comes back around to a link that was rate limited, the
|
||
# window may well have cleared. A third pass would mostly re-probe an
|
||
# outage that is not going to clear inside a single turn.
|
||
_FALLBACK_MAX_PASSES = 2
|
||
|
||
def fallback_available(self) -> bool:
|
||
"""Whether this run can still advance along the fallback chain.
|
||
|
||
True while link attempts remain inside the pass budget. Unlike the old
|
||
single-model fallback, sitting on a link is not the end: a backup that
|
||
fails for good earns the next one, which is the point of having a
|
||
chain — and reaching the last link wraps back to the first instead of
|
||
ending the turn.
|
||
"""
|
||
chain = self.fallback_config()["chain"]
|
||
return self._fallback_depth < len(chain) * self._FALLBACK_MAX_PASSES
|
||
|
||
def use_fallback(self) -> bool:
|
||
"""Advance the rest of this run onto the next fallback model.
|
||
|
||
Returns True when the switch happened. Called after the *current*
|
||
model has failed a turn for good (retries exhausted), never mid-retry —
|
||
for the primary that is the end of its own retries, and for a fallback
|
||
link the single attempt it is granted.
|
||
|
||
The switch is sticky in the sense that the run stays on whichever link
|
||
answered, so a sustained outage isn't re-probed once per step; but a
|
||
link that fails advances to the next one rather than giving up.
|
||
reset_fallback() returns the run to the primary at the start of the
|
||
next one.
|
||
"""
|
||
if not self.fallback_available():
|
||
return False
|
||
chain = self.fallback_config()["chain"]
|
||
# Wrap around: `_fallback_depth` counts attempts, not links, so the
|
||
# second pass re-tries link 0. A turn that reaches the end of the chain
|
||
# has not run out of options — the walk took long enough that a rate
|
||
# limit hit on the first pass may have cleared by now.
|
||
link = chain[self._fallback_depth % len(chain)]
|
||
self._fallback_provider = link["provider"]
|
||
self._fallback_model = link["model"]
|
||
self._fallback_depth += 1
|
||
# Drop the cached primary bot; `bot` rebuilds it for the new routing.
|
||
self._bot = None
|
||
self._bot_model = None
|
||
self._bot_type = None
|
||
total = len(chain) * self._FALLBACK_MAX_PASSES
|
||
logger.warning(
|
||
"[AgentLLMModel] current model failed; falling back to "
|
||
f"{link['provider']}/{link['model']} "
|
||
f"(link {self._fallback_depth}/{total})"
|
||
)
|
||
return True
|
||
|
||
def reset_fallback(self) -> None:
|
||
"""Return to the primary model — call once at the start of a run.
|
||
|
||
A new user message always starts fresh on the primary; within a run the
|
||
fallback stays engaged on whichever link answered (see use_fallback).
|
||
Rewinding the chain index here — not mid-run — is what lets the *next*
|
||
run walk the chain again from the front.
|
||
"""
|
||
if self._fallback_model is None:
|
||
return
|
||
self._fallback_model = None
|
||
self._fallback_provider = None
|
||
# Back to the front of the chain: the next run starts on the primary
|
||
# model, and if it fails again it is a new failure that earns a new
|
||
# walk through the links.
|
||
self._fallback_depth = 0
|
||
self._bot = None
|
||
self._bot_model = None
|
||
self._bot_type = None
|
||
|
||
def set_agent_default(self, provider: Optional[str], model: Optional[str]) -> None:
|
||
"""Pin the Agent's own model, under any per-conversation choice."""
|
||
provider = (provider or "").strip() or None
|
||
model = (model or "").strip() or None
|
||
if provider == self._agent_provider and model == self._agent_model:
|
||
return
|
||
self._agent_provider = provider
|
||
self._agent_model = model
|
||
self._bot = None
|
||
self._bot_model = None
|
||
self._bot_type = None
|
||
|
||
def set_session_override(self, provider: Optional[str], model: Optional[str]) -> None:
|
||
"""Pin this session to one model/provider, or clear it with None/None.
|
||
|
||
The provider matters as much as the model: without it a session that
|
||
switches from DeepSeek to Claude would keep routing through the globally
|
||
configured bot type and ask DeepSeek for a Claude model.
|
||
"""
|
||
provider = (provider or "").strip() or None
|
||
model = (model or "").strip() or None
|
||
if provider == self._session_provider and model == self._session_model:
|
||
return
|
||
self._session_provider = provider
|
||
self._session_model = model
|
||
# Force the lazy bot to be rebuilt for the new routing on the next call.
|
||
self._bot = None
|
||
self._bot_model = None
|
||
self._bot_type = None
|
||
|
||
def catalog_model_meta(self) -> dict:
|
||
"""Model-catalog metadata for the effective provider+model, or {}.
|
||
|
||
The provider mirrors ``_resolve_bot_type`` precedence (session
|
||
override, then use_linkai, then the configured bot type), mapped back
|
||
onto the UI provider ids the catalog is keyed by."""
|
||
from models import model_catalog
|
||
if self._session_provider:
|
||
provider = self._session_provider
|
||
elif conf().get("use_linkai", False) and conf().get("linkai_api_key"):
|
||
provider = "linkai"
|
||
else:
|
||
bot_type = conf().get("bot_type") or ""
|
||
provider = "openai" if bot_type == const.CHATGPT else bot_type
|
||
return model_catalog.resolve_model_meta(provider, self.model)
|
||
|
||
@staticmethod
|
||
def provider_to_bot_type(provider_id: str) -> str:
|
||
"""Map a UI provider id onto a bot type, as the models console does."""
|
||
if not provider_id:
|
||
return ""
|
||
# Same mapping the models console persists: "openai" routes through the
|
||
# OpenAI-compatible bot, not the legacy completion one.
|
||
if provider_id == "openai":
|
||
return const.CHATGPT
|
||
return provider_id
|
||
|
||
def _resolve_bot_type(self, model_name: str) -> str:
|
||
"""Resolve bot type from model name, matching Bridge.__init__ logic."""
|
||
# An engaged fallback outranks every normal choice: the whole point of
|
||
# the fallback is to leave whichever provider just failed, and the
|
||
# model being requested (`self.model`) is already the fallback's own.
|
||
if self._fallback_provider:
|
||
return self.provider_to_bot_type(self._fallback_provider)
|
||
return self._primary_bot_type(model_name)
|
||
|
||
def _primary_bot_type(self, model_name: str) -> str:
|
||
"""Bot type the primary model routes through, ignoring any engaged fallback.
|
||
|
||
The tail _resolve_bot_type delegates to; split out so the fallback
|
||
chain can seed its dedup with the primary's route without an engaged
|
||
fallback's provider leaking in.
|
||
"""
|
||
# A session override wins over every global routing switch, including
|
||
# use_linkai: the user picked this provider for this conversation.
|
||
if self._session_provider:
|
||
return self.provider_to_bot_type(self._session_provider)
|
||
if self._agent_provider:
|
||
return self.provider_to_bot_type(self._agent_provider)
|
||
|
||
if conf().get("use_linkai", False) and conf().get("linkai_api_key"):
|
||
return const.LINKAI
|
||
# Support custom bot type configuration
|
||
configured_bot_type = conf().get("bot_type")
|
||
if configured_bot_type:
|
||
return configured_bot_type
|
||
|
||
if not model_name or not isinstance(model_name, str):
|
||
return const.OPENAI
|
||
if model_name in self._MODEL_BOT_TYPE_MAP:
|
||
return self._MODEL_BOT_TYPE_MAP[model_name]
|
||
if model_name.lower().startswith("minimax") or model_name in ["abab6.5-chat"]:
|
||
return const.MiniMax
|
||
if model_name in [const.QWEN_TURBO, const.QWEN_PLUS, const.QWEN_MAX]:
|
||
return const.QWEN_DASHSCOPE
|
||
if model_name in [const.MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"]:
|
||
return const.MOONSHOT
|
||
if conf().get("bot_type") == "modelscope":
|
||
return const.MODELSCOPE
|
||
lowered_model = model_name.lower()
|
||
for prefix, btype in self._MODEL_PREFIX_MAP:
|
||
if lowered_model.startswith(prefix):
|
||
return btype
|
||
return const.OPENAI
|
||
|
||
def _normalized_reasoning_effort(self):
|
||
"""Return the active model's effort value after config resolution."""
|
||
from models.reasoning_capabilities import resolve_reasoning_effort
|
||
|
||
return resolve_reasoning_effort(
|
||
self._resolve_bot_type(self.model),
|
||
self.model,
|
||
conf().get("reasoning_effort_by_model", {}),
|
||
conf().get("reasoning_effort", "high"),
|
||
)
|
||
|
||
def _is_thinking_only_model(self) -> bool:
|
||
"""Return True for models that require reasoning to stay enabled."""
|
||
from models.reasoning_capabilities import get_reasoning_capability
|
||
|
||
capability = get_reasoning_capability(
|
||
self._resolve_bot_type(self.model),
|
||
self.model,
|
||
)
|
||
return bool(capability.get("thinking_only"))
|
||
|
||
@property
|
||
def bot(self):
|
||
"""Lazy load the bot, re-create when model or bot_type changes"""
|
||
from models.bot_factory import create_bot
|
||
cur_model = self.model
|
||
cur_bot_type = self._resolve_bot_type(cur_model)
|
||
if self._bot is None or self._bot_model != cur_model or getattr(self, '_bot_type', None) != cur_bot_type:
|
||
# Hand the resolved type to create_bot as the credential provider
|
||
# too. cur_bot_type already encodes the engaged fallback / session
|
||
# override, and the bot must resolve api_key+api_base from *that*
|
||
# provider — reading the global bot_type instead would pair this
|
||
# link's model id with the primary provider's endpoint.
|
||
self._bot = create_bot(cur_bot_type, credential_bot_type=cur_bot_type)
|
||
self._bot = add_openai_compatible_support(self._bot)
|
||
self._bot_model = cur_model
|
||
self._bot_type = cur_bot_type
|
||
return self._bot
|
||
|
||
def call(self, request: LLMRequest):
|
||
"""
|
||
Call the model using COW's bot infrastructure
|
||
"""
|
||
try:
|
||
# For non-streaming calls, we'll use the existing reply method
|
||
# This is a simplified implementation
|
||
if hasattr(self.bot, 'call_with_tools'):
|
||
# Use tool-enabled call if available
|
||
kwargs = {
|
||
'messages': request.messages,
|
||
'tools': getattr(request, 'tools', None),
|
||
'stream': False,
|
||
'model': self.model # Pass model parameter
|
||
}
|
||
# Only pass max_tokens if it's explicitly set
|
||
if request.max_tokens is not None:
|
||
kwargs['max_tokens'] = request.max_tokens
|
||
|
||
# Extract system prompt if present
|
||
system_prompt = getattr(request, 'system', None)
|
||
if system_prompt:
|
||
kwargs['system'] = system_prompt
|
||
|
||
# Pass context metadata to bot
|
||
channel_type = getattr(self, 'channel_type', None) or ''
|
||
if channel_type:
|
||
kwargs['channel_type'] = channel_type
|
||
session_id = getattr(self, 'session_id', None)
|
||
if session_id:
|
||
kwargs['session_id'] = session_id
|
||
# Only bots that declare it get the agent id: others may pass
|
||
# unknown kwargs straight through to their provider.
|
||
agent_id = getattr(self, 'agent_id', None)
|
||
if agent_id and getattr(self.bot, 'accepts_agent_id', False):
|
||
kwargs['agent_id'] = agent_id
|
||
|
||
# Thinking mode is a global toggle independent of the channel.
|
||
# IM channels (WeChat/WeCom/DingTalk/Feishu) won't render the
|
||
# reasoning trace, but still benefit from the higher answer
|
||
# quality the thinking pass produces.
|
||
from config import conf
|
||
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||
# Some native-reasoning models reject disabled thinking or use
|
||
# effort as their only control, so they cannot follow the UI
|
||
# toggle literally.
|
||
if self._is_thinking_only_model():
|
||
thinking_enabled = True
|
||
kwargs['thinking'] = (
|
||
{"type": "enabled"} if thinking_enabled
|
||
else {"type": "disabled"}
|
||
)
|
||
# Effort only shapes a thinking pass. Thinking-only models keep
|
||
# receiving it because they force thinking_enabled above.
|
||
if thinking_enabled:
|
||
effort = self._normalized_reasoning_effort()
|
||
if effort:
|
||
kwargs['reasoning_effort'] = effort
|
||
|
||
response = self.bot.call_with_tools(**kwargs)
|
||
return self._format_response(response)
|
||
else:
|
||
# Fallback to regular call
|
||
# This would need to be implemented based on your specific needs
|
||
raise NotImplementedError("Regular call not implemented yet")
|
||
|
||
except Exception as e:
|
||
logger.error(f"AgentLLMModel call error: {e}")
|
||
raise
|
||
|
||
def call_stream(self, request: LLMRequest):
|
||
"""
|
||
Call the model with streaming using COW's bot infrastructure
|
||
"""
|
||
try:
|
||
if hasattr(self.bot, 'call_with_tools'):
|
||
# Use tool-enabled streaming call if available
|
||
# Extract system prompt if present
|
||
system_prompt = getattr(request, 'system', None)
|
||
|
||
# Build kwargs for call_with_tools
|
||
kwargs = {
|
||
'messages': request.messages,
|
||
'tools': getattr(request, 'tools', None),
|
||
'stream': True,
|
||
'model': self.model # Pass model parameter
|
||
}
|
||
|
||
# Only pass max_tokens if explicitly set, let the bot use its default
|
||
if request.max_tokens is not None:
|
||
kwargs['max_tokens'] = request.max_tokens
|
||
|
||
# Add system prompt if present
|
||
if system_prompt:
|
||
kwargs['system'] = system_prompt
|
||
|
||
# Pass context metadata to bot
|
||
channel_type = getattr(self, 'channel_type', None) or ''
|
||
if channel_type:
|
||
kwargs['channel_type'] = channel_type
|
||
session_id = getattr(self, 'session_id', None)
|
||
if session_id:
|
||
kwargs['session_id'] = session_id
|
||
# Only bots that declare it get the agent id: others may pass
|
||
# unknown kwargs straight through to their provider.
|
||
agent_id = getattr(self, 'agent_id', None)
|
||
if agent_id and getattr(self.bot, 'accepts_agent_id', False):
|
||
kwargs['agent_id'] = agent_id
|
||
|
||
# Thinking mode is a global toggle independent of the channel.
|
||
# IM channels (WeChat/WeCom/DingTalk/Feishu) won't render the
|
||
# reasoning trace, but still benefit from the higher answer
|
||
# quality the thinking pass produces.
|
||
from config import conf
|
||
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||
# Keep streaming and non-streaming calls on the same provider
|
||
# contract for always-thinking models.
|
||
if self._is_thinking_only_model():
|
||
thinking_enabled = True
|
||
kwargs['thinking'] = (
|
||
{"type": "enabled"} if thinking_enabled
|
||
else {"type": "disabled"}
|
||
)
|
||
# Effort only shapes a thinking pass. Thinking-only models keep
|
||
# receiving it because they force thinking_enabled above.
|
||
if thinking_enabled:
|
||
effort = self._normalized_reasoning_effort()
|
||
if effort:
|
||
kwargs['reasoning_effort'] = effort
|
||
|
||
stream = self.bot.call_with_tools(**kwargs)
|
||
|
||
# Convert stream format to our expected format
|
||
for chunk in stream:
|
||
yield self._format_stream_chunk(chunk)
|
||
else:
|
||
bot_type = type(self.bot).__name__
|
||
raise NotImplementedError(f"Bot {bot_type} does not support call_with_tools. Please add the method.")
|
||
|
||
except Exception as e:
|
||
logger.error(f"AgentLLMModel call_stream error: {e}", exc_info=True)
|
||
raise
|
||
|
||
def _format_response(self, response):
|
||
"""Format Claude response to our expected format"""
|
||
# This would need to be implemented based on Claude's response format
|
||
return response
|
||
|
||
def _format_stream_chunk(self, chunk):
|
||
"""Format Claude stream chunk to our expected format"""
|
||
# This would need to be implemented based on Claude's stream format
|
||
return chunk
|
||
|
||
|
||
class AgentBridge:
|
||
"""
|
||
Bridge class that integrates super Agent with COW
|
||
Manages multiple agent instances per session for conversation isolation
|
||
"""
|
||
|
||
def __init__(self, bridge: Bridge):
|
||
self.bridge = bridge
|
||
from agent.registry import get_agent_registry
|
||
from agent.routing import get_agent_router
|
||
|
||
self.agent_registry = get_agent_registry()
|
||
self.agent_router = get_agent_router(self.agent_registry)
|
||
# Canonical runtime map. A session identifier is only unique inside an
|
||
# agent workspace, so the agent id is part of every live key.
|
||
self._agent_instances: Dict[Tuple[str, str], Agent] = {}
|
||
self._default_agents: Dict[str, Agent] = {}
|
||
self._agents_lock = threading.RLock()
|
||
# Backward-compatible view for integrations that inspect sessions of
|
||
# the configured default agent directly.
|
||
self.agents: Dict[str, Agent] = {}
|
||
self.default_agent = None
|
||
self.agent: Optional[Agent] = None
|
||
self.scheduler_initialized = False
|
||
self.scheduler_agent_ids = set()
|
||
|
||
# Create helper instances
|
||
self.initializer = AgentInitializer(bridge, self)
|
||
|
||
# Eager-start the scheduler so cron tasks fire without waiting
|
||
# for the first user message. init_scheduler is idempotent.
|
||
try:
|
||
from agent.tools.scheduler.integration import init_scheduler
|
||
for profile in self.agent_registry.list(include_disabled=False):
|
||
if init_scheduler(self, profile.workspace, profile.id):
|
||
self.scheduler_agent_ids.add(profile.id)
|
||
self.scheduler_initialized = bool(self.scheduler_agent_ids)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
|
||
|
||
# Start the self-evolution idle trigger (idempotent, daemon thread).
|
||
try:
|
||
from agent.evolution.trigger import start_evolution_trigger
|
||
start_evolution_trigger(self)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
|
||
|
||
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||
"""
|
||
Create the super agent with COW integration
|
||
|
||
Args:
|
||
system_prompt: System prompt
|
||
tools: List of tools (optional)
|
||
**kwargs: Additional agent parameters
|
||
|
||
Returns:
|
||
Agent instance
|
||
"""
|
||
# Create LLM model that uses COW's bot infrastructure
|
||
model = AgentLLMModel(self.bridge)
|
||
|
||
# Default tools if none provided
|
||
if tools is None:
|
||
# Use ToolManager to load all available tools
|
||
from agent.tools import ToolManager
|
||
tool_manager = ToolManager()
|
||
tool_manager.load_tools()
|
||
|
||
tools = []
|
||
workspace_dir = kwargs.get("workspace_dir")
|
||
for tool_name in tool_manager.tool_classes.keys():
|
||
try:
|
||
tool = tool_manager.create_tool(tool_name)
|
||
if tool:
|
||
if workspace_dir:
|
||
tool.cwd = workspace_dir
|
||
tools.append(tool)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Failed to load tool {tool_name}: {e}")
|
||
|
||
# Create agent instance
|
||
agent = Agent(
|
||
system_prompt=system_prompt,
|
||
description=kwargs.get("description", "AI Super Agent"),
|
||
model=model,
|
||
tools=tools,
|
||
max_steps=kwargs.get("max_steps", 100),
|
||
output_mode=kwargs.get("output_mode", "logger"),
|
||
workspace_dir=kwargs.get("workspace_dir"),
|
||
skill_manager=kwargs.get("skill_manager"),
|
||
enable_skills=kwargs.get("enable_skills", True),
|
||
memory_manager=kwargs.get("memory_manager"),
|
||
max_context_tokens=kwargs.get("max_context_tokens"),
|
||
context_reserve_tokens=kwargs.get("context_reserve_tokens"),
|
||
runtime_info=kwargs.get("runtime_info"),
|
||
)
|
||
|
||
# Log skill loading details
|
||
if agent.skill_manager:
|
||
logger.debug(f"[AgentBridge] SkillManager initialized with {len(agent.skill_manager.skills)} skills")
|
||
|
||
return agent
|
||
|
||
def steer_session(self, session_id: str, instruction: str, agent_id: str = None):
|
||
"""Inject an explicit instruction into one active session."""
|
||
logger.info(f"[AgentBridge] steer new instruction: session={session_id}, content={instruction}")
|
||
return get_steer_registry().submit(
|
||
self.scoped_session_key(session_id, agent_id), instruction
|
||
)
|
||
|
||
def _resolve_agent_id(self, agent_id: str = None) -> str:
|
||
return self.agent_registry.get(agent_id).id
|
||
|
||
def scoped_session_key(self, session_id: str, agent_id: str = None) -> str:
|
||
"""Namespace a session id by its agent.
|
||
|
||
Session ids are only unique within one Agent, so cancel and steer
|
||
lookups must be scoped or a /cancel in one Agent's chat would abort a
|
||
different Agent's run that happens to share the id.
|
||
"""
|
||
return self._cancel_key(
|
||
self._resolve_agent_id(agent_id),
|
||
session_id,
|
||
self.agent_registry.default_agent_id,
|
||
)
|
||
|
||
def route_context(self, context: Context) -> str:
|
||
"""Resolve and attach the workspace selected for an inbound context."""
|
||
return self.agent_router.resolve_context(context)
|
||
|
||
def get_conversation_store(self, agent_id: str = None):
|
||
"""Return the session store owned by one agent workspace."""
|
||
profile = self.agent_registry.get(agent_id)
|
||
from agent.memory import get_conversation_store
|
||
return get_conversation_store(profile.workspace)
|
||
|
||
def _seed_team_members(self, session_id: str, host_agent_id: str, context: Context = None) -> None:
|
||
"""Project a team bot's roster onto the session.
|
||
|
||
A channel instance configured with ``members`` is a fixed team: its
|
||
owner (``host_agent_id``) plus teammates it may delegate to. The rest of
|
||
the stack learns a conversation is a team from
|
||
``session_prefs.members``, so the instance roster is mirrored there.
|
||
|
||
Two sources, two policies:
|
||
|
||
- **Channel instance** (message carries an ``instance_id``): the instance
|
||
roster is *authoritative* and is reconciled onto the session every
|
||
time — including shrinking it, or clearing it when the instance was
|
||
switched back to a single Agent. Without this, a session seeded once
|
||
when the instance was a team keeps injecting the team prompt forever
|
||
even after the roster is emptied in the console.
|
||
|
||
- **Delegation** (a delegated turn runs in its own private session and
|
||
carries ``delegation_members``): seed-once, never overwrite, so a
|
||
teammate can delegate onward to the same team.
|
||
|
||
Only enabled teammates other than the owner are kept, matching how a Web
|
||
team is stored.
|
||
"""
|
||
if not session_id or not context:
|
||
return
|
||
# A roster may name teammates that live in another process. Learn how to
|
||
# reach them before resolving the roster below, so a member reached
|
||
# through the transport is kept rather than dropped as unknown. Empty or
|
||
# no transport (a stand-alone install) makes this a no-op.
|
||
peers = context.get("peers")
|
||
if peers is None:
|
||
peers = context.kwargs.get("peers")
|
||
if peers:
|
||
try:
|
||
from agent.multiagent import get_transport
|
||
|
||
transport = get_transport()
|
||
if transport is not None:
|
||
transport.register_peers(peers)
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] register_peers failed: {e}")
|
||
# The channel path carries the roster under ``members`` and is
|
||
# authoritative (it mirrors the instance's live team.json roster). A
|
||
# delegated turn instead carries ``delegation_members`` and is seed-once.
|
||
channel_members = context.get("members")
|
||
if channel_members is None:
|
||
channel_members = context.kwargs.get("members")
|
||
from_channel = channel_members is not None
|
||
delegation_members = context.get("delegation_members") or context.kwargs.get("delegation_members")
|
||
|
||
try:
|
||
from agent.workspace import session_prefs
|
||
|
||
existing = session_prefs.get_prefs(session_id, host_agent_id).get("members")
|
||
|
||
if from_channel:
|
||
# Authoritative reconcile against the instance's current roster,
|
||
# even when it is now empty (single-Agent instance).
|
||
cleaned = self._clean_team_members(channel_members or [], host_agent_id)
|
||
if list(existing or []) == cleaned:
|
||
return # already in sync — nothing to write
|
||
if cleaned:
|
||
session_prefs.set_prefs(session_id, host_agent_id, members=cleaned)
|
||
logger.info(
|
||
f"[AgentBridge] Reconciled team roster {cleaned} onto session "
|
||
f"'{session_id}' owned by {host_agent_id}"
|
||
)
|
||
elif existing:
|
||
# Instance is no longer a team: drop the stale session roster
|
||
# so the team prompt stops being injected.
|
||
session_prefs.set_prefs(session_id, host_agent_id, members=None)
|
||
logger.info(
|
||
f"[AgentBridge] Cleared stale team roster from session "
|
||
f"'{session_id}' owned by {host_agent_id} (instance is single-Agent)"
|
||
)
|
||
self._retire_session_runtimes(session_id)
|
||
return
|
||
|
||
# Delegation path: seed once, never clobber an existing roster.
|
||
if existing:
|
||
return
|
||
cleaned = self._clean_team_members(delegation_members or [], host_agent_id)
|
||
if cleaned:
|
||
session_prefs.set_prefs(session_id, host_agent_id, members=cleaned)
|
||
logger.info(
|
||
f"[AgentBridge] Seeded team roster {cleaned} onto session "
|
||
f"'{session_id}' owned by {host_agent_id}"
|
||
)
|
||
self._retire_session_runtimes(session_id)
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] _seed_team_members failed: {e}")
|
||
|
||
def _retire_session_runtimes(self, session_id: str) -> None:
|
||
"""Drop the runtimes of *session_id* so the next turn rebuilds them.
|
||
|
||
A runtime fixes its tool list when it is built, and delegation is only
|
||
offered to a conversation that has teammates. A roster that changes
|
||
while the conversation is already live therefore has to retire what was
|
||
built under the old one: otherwise the team gains a member and the tool
|
||
to reach them only appears after a restart. Retiring costs a rebuild on
|
||
the next turn; the transcript is reloaded from the store either way.
|
||
"""
|
||
with self._agents_lock:
|
||
retired = [key for key in self._agent_instances if key[1] == session_id]
|
||
for key in retired:
|
||
self._agent_instances.pop(key, None)
|
||
if retired:
|
||
self.agents.pop(session_id, None)
|
||
if retired:
|
||
logger.info(
|
||
f"[AgentBridge] Retired {len(retired)} runtime(s) of session "
|
||
f"'{session_id}' after its roster changed"
|
||
)
|
||
|
||
def _clean_team_members(self, members, host_agent_id: str) -> list:
|
||
"""Normalize a roster: drop the owner, blanks, dupes and unknown/disabled
|
||
Agents, preserving order. Returns the teammates to store on a session.
|
||
|
||
Members arrive as ids a caller may address an Agent by, so the reserved
|
||
"default" alias is resolved here too; storing the resolved id keeps the
|
||
roster comparable with the owner and with an id sent the other way.
|
||
|
||
A member hosted in another process is kept under the id the installed
|
||
transport knows it by; without a transport such ids are unknown here
|
||
and dropped like any other stranger.
|
||
"""
|
||
from agent.multiagent import peer as peer_of
|
||
|
||
cleaned = []
|
||
for mid in members or []:
|
||
mid = str(mid or "").strip()
|
||
if not mid:
|
||
continue
|
||
try:
|
||
resolved = self.agent_registry.get_addressed(mid, require_enabled=True).id
|
||
except Exception:
|
||
found = peer_of(mid)
|
||
if found is None:
|
||
continue # skip unknown/disabled teammates
|
||
resolved = found.id
|
||
if resolved == host_agent_id or resolved in cleaned:
|
||
continue
|
||
cleaned.append(resolved)
|
||
return cleaned
|
||
|
||
def _resolve_speaker(self, host_agent_id: str, context: Context = None) -> str:
|
||
"""Pick who answers this turn: the conversation's owner, or a teammate
|
||
the user addressed by name.
|
||
|
||
Naming someone is an instruction about who should answer, so it is
|
||
honoured literally. Anything unrecognised, disabled, or already the
|
||
owner falls back to the owner, which is the single-Agent behaviour.
|
||
"""
|
||
named = (context.get("speaker_agent_id") if context else "") or ""
|
||
if not named or named == host_agent_id:
|
||
return host_agent_id
|
||
try:
|
||
profile = self.agent_registry.get(named, require_enabled=True)
|
||
except Exception:
|
||
logger.warning(
|
||
f"[AgentBridge] Ignoring unknown addressee '{named}', "
|
||
f"answering as {host_agent_id}"
|
||
)
|
||
return host_agent_id
|
||
logger.info(
|
||
f"[AgentBridge] Turn addressed to {profile.id}; "
|
||
f"answering in {host_agent_id}'s conversation"
|
||
)
|
||
return profile.id
|
||
|
||
def _peer_speaker(self, named: str, host_agent_id: str):
|
||
"""The teammate a turn names when it is hosted in another process.
|
||
|
||
None for an id that is empty, the owner's, a local Agent's, or that no
|
||
transport knows — every one of which the ordinary path already handles.
|
||
"""
|
||
named = str(named or "").strip()
|
||
if not named or named == host_agent_id:
|
||
return None
|
||
try:
|
||
self.agent_registry.get_addressed(named, require_enabled=False)
|
||
return None # local: answered here, as always
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from agent.multiagent import get_transport, peer as peer_of
|
||
|
||
if get_transport() is None:
|
||
return None
|
||
return peer_of(named)
|
||
except Exception:
|
||
return None
|
||
|
||
def _speak_on_peer(self, query: str, session_id: str, host_agent_id: str, peer,
|
||
channel_type: str = "") -> Reply:
|
||
"""Let a teammate elsewhere answer this turn, as itself.
|
||
|
||
The conversation stays the owner's — same session, same transcript — and
|
||
only the voice changes, which is what addressing someone by name asks
|
||
for. The teammate is given the conversation so far so it answers in
|
||
context rather than cold.
|
||
"""
|
||
from agent.multiagent import MODE_SPEAK, InvokeRequest, PeerAgent, get_transport, resolve_teammate
|
||
|
||
owner = self.agent_registry.get(host_agent_id, require_enabled=False)
|
||
|
||
members = [owner.id]
|
||
for member_id in self._session_members(session_id, host_agent_id):
|
||
if member_id not in (owner.id, peer.id) and member_id not in members:
|
||
members.append(member_id)
|
||
peers = []
|
||
for member_id in members:
|
||
found = resolve_teammate(member_id)
|
||
profile = PeerAgent.from_any(found) if found else None
|
||
if profile is not None:
|
||
peers.append(profile)
|
||
|
||
request = InvokeRequest(
|
||
request_id=uuid.uuid4().hex,
|
||
target_id=peer.id,
|
||
task=self._strip_peer_address(query, peer),
|
||
source_id=owner.id,
|
||
source_name=owner.name,
|
||
root_session_id=session_id,
|
||
trace=(owner.id,),
|
||
depth=0,
|
||
members=tuple(members),
|
||
peers=tuple(peers),
|
||
timeout_seconds=self._delegation_timeout(),
|
||
mode=MODE_SPEAK,
|
||
history=tuple(self._shared_history(session_id, owner.id)),
|
||
)
|
||
logger.info(
|
||
f"[AgentBridge] Turn addressed to peer {peer.id}; "
|
||
f"answering in {owner.id}'s conversation, session={session_id}"
|
||
)
|
||
result = get_transport().invoke(request)
|
||
if not result.ok:
|
||
return Reply(ReplyType.ERROR, f"{peer.name} could not answer: {result.error}")
|
||
|
||
content = result.content or ""
|
||
# The turn happened in this conversation, so it belongs in its record;
|
||
# nothing local ran to write it down.
|
||
turn = [
|
||
{"role": "user", "content": [{"type": "text", "text": query}]},
|
||
{"role": "assistant", "content": [{"type": "text", "text": content}]},
|
||
]
|
||
self._persist_messages(
|
||
session_id, self._attribute_to_speaker(turn, peer.id), channel_type, owner.id
|
||
)
|
||
return Reply(ReplyType.TEXT, content)
|
||
|
||
@staticmethod
|
||
def _session_members(session_id: str, host_agent_id: str) -> list:
|
||
"""Teammate ids recorded on the session, as stored."""
|
||
if not session_id:
|
||
return []
|
||
try:
|
||
from agent.workspace import session_prefs
|
||
|
||
return list(session_prefs.get_prefs(session_id, host_agent_id).get("members") or [])
|
||
except Exception:
|
||
return []
|
||
|
||
@staticmethod
|
||
def _strip_peer_address(query: str, peer) -> str:
|
||
"""Drop the leading "@name" aimed at *peer*; see ``_strip_address``."""
|
||
from agent.team_addressing import strip_address
|
||
|
||
return strip_address(query, [peer.name, peer.id])
|
||
|
||
@staticmethod
|
||
def _delegation_timeout() -> float:
|
||
try:
|
||
return float((conf().get("agent_delegation") or {}).get("timeout_seconds") or 600)
|
||
except Exception:
|
||
return 600.0
|
||
|
||
def _shared_history(self, session_id: str, owner_agent_id: str) -> list:
|
||
"""Text-only history with authors, oldest first."""
|
||
try:
|
||
if not conf().get("conversation_persistence", True):
|
||
return []
|
||
max_turns = conf().get("agent_max_context_turns", 30)
|
||
saved = self.get_conversation_store(owner_agent_id).load_messages(
|
||
session_id, max_turns=max(3, max_turns // 2), with_authors=True
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] shared history unavailable for {session_id}: {e}")
|
||
return []
|
||
history = []
|
||
for message in AgentInitializer._filter_text_only_messages(saved or []):
|
||
blocks = message.get("content") or []
|
||
text = blocks[0].get("text", "") if blocks and isinstance(blocks[0], dict) else ""
|
||
if not text:
|
||
continue
|
||
entry = {"role": message["role"], "text": text}
|
||
if message["role"] == "assistant":
|
||
entry["agent_id"] = message.get("agent_id") or owner_agent_id
|
||
history.append(entry)
|
||
return history
|
||
|
||
def _strip_address(self, query: str, speaker_agent_id: str) -> str:
|
||
"""Drop the leading "@name" now that it has been acted on.
|
||
|
||
Routing already answered the question the mention was asking, so
|
||
leaving it in makes the Agent read its own name as someone else's and
|
||
reply about that person instead of as itself. It only ever comes off
|
||
the front, and only for the Agent it named.
|
||
|
||
The transcript keeps the original: the mention is what the user wrote,
|
||
and it records who the turn was aimed at.
|
||
"""
|
||
if not query:
|
||
return query
|
||
try:
|
||
profile = self.agent_registry.get(speaker_agent_id, require_enabled=False)
|
||
except Exception:
|
||
return query
|
||
from agent.team_addressing import strip_address
|
||
|
||
return strip_address(query, [profile.name, profile.id])
|
||
|
||
@staticmethod
|
||
def _attribute_to_speaker(messages: list, speaker_agent_id: str) -> list:
|
||
"""Tag a guest's turns with who wrote them, for the transcript.
|
||
|
||
A shared conversation that records no author replays as one voice, so a
|
||
reload loses track of who said what.
|
||
|
||
Returns copies. The dicts handed in are the Agent's live context, and
|
||
``extras`` is a column of ours: annotating them in place would put an
|
||
unknown key on every later request and the model rejects the call.
|
||
"""
|
||
return [
|
||
{
|
||
**message,
|
||
"extras": {
|
||
**(message.get("extras") or {}),
|
||
"agent_id": speaker_agent_id,
|
||
},
|
||
}
|
||
for message in messages or []
|
||
]
|
||
|
||
def _roster_speaker_labels(self) -> list:
|
||
"""Names and ids a model might copy from a shared transcript."""
|
||
labels = []
|
||
try:
|
||
for profile in self.agent_registry.list(include_disabled=True):
|
||
if profile.name:
|
||
labels.append(profile.name)
|
||
if profile.id:
|
||
labels.append(profile.id)
|
||
except Exception:
|
||
return []
|
||
return labels
|
||
|
||
@staticmethod
|
||
def _strip_speaker_prefix(text: str, labels: list) -> str:
|
||
"""Drop a leading speaker label the model copied from history.
|
||
|
||
Covers the forms a shared transcript can show: ``[Name]``,
|
||
``Name:`` and ``Name(@id):`` (with either colon).
|
||
"""
|
||
if not text or not labels:
|
||
return text
|
||
escaped = "|".join(
|
||
re.escape(label)
|
||
for label in sorted({label for label in labels if label}, key=len, reverse=True)
|
||
)
|
||
if not escaped:
|
||
return text
|
||
name = rf"(?:{escaped})"
|
||
return re.sub(
|
||
rf"^\s*(?:\[{name}\]|{name}\s*(?:\(@{name}\))?\s*[::])\s*",
|
||
"",
|
||
text,
|
||
count=1,
|
||
)
|
||
|
||
def _strip_speaker_prefix_from_messages(self, messages: list) -> list:
|
||
labels = self._roster_speaker_labels()
|
||
if not labels:
|
||
return messages
|
||
cleaned = []
|
||
for message in messages or []:
|
||
if message.get("role") != "assistant":
|
||
cleaned.append(message)
|
||
continue
|
||
content = message.get("content")
|
||
if isinstance(content, list) and content and content[0].get("type") == "text":
|
||
text = self._strip_speaker_prefix(content[0].get("text", ""), labels)
|
||
cleaned.append({
|
||
**message,
|
||
"content": [{**content[0], "text": text}, *content[1:]],
|
||
})
|
||
elif isinstance(content, str):
|
||
cleaned.append({
|
||
**message,
|
||
"content": self._strip_speaker_prefix(content, labels),
|
||
})
|
||
else:
|
||
cleaned.append(message)
|
||
return cleaned
|
||
|
||
def _begin_run(self, session_id: str, agent_id: str, context: Context = None):
|
||
"""Open a run for this turn and make its id the ambient one.
|
||
|
||
Returns ``(run_id, token, store)``; every element is None when the run
|
||
could not be recorded. Bookkeeping must never break a reply, so any
|
||
failure here degrades to "no run row" rather than raising: the turn
|
||
still runs, it just is not addressable afterwards.
|
||
|
||
A run id already in scope means this turn was started by another run
|
||
(a delegation or a spawn), so that one becomes the parent and the tree
|
||
stays walkable from either end. A caller that hands work to another
|
||
thread cannot rely on that ambient id, since context variables do not
|
||
cross threads, so it may instead name the run and its parent through
|
||
the context and keep the tree intact.
|
||
"""
|
||
from common.utils import current_agent_run_id, set_agent_run_id
|
||
|
||
try:
|
||
parent_run_id = str(
|
||
(context.get("parent_run_id") if context else "")
|
||
or current_agent_run_id()
|
||
or ""
|
||
)
|
||
run_id = str((context.get("run_id") if context else "") or "") or uuid.uuid4().hex
|
||
store = self.get_conversation_store(agent_id)
|
||
# An external system driving this work passes its own handle
|
||
# through the context; a native turn leaves both empty.
|
||
task_id = str((context.get("task_id") if context else "") or "")
|
||
task_source = str((context.get("task_source") if context else "") or "")
|
||
store.create_run(
|
||
run_id,
|
||
agent_id=agent_id or "",
|
||
session_id=session_id or "",
|
||
parent_run_id=parent_run_id,
|
||
task_id=task_id,
|
||
task_source=task_source,
|
||
)
|
||
# Set last: once the ambient id changes, the caller owes us a reset.
|
||
token = set_agent_run_id(run_id)
|
||
return run_id, token, store
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Could not open run: {e}")
|
||
return None, None, None
|
||
|
||
def _end_run(self, store, run_id: str, token, status: str, error: str = "") -> None:
|
||
"""Close a run and restore the previous ambient run id.
|
||
|
||
The reset happens even when the status update fails, or the ambient id
|
||
would leak into whatever this thread handles next.
|
||
"""
|
||
from common.utils import clear_agent_run_id
|
||
|
||
try:
|
||
if store is not None and run_id:
|
||
store.finish_run(run_id, status=status, error=error)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Could not close run {run_id}: {e}")
|
||
finally:
|
||
if token is not None:
|
||
clear_agent_run_id(token)
|
||
|
||
def peek_agent(self, session_id: str, agent_id: str = None) -> Optional[Agent]:
|
||
"""Return the session's live agent, or None if it has not been built.
|
||
|
||
The read-only counterpart to `get_agent`, which initializes an agent on
|
||
miss — spinning up MCP connections and skills. Callers that only want to
|
||
inspect existing state (the context-usage endpoint hovers on this) must
|
||
use this instead, and must tolerate None. Deliberately skips
|
||
`_apply_session_project` / `apply_session_prefs`: both mutate the agent.
|
||
|
||
:param session_id: Session identifier
|
||
:param agent_id: Agent profile identifier. Omit for the configured default.
|
||
:return: The existing Agent instance, or None.
|
||
"""
|
||
if not session_id:
|
||
return None
|
||
resolved_agent_id = self._resolve_agent_id(agent_id)
|
||
with self._agents_lock:
|
||
return self._agent_instances.get(
|
||
self._runtime_key(resolved_agent_id, session_id)
|
||
)
|
||
|
||
@staticmethod
|
||
def _runtime_key(agent_id: str, session_id: str) -> Tuple[str, str]:
|
||
return agent_id, session_id
|
||
|
||
@staticmethod
|
||
def _cancel_key(agent_id: str, token: str, default_agent_id: str) -> str:
|
||
"""Keep legacy token keys for the default agent, namespace the rest."""
|
||
return token if agent_id == default_agent_id else f"{agent_id}::{token}"
|
||
|
||
def _has_runtime(self, agent_id: str, session_id: str) -> bool:
|
||
"""Whether ``get_agent`` would return a cached runtime rather than build one."""
|
||
if not session_id:
|
||
return False
|
||
with self._agents_lock:
|
||
key = self._runtime_key(self._resolve_agent_id(agent_id), session_id)
|
||
return key in self._agent_instances
|
||
|
||
def get_agent(
|
||
self,
|
||
session_id: str = None,
|
||
agent_id: str = None,
|
||
host_agent_id: str = None,
|
||
permission_mode: str = None,
|
||
) -> Optional[Agent]:
|
||
"""
|
||
Get agent instance for the given session
|
||
|
||
Args:
|
||
session_id: Session identifier (e.g., user_id). If None, returns
|
||
the workspace's default runtime instance.
|
||
agent_id: Agent profile identifier. Omit for the configured default.
|
||
host_agent_id: Agent that owns this conversation, when it is not
|
||
``agent_id``. Set when the user addressed a teammate directly:
|
||
the teammate answers as itself, but reads and continues the
|
||
host's transcript instead of starting a private one.
|
||
|
||
Returns:
|
||
Agent instance for this session
|
||
"""
|
||
resolved_agent_id = self._resolve_agent_id(agent_id)
|
||
with self._agents_lock:
|
||
if session_id is None:
|
||
agent = self._default_agents.get(resolved_agent_id)
|
||
if agent is None:
|
||
agent = self.initializer.initialize_agent(
|
||
session_id=None, agent_id=resolved_agent_id
|
||
)
|
||
self._default_agents[resolved_agent_id] = agent
|
||
if resolved_agent_id == self.agent_registry.default_agent_id:
|
||
self.default_agent = agent
|
||
return agent
|
||
|
||
host_id = self._resolve_agent_id(host_agent_id or resolved_agent_id)
|
||
key = self._runtime_key(resolved_agent_id, session_id)
|
||
agent = self._agent_instances.get(key)
|
||
if agent is None:
|
||
agent = self.initializer.initialize_agent(
|
||
session_id=session_id,
|
||
agent_id=resolved_agent_id,
|
||
host_agent_id=host_id,
|
||
)
|
||
self._agent_instances[key] = agent
|
||
if resolved_agent_id == self.agent_registry.default_agent_id:
|
||
self.agents[session_id] = agent
|
||
# Point the working directory at the session's project (if any).
|
||
# Applied on every fetch, so switching projects — or back to the
|
||
# default — takes effect on the next message without rebuilding the
|
||
# agent. Memory/skills stay anchored to the workspace regardless.
|
||
# Project and per-session settings belong to the conversation, so a
|
||
# guest follows the host's, not its own unrelated ones.
|
||
self._apply_session_project(agent, session_id, host_id)
|
||
# Same idea for the session's permission mode, a per-conversation
|
||
# override that falls back to the global config. The model is not
|
||
# shared with a guest — see apply_session_prefs.
|
||
self.apply_session_prefs(
|
||
agent,
|
||
session_id,
|
||
host_id,
|
||
owns_conversation=resolved_agent_id == host_id,
|
||
permission_mode=permission_mode,
|
||
)
|
||
return agent
|
||
|
||
def _sync_shared_transcript(self, agent, session_id: str, host_agent_id: str) -> None:
|
||
"""Reload the host's transcript so every teammate sees the same history.
|
||
|
||
Solo conversations keep their live in-memory list (including tool
|
||
chains). A team conversation is reread from the host store: this
|
||
speaker's own turns keep their tool chains, and colleagues' replies are
|
||
replayed as ``Name:`` user turns so ``assistant`` stays its own voice.
|
||
"""
|
||
if not session_id or not AgentInitializer._is_shared_conversation(
|
||
session_id, host_agent_id
|
||
):
|
||
return
|
||
restore = getattr(self.initializer, "_restore_conversation_history", None)
|
||
if restore is None:
|
||
return
|
||
try:
|
||
host = self.agent_registry.get(host_agent_id, require_enabled=False)
|
||
except Exception:
|
||
return
|
||
restore(agent, session_id, host.workspace, host_agent_id)
|
||
|
||
def _apply_session_project(self, agent, session_id: str, agent_id: str) -> None:
|
||
"""Retarget the agent's working directory to the session's project dir.
|
||
|
||
A no-op when the session has no project selected (clears any previous
|
||
override). Failures are swallowed: a bad project setting must not break
|
||
the chat, it just falls back to the default workspace.
|
||
"""
|
||
try:
|
||
from agent.workspace import project_store
|
||
project_dir = project_store.get_project_dir(session_id, agent_id)
|
||
if getattr(agent, "apply_project_dir", None):
|
||
agent.apply_project_dir(project_dir)
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] apply_session_project failed: {e}")
|
||
|
||
def apply_session_prefs(
|
||
self,
|
||
agent,
|
||
session_id: str,
|
||
agent_id: str = None,
|
||
owns_conversation: bool = True,
|
||
permission_mode: str = None,
|
||
) -> None:
|
||
"""Apply a session's model / permission overrides to its agent.
|
||
|
||
Called on every agent fetch and right after the user changes a setting,
|
||
so a switch takes effect on the next message without rebuilding the
|
||
agent. An empty override resets the agent to the global config, which is
|
||
what makes "follow global" work after a session had pinned something.
|
||
|
||
The model is the exception on two counts. A conversation's pinned model
|
||
belongs to the Agent that owns it, so an invited teammate is never
|
||
forced onto it — that would throw away the model it was configured with,
|
||
often the reason it was invited. And in a group chat nobody follows the
|
||
pin, the owner included: a team is a set of Agents each answering on its
|
||
own model, so the pinned model (a single-chat notion) is ignored and
|
||
every speaker uses its own default. Permission stays shared either way,
|
||
because it bounds what this conversation may change regardless of who is
|
||
speaking.
|
||
"""
|
||
if agent is None or not session_id:
|
||
return
|
||
try:
|
||
from agent.workspace import session_prefs
|
||
|
||
prefs = session_prefs.get_prefs(session_id, agent_id)
|
||
model = getattr(agent, "model", None)
|
||
if model is not None and hasattr(model, "set_session_override"):
|
||
# A conversation with members is a group: each Agent answers on
|
||
# its own configured model, so the session pin never applies.
|
||
is_group = bool(prefs.get("members"))
|
||
if owns_conversation and not is_group:
|
||
model.set_session_override(prefs.get("provider"), prefs.get("model"))
|
||
else:
|
||
model.set_session_override(None, None)
|
||
if hasattr(agent, "apply_permission_mode"):
|
||
# A delegated turn's session has no prefs; it carries the
|
||
# delegating conversation's mode instead.
|
||
agent.apply_permission_mode(permission_mode or prefs.get("permission"))
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] apply_session_prefs failed: {e}")
|
||
|
||
def get_cached_agent(self, session_id: str, agent_id: str = None) -> Optional[Agent]:
|
||
"""Return an existing session agent without creating one."""
|
||
resolved_agent_id = self._resolve_agent_id(agent_id)
|
||
with self._agents_lock:
|
||
return self._agent_instances.get(
|
||
self._runtime_key(resolved_agent_id, session_id)
|
||
)
|
||
|
||
def iter_agent_instances(
|
||
self, include_defaults: bool = True
|
||
) -> Iterator[Tuple[str, Optional[str], Agent]]:
|
||
"""Snapshot all live instances as ``(agent_id, session_id, agent)``."""
|
||
with self._agents_lock:
|
||
defaults = list(self._default_agents.items()) if include_defaults else []
|
||
sessions = list(self._agent_instances.items())
|
||
for agent_id, agent in defaults:
|
||
yield agent_id, None, agent
|
||
for (agent_id, session_id), agent in sessions:
|
||
yield agent_id, session_id, agent
|
||
|
||
def clear_all_model_fallbacks(self) -> int:
|
||
"""Drop any engaged fallback routing on every live agent's model.
|
||
|
||
Fallback state lives on the long-lived ``AgentLLMModel`` and is normally
|
||
cleared at the top of the next run. Disabling the fallback in the UI only
|
||
rewrites config, so a model that had already switched would stay on the
|
||
backup until that next run happens to reset it. Called when the user
|
||
turns the fallback off so the change takes effect immediately, on the
|
||
very next message, without waiting for a run boundary or a restart.
|
||
|
||
Returns the number of models that were actually on a fallback.
|
||
"""
|
||
cleared = 0
|
||
for _agent_id, _session_id, agent in self.iter_agent_instances():
|
||
model = getattr(agent, "model", None)
|
||
reset = getattr(model, "reset_fallback", None)
|
||
if not callable(reset):
|
||
continue
|
||
if getattr(model, "_fallback_model", None) is None:
|
||
continue
|
||
try:
|
||
reset()
|
||
cleared += 1
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] clear fallback skipped: {e}")
|
||
if cleared:
|
||
logger.info(f"[AgentBridge] cleared engaged fallback on {cleared} model(s)")
|
||
return cleared
|
||
|
||
def apply_runtime_limits(self) -> int:
|
||
"""Push the configured step cap and context budget onto live agents.
|
||
|
||
Both are read once when an agent is built. Updating them in place keeps
|
||
each session's working context and prompt cache; a smaller budget is
|
||
enforced by the regular trim (with summary) at the start of the next
|
||
run. Evicting the agents instead would rebuild them from the store's
|
||
short, clipped restore window.
|
||
|
||
Returns the number of agents updated.
|
||
"""
|
||
from bridge.agent_initializer import configured_runtime_limits
|
||
|
||
max_steps, max_context_tokens = configured_runtime_limits()
|
||
updated = 0
|
||
for _agent_id, _session_id, agent in self.iter_agent_instances():
|
||
agent.max_steps = max_steps
|
||
agent.max_context_tokens = max_context_tokens
|
||
updated += 1
|
||
logger.info(
|
||
f"[AgentBridge] Applied runtime limits to {updated} agent(s): "
|
||
f"max_steps={max_steps}, max_context_tokens={max_context_tokens}"
|
||
)
|
||
return updated
|
||
|
||
def sync_session_messages_from_store(
|
||
self, session_id: str, agent_id: str = None
|
||
) -> int:
|
||
"""Reload an agent's in-memory ``messages`` list from the persistent
|
||
conversation store.
|
||
|
||
Used after an external mutation (e.g. user edits / deletes a message
|
||
via the web console) so the agent's next turn sees the same history
|
||
as the database. The operation is a no-op when the agent has not been
|
||
instantiated yet for the session.
|
||
|
||
History is rebuilt exactly as on session restore. Deleting a message
|
||
can orphan a tool_use from its tool_result; a turn left like that is
|
||
replayed as text, since the provider would reject the broken pair.
|
||
|
||
Returns:
|
||
Number of messages now held in the agent's memory. Returns -1 if
|
||
the agent does not exist or has no compatible ``messages`` attr.
|
||
"""
|
||
if not session_id:
|
||
return -1
|
||
agent = self.get_cached_agent(session_id, agent_id=agent_id)
|
||
if agent is None:
|
||
return -1
|
||
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
|
||
return -1
|
||
try:
|
||
store = self.get_conversation_store(agent_id)
|
||
# No turn cap here: we want a faithful mirror of what the store
|
||
# has for this session after deletion.
|
||
remaining = store.load_messages(session_id, max_turns=10**6)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
|
||
)
|
||
return -1
|
||
try:
|
||
remaining = AgentInitializer._restored_history(remaining)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Replaying tool calls failed for session={session_id}, "
|
||
f"syncing text only: {e}"
|
||
)
|
||
remaining = AgentInitializer._filter_text_only_messages(remaining)
|
||
with agent.messages_lock:
|
||
agent.messages.clear()
|
||
for msg in remaining:
|
||
agent.messages.append({
|
||
"role": msg["role"],
|
||
"content": msg["content"],
|
||
})
|
||
count = len(agent.messages)
|
||
logger.info(
|
||
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
|
||
)
|
||
return count
|
||
|
||
def agent_reply(self, query: str, context: Context = None,
|
||
on_event=None, clear_history: bool = False) -> Reply:
|
||
"""
|
||
Use super agent to reply to a query
|
||
|
||
Args:
|
||
query: User query
|
||
context: COW context (optional, contains session_id for user isolation)
|
||
on_event: Event callback (optional)
|
||
clear_history: Whether to clear conversation history
|
||
|
||
Returns:
|
||
Reply object
|
||
"""
|
||
session_id = None
|
||
agent = None
|
||
request_id = None
|
||
cancel_event = None
|
||
token_key = None
|
||
# Bound before the try so the error paths below can read it even when
|
||
# the failure happened before it was computed.
|
||
scoped_session_id = None
|
||
steer_inbox = None
|
||
run_id = None
|
||
run_token = None
|
||
run_store = None
|
||
run_status = "done"
|
||
run_error = ""
|
||
try:
|
||
# Extract session_id from context for user isolation
|
||
if context:
|
||
session_id = context.kwargs.get("session_id") or context.get("session_id")
|
||
request_id = context.kwargs.get("request_id") or context.get("request_id")
|
||
|
||
resolved_agent_id = (
|
||
self.route_context(context)
|
||
if context is not None
|
||
else self.agent_registry.default_agent_id
|
||
)
|
||
|
||
# A team channel bot (e.g. a Feishu instance with members) carries
|
||
# its roster on every inbound message. Materialize it into the
|
||
# session the first time we see the conversation so the shared
|
||
# delegate/@mention machinery — which reads session_prefs.members —
|
||
# treats it as a team, exactly like a Web team conversation.
|
||
self._seed_team_members(session_id, resolved_agent_id, context)
|
||
|
||
# Addressing a teammate by name hands the turn to that teammate
|
||
# directly. The conversation still belongs to `resolved_agent_id`,
|
||
# so the transcript, the run and the queue all stay in one place —
|
||
# only the voice answering this turn changes.
|
||
#
|
||
# A teammate on the roster may be hosted in another process; it
|
||
# answers over the transport instead of here, and the turn is
|
||
# recorded in this conversation either way.
|
||
addressed = (context.get("speaker_agent_id") if context else "") or ""
|
||
remote_speaker = self._peer_speaker(addressed, resolved_agent_id)
|
||
if remote_speaker is not None:
|
||
return self._speak_on_peer(
|
||
query, session_id, resolved_agent_id, remote_speaker,
|
||
channel_type=(context.get("channel_type") or "") if context else "",
|
||
)
|
||
speaker_agent_id = self._resolve_speaker(resolved_agent_id, context)
|
||
# With multiple Agents (and especially several bound channel
|
||
# instances) it isn't obvious from the logs which Agent a message
|
||
# was routed to. Emit one line naming the target Agent and, when
|
||
# present, the channel instance it arrived on. Skipped for
|
||
# single-Agent setups to avoid noise.
|
||
try:
|
||
if len(self.agent_registry.list()) > 1:
|
||
instance_id = (
|
||
context.get("instance_id")
|
||
or context.kwargs.get("instance_id")
|
||
if context is not None else ""
|
||
)
|
||
via = f" | {instance_id}" if instance_id else ""
|
||
# The Agent that actually answers this turn is the speaker,
|
||
# which differs from the owner when the user addressed a
|
||
# teammate by name. Log the speaker so the line matches who
|
||
# replies; note the owner's conversation it runs in when
|
||
# they differ, so routing + addressing read consistently.
|
||
speaker = self.agent_registry.get(speaker_agent_id)
|
||
if speaker_agent_id != resolved_agent_id:
|
||
owner = self.agent_registry.get(resolved_agent_id)
|
||
logger.info(
|
||
f"[Routing] → 🤖 {speaker.name}({speaker.id}) "
|
||
f"in {owner.name}({owner.id})'s conversation{via}"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"[Routing] → 🤖 {speaker.name}({speaker.id}){via}"
|
||
)
|
||
except Exception:
|
||
pass
|
||
# What the Agent is asked, once the addressing has been acted on.
|
||
# Kept apart from `query`, which stays verbatim for the transcript.
|
||
model_query = (
|
||
self._strip_address(query, speaker_agent_id)
|
||
if speaker_agent_id != resolved_agent_id
|
||
else query
|
||
)
|
||
|
||
# Register a cancel token. Prefer per-turn request_id (web),
|
||
# fall back to session_id (IM channels). The Event is polled by
|
||
# AgentStreamExecutor at safe checkpoints.
|
||
registry = get_cancel_registry()
|
||
# Both the cancel token and the session grouping are namespaced:
|
||
# session ids are only unique within one Agent, so two Agents
|
||
# serving the same id must not cancel or steer each other. The
|
||
# steer registry is keyed by the same scoped id.
|
||
scoped_session_id = (
|
||
self._cancel_key(
|
||
resolved_agent_id,
|
||
session_id,
|
||
self.agent_registry.default_agent_id,
|
||
)
|
||
if session_id
|
||
else None
|
||
)
|
||
token_key = request_id or session_id
|
||
if token_key:
|
||
token_key = self._cancel_key(
|
||
resolved_agent_id,
|
||
token_key,
|
||
self.agent_registry.default_agent_id,
|
||
)
|
||
cancel_event = registry.register(
|
||
token_key, session_id=scoped_session_id
|
||
)
|
||
|
||
# Get agent for this session (will auto-initialize if needed)
|
||
cached = self._has_runtime(speaker_agent_id, session_id)
|
||
agent = self.get_agent(
|
||
session_id=session_id,
|
||
agent_id=speaker_agent_id,
|
||
host_agent_id=resolved_agent_id,
|
||
permission_mode=context.get("delegated_permission_mode"),
|
||
)
|
||
if not agent:
|
||
return Reply(ReplyType.ERROR, "Failed to initialize super agent")
|
||
|
||
# A team conversation is one transcript. Each Agent caches its own
|
||
# in-memory list and only restores it on first init, so a teammate
|
||
# that already joined would miss later turns spoken by someone else
|
||
# (and the host would miss guest replies). Reload the shared
|
||
# transcript with author labels before this turn is appended; a
|
||
# runtime built for this turn has only just restored it.
|
||
if cached:
|
||
self._sync_shared_transcript(agent, session_id, resolved_agent_id)
|
||
|
||
# Create event handler for logging and channel communication
|
||
event_handler = AgentEventHandler(context=context, original_callback=on_event)
|
||
|
||
# Filter tools based on context
|
||
original_tools = agent.tools
|
||
filtered_tools = original_tools
|
||
|
||
# If this is a scheduled task execution, exclude scheduler tool to prevent recursion
|
||
if context and context.get("is_scheduled_task"):
|
||
filtered_tools = [tool for tool in agent.tools if tool.name != "scheduler"]
|
||
agent.tools = filtered_tools
|
||
logger.info(f"[AgentBridge] Scheduled task execution: excluded scheduler tool ({len(filtered_tools)}/{len(original_tools)} tools)")
|
||
|
||
if context and agent.tools:
|
||
for tool in agent.tools:
|
||
if tool.name == "scheduler" and not context.get("is_scheduled_task"):
|
||
try:
|
||
from agent.tools.scheduler.integration import attach_scheduler_to_tool
|
||
attach_scheduler_to_tool(tool, context)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Failed to attach context to scheduler: {e}")
|
||
elif tool.name == "agent_delegate":
|
||
try:
|
||
from agent.tools.agent_delegate.agent_delegate import attach_agent_delegate_to_tool
|
||
attach_agent_delegate_to_tool(tool, self, context)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Failed to attach delegation context: {e}")
|
||
|
||
# Pass context metadata to model for downstream API requests
|
||
if context and hasattr(agent, 'model'):
|
||
agent.model.channel_type = context.get("channel_type", "")
|
||
agent.model.session_id = session_id or ""
|
||
agent.model.agent_id = speaker_agent_id
|
||
|
||
# Store session_id on agent so executor can clear DB on fatal errors.
|
||
# The conversation's owner is what identifies the transcript, so a
|
||
# guest speaker still reads and writes the shared one.
|
||
agent._current_session_id = session_id
|
||
agent._current_agent_id = resolved_agent_id
|
||
|
||
# Bound the in-memory context for scheduler sessions before each run.
|
||
# Scheduler sessions are stable per-task and append every trigger,
|
||
# so without trimming they would grow unbounded across runs and
|
||
# blow up prompt cost. Regular user chats are not touched here —
|
||
# the agent's own context manager handles that path.
|
||
if session_id and session_id.startswith("scheduler_"):
|
||
from config import conf
|
||
scheduler_keep_turns = max(
|
||
1, int(conf().get("agent_max_context_turns", 30)) // 5
|
||
)
|
||
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
||
|
||
# Open the run before anything is persisted, so both the user turn
|
||
# and the reply are attributed to it and the work is addressable
|
||
# while it is still in flight.
|
||
run_id, run_token, run_store = self._begin_run(
|
||
session_id, resolved_agent_id, context
|
||
)
|
||
|
||
# Eagerly persist the user message BEFORE running the agent so the
|
||
# session and the user's bubble are immediately visible — even if
|
||
# the user switches away or refreshes before the reply finishes.
|
||
# The reply (assistant/tool messages) is appended step by step as
|
||
# the run goes; later writes skip this already-stored user turn.
|
||
pre_persisted = self._pre_persist_user_message(
|
||
session_id, query, context, clear_history, resolved_agent_id
|
||
)
|
||
|
||
channel_type = (context.get("channel_type") or "") if context else ""
|
||
|
||
def write_reply(messages: list):
|
||
# Stamp every reply with its author, the owner's included. In a
|
||
# shared conversation a guest reconstructs "who said what" from
|
||
# this stamp; if the owner's turns went unstamped they would read
|
||
# as unattributed, and a guest would mistake the owner's persona
|
||
# ("I am Gray…") for its own and answer in that voice.
|
||
messages = self._attribute_to_speaker(messages, speaker_agent_id)
|
||
messages = self._strip_speaker_prefix_from_messages(messages)
|
||
if messages:
|
||
persisted = self._persist_messages(
|
||
session_id,
|
||
list(messages),
|
||
channel_type,
|
||
resolved_agent_id,
|
||
create_if_missing=not pre_persisted,
|
||
)
|
||
if persisted is False:
|
||
raise RuntimeError("conversation write failed; step remains pending")
|
||
|
||
writer = StepWriter(write_reply, skip_query=pre_persisted) if session_id else None
|
||
|
||
def on_run_event(event):
|
||
# Store the step before announcing it: a listener hearing
|
||
# turn_end may rely on the step being in the transcript.
|
||
if writer is not None and event.get("type") == "turn_end":
|
||
writer.step()
|
||
event_handler.handle_event(event)
|
||
|
||
# Mark this session as mid-run so the self-evolution idle scan does
|
||
# not fire concurrently when a single turn runs longer than
|
||
# idle_minutes.
|
||
try:
|
||
from agent.evolution.trigger import mark_run_active
|
||
mark_run_active(agent, True)
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
if scoped_session_id:
|
||
steer_inbox = get_steer_registry().register(scoped_session_id)
|
||
# Use agent's run_stream method with event handler
|
||
response = agent.run_stream(
|
||
user_message=model_query,
|
||
on_event=on_run_event,
|
||
clear_history=clear_history,
|
||
cancel_event=cancel_event,
|
||
steer_inbox=steer_inbox,
|
||
# A scheduled task may legitimately have nothing to report
|
||
# (e.g. "notify me only if the price drops"). Nobody is
|
||
# waiting on this run, so an empty answer stays empty and
|
||
# the scheduler sends no message at all.
|
||
allow_empty_response=bool(context and context.get("is_scheduled_task")),
|
||
on_executor=writer.bind if writer is not None else None,
|
||
)
|
||
except Exception:
|
||
# Keep the steps finished before the failure.
|
||
if writer is not None:
|
||
writer.step()
|
||
raise
|
||
finally:
|
||
# Clear the mid-run flag so idle scans can review this session.
|
||
try:
|
||
from agent.evolution.trigger import mark_run_active
|
||
mark_run_active(agent, False)
|
||
except Exception:
|
||
pass
|
||
|
||
# Restore original tools
|
||
if context and context.get("is_scheduled_task"):
|
||
agent.tools = original_tools
|
||
|
||
# Log execution summary
|
||
event_handler.log_summary()
|
||
|
||
# Release cancel token; keep registry bounded.
|
||
if token_key:
|
||
try:
|
||
registry.unregister(token_key)
|
||
except Exception:
|
||
pass
|
||
if scoped_session_id and steer_inbox is not None:
|
||
get_steer_registry().unregister(scoped_session_id, steer_inbox)
|
||
|
||
# A cancelled turn is not a failure, but it is not a completed run
|
||
# either: the distinction is what tells a reader whether the result
|
||
# is trustworthy or simply absent.
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
run_status = "cancelled"
|
||
|
||
# Persist what this run added beyond the steps already stored
|
||
if writer is not None:
|
||
new_messages = list(getattr(agent, '_last_run_new_messages', []))
|
||
# The leading user turn was already persisted eagerly above;
|
||
# drop it here so it isn't stored twice.
|
||
if pre_persisted and new_messages and new_messages[0].get("role") == "user":
|
||
new_messages = new_messages[1:]
|
||
writer.finish(new_messages)
|
||
|
||
self.remember_context_usage(agent, session_id, resolved_agent_id)
|
||
|
||
# Record this user turn for the self-evolution idle trigger. Skip
|
||
# scheduler-injected / scheduled-task sessions so internal runs do
|
||
# not count as user activity.
|
||
if session_id and not session_id.startswith("scheduler_") and not (
|
||
context and (
|
||
context.get("is_scheduled_task")
|
||
or context.get("is_delegated_task")
|
||
)
|
||
):
|
||
try:
|
||
from agent.evolution.trigger import note_user_turn
|
||
ch = (context.get("channel_type") or "") if context else ""
|
||
rcv = (context.get("receiver") or "") if context else ""
|
||
is_group = bool(context.get("isgroup")) if context else False
|
||
# Only enable proactive push for single chats (group push is
|
||
# noisy); group sessions still evolve, just without notify.
|
||
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||
except Exception:
|
||
pass
|
||
|
||
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||
# sync any new/removed MCP tools into the live agent in the
|
||
# background. Off the critical path so user latency is unaffected;
|
||
# changes take effect on the user's next message.
|
||
self._schedule_mcp_hot_reload(agent)
|
||
|
||
if isinstance(response, str):
|
||
response = self._strip_speaker_prefix(
|
||
response, self._roster_speaker_labels()
|
||
)
|
||
|
||
# Check if there are files to send (from send/read tool)
|
||
if hasattr(agent, 'stream_executor') and hasattr(agent.stream_executor, 'files_to_send'):
|
||
files_to_send = agent.stream_executor.files_to_send
|
||
if files_to_send:
|
||
logger.info(
|
||
f"[AgentBridge] Sending {len(files_to_send)} file(s), "
|
||
f"first={files_to_send[0].get('path')}"
|
||
)
|
||
|
||
# Clear files_to_send for next request
|
||
agent.stream_executor.files_to_send = []
|
||
|
||
# The reply pipeline carries one Reply, so the remaining
|
||
# files ride along and the channel sends them afterwards.
|
||
# Only the first carries the text, or it would repeat.
|
||
reply = self._create_file_reply(files_to_send[0], response, context)
|
||
extras = [
|
||
self._create_file_reply(f, "", context)
|
||
for f in files_to_send[1:]
|
||
]
|
||
if extras:
|
||
reply.extra_replies = extras
|
||
return reply
|
||
|
||
return Reply(ReplyType.TEXT, response)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Agent reply error: {e}")
|
||
run_status = "failed"
|
||
run_error = str(e)
|
||
# The in-memory context may have been reset to recover from a format
|
||
# error or overflow, but the stored history is deliberately left
|
||
# intact: it is irreplaceable and is never reloaded with tool blocks.
|
||
# Release cancel token on error path too (idempotent).
|
||
if cancel_event is not None and token_key:
|
||
try:
|
||
get_cancel_registry().unregister(token_key)
|
||
except Exception:
|
||
pass
|
||
if scoped_session_id and steer_inbox is not None:
|
||
try:
|
||
get_steer_registry().unregister(scoped_session_id, steer_inbox)
|
||
except Exception:
|
||
pass
|
||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||
|
||
finally:
|
||
self._end_run(run_store, run_id, run_token, run_status, run_error)
|
||
|
||
def _schedule_mcp_hot_reload(self, agent):
|
||
"""
|
||
Fire-and-forget: detect mcp.json edits and reconcile the agent's
|
||
tool dict in the background. Runs after the user's reply is sent,
|
||
so any cost (file stat, hash, server boot) never adds to user latency.
|
||
Failures are isolated and never raise into the message pipeline.
|
||
"""
|
||
import threading
|
||
from agent.tools import ToolManager
|
||
from common.runtime_identity import wrap
|
||
|
||
def _run():
|
||
try:
|
||
tm = ToolManager()
|
||
tm.refresh_mcp_if_changed()
|
||
added, removed = tm.sync_mcp_into_agent(agent)
|
||
if added or removed:
|
||
logger.info(
|
||
f"[AgentBridge] Agent tools synced — "
|
||
f"added={added}, removed={removed}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] MCP hot-reload failed (non-fatal): {e}")
|
||
|
||
# wrap carries the routed identity into the thread; without it this
|
||
# would reload the default Agent's mcp.json and sync its tools into
|
||
# whichever Agent actually served the message.
|
||
threading.Thread(
|
||
target=wrap(_run), daemon=True, name="mcp-hot-reload"
|
||
).start()
|
||
|
||
def _create_file_reply(self, file_info: dict, text_response: str, context: Context = None) -> Reply:
|
||
"""
|
||
Create a reply for sending files
|
||
|
||
Args:
|
||
file_info: File metadata from read tool
|
||
text_response: Text response from agent
|
||
context: Context object
|
||
|
||
Returns:
|
||
Reply object for file sending
|
||
"""
|
||
file_type = file_info.get("file_type", "file")
|
||
file_path = file_info.get("path")
|
||
# Remote URLs are passed through as-is; local paths get a file:// prefix
|
||
# so the channel can read them from disk.
|
||
remote_url = file_info.get("url", "")
|
||
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||
|
||
def _to_channel_url(p: str) -> str:
|
||
if is_remote:
|
||
return remote_url
|
||
if p and p.lower().startswith(("http://", "https://")):
|
||
return p
|
||
return f"file://{p}"
|
||
|
||
# For images, use IMAGE_URL type (channel will handle upload)
|
||
if file_type == "image":
|
||
file_url = _to_channel_url(file_path)
|
||
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
||
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
||
# Attach text message if present (for channels that support text+image)
|
||
if text_response:
|
||
reply.text_content = text_response # Store accompanying text
|
||
return reply
|
||
|
||
# For all file types (document, video, audio), use FILE type
|
||
if file_type in ["document", "video", "audio"]:
|
||
file_url = _to_channel_url(file_path)
|
||
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
||
reply = Reply(ReplyType.FILE, file_url)
|
||
reply.file_type = file_type
|
||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||
# Attach text message if present
|
||
if text_response:
|
||
reply.text_content = text_response
|
||
return reply
|
||
|
||
# For all other file types (tar.gz, zip, etc.), also use FILE type
|
||
file_url = _to_channel_url(file_path)
|
||
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
|
||
reply = Reply(ReplyType.FILE, file_url)
|
||
reply.file_type = file_type
|
||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||
if text_response:
|
||
reply.text_content = text_response
|
||
return reply
|
||
|
||
def _migrate_config_to_env(self, workspace_root: str):
|
||
"""
|
||
Sync API keys from config.json to .env file.
|
||
Adds new keys and updates changed values on each startup.
|
||
|
||
Args:
|
||
workspace_root: Workspace directory path (not used, kept for compatibility)
|
||
"""
|
||
from config import conf
|
||
import os
|
||
|
||
key_mapping = {
|
||
"open_ai_api_key": "OPENAI_API_KEY",
|
||
"open_ai_api_base": "OPENAI_API_BASE",
|
||
"gemini_api_key": "GEMINI_API_KEY",
|
||
"claude_api_key": "CLAUDE_API_KEY",
|
||
"linkai_api_key": "LINKAI_API_KEY",
|
||
}
|
||
|
||
env_file = expand_path("~/.cow/.env")
|
||
|
||
# Read existing env vars (key -> value)
|
||
existing_env_vars = {}
|
||
if os.path.exists(env_file):
|
||
try:
|
||
with open(env_file, 'r', encoding='utf-8') as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line and not line.startswith('#') and '=' in line:
|
||
key, val = line.split('=', 1)
|
||
existing_env_vars[key.strip()] = val.strip()
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Failed to read .env file: {e}")
|
||
|
||
# Sync config.json values into .env (add/update/remove)
|
||
updated = False
|
||
for config_key, env_key in key_mapping.items():
|
||
raw = conf().get(config_key, "")
|
||
value = raw.strip() if raw else ""
|
||
old_value = existing_env_vars.get(env_key)
|
||
|
||
if value:
|
||
if old_value == value:
|
||
continue
|
||
existing_env_vars[env_key] = value
|
||
os.environ[env_key] = value
|
||
updated = True
|
||
else:
|
||
if old_value is None:
|
||
continue
|
||
existing_env_vars.pop(env_key, None)
|
||
os.environ.pop(env_key, None)
|
||
updated = True
|
||
updated = True
|
||
|
||
if updated:
|
||
try:
|
||
env_dir = os.path.dirname(env_file)
|
||
os.makedirs(env_dir, exist_ok=True)
|
||
|
||
with open(env_file, 'w', encoding='utf-8') as f:
|
||
f.write('# Environment variables for agent\n')
|
||
f.write('# Auto-managed - synced from config.json on startup\n\n')
|
||
for key, value in sorted(existing_env_vars.items()):
|
||
f.write(f'{key}={value}\n')
|
||
|
||
logger.info("[AgentBridge] Synced API keys from config.json to .env")
|
||
except Exception as e:
|
||
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
|
||
|
||
def _pre_persist_user_message(
|
||
self,
|
||
session_id: str,
|
||
query: str,
|
||
context: Context,
|
||
clear_history: bool,
|
||
agent_id: str = None,
|
||
) -> bool:
|
||
"""Persist the user's message before the agent runs.
|
||
|
||
This makes a brand-new session (and the user's bubble) visible even if
|
||
the reply hasn't finished — switching away or refreshing no longer
|
||
loses the in-flight session. Returns True when the user turn was
|
||
stored, so the caller can skip it in the post-run persist.
|
||
|
||
Best-effort: any failure is swallowed and reported as not-persisted.
|
||
"""
|
||
if not session_id or not query:
|
||
return False
|
||
# Only real user turns: skip scheduler-injected / scheduled-task runs.
|
||
if session_id.startswith("scheduler_") or (
|
||
context and context.get("is_scheduled_task")
|
||
):
|
||
return False
|
||
try:
|
||
from config import conf
|
||
if not conf().get("conversation_persistence", True):
|
||
return False
|
||
store = self.get_conversation_store(agent_id)
|
||
# clear_history starts a fresh transcript: wipe the store first so
|
||
# the eager user turn becomes seq 0, matching in-memory state.
|
||
if clear_history:
|
||
store.clear_session(session_id)
|
||
channel_type = (context.get("channel_type") or "") if context else ""
|
||
user_msg = {
|
||
"role": "user",
|
||
"content": [{"type": "text", "text": query}],
|
||
}
|
||
display = context.get("display") if context else None
|
||
if display:
|
||
user_msg["extras"] = {"display": display}
|
||
store.append_messages(session_id, [user_msg], channel_type=channel_type)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}"
|
||
)
|
||
return False
|
||
|
||
def _persist_messages(
|
||
self,
|
||
session_id: str,
|
||
new_messages: list,
|
||
channel_type: str = "",
|
||
agent_id: str = None,
|
||
create_if_missing: bool = True,
|
||
) -> bool:
|
||
"""
|
||
Persist new messages to the conversation store after each agent run.
|
||
|
||
Failures are logged but never propagate — they must not interrupt replies.
|
||
|
||
``create_if_missing=False`` is used once the user turn is known to be
|
||
stored already: a missing session row then means the user deleted the
|
||
session while the reply was still running, so the reply is dropped
|
||
instead of resurrecting the session without its question.
|
||
"""
|
||
if not new_messages:
|
||
return True
|
||
try:
|
||
from config import conf
|
||
if not conf().get("conversation_persistence", True):
|
||
return True
|
||
# When deep-thinking display is disabled, strip "thinking" content
|
||
# blocks before persisting so they don't resurface on history reload.
|
||
# The in-memory message list keeps them intact for this run's
|
||
# multi-turn LLM context.
|
||
thinking_enabled = bool(conf().get("enable_thinking", False))
|
||
if not thinking_enabled:
|
||
from models.reasoning_capabilities import get_reasoning_capability
|
||
|
||
# Thinking-only models need their reasoning trace in stored
|
||
# history so the next tool-calling turn can echo it back.
|
||
capability = get_reasoning_capability(
|
||
AgentLLMModel(None)._resolve_bot_type(conf().get("model", "")),
|
||
conf().get("model", ""),
|
||
)
|
||
thinking_enabled = bool(capability.get("thinking_only"))
|
||
except Exception:
|
||
thinking_enabled = False
|
||
|
||
messages_to_store = new_messages
|
||
if not thinking_enabled:
|
||
messages_to_store = self._strip_thinking_blocks(new_messages)
|
||
|
||
stored = False
|
||
try:
|
||
store = self.get_conversation_store(agent_id)
|
||
stored = store.append_messages(
|
||
session_id, messages_to_store, channel_type=channel_type,
|
||
create_if_missing=create_if_missing
|
||
)
|
||
if stored:
|
||
from agent.protocol.artifact import index_message_artifacts
|
||
index_message_artifacts(store, session_id, messages_to_store, agent_id=agent_id)
|
||
elif not create_if_missing:
|
||
logger.info(
|
||
f"[AgentBridge] Session {session_id} was deleted mid-run, "
|
||
f"dropped {len(messages_to_store)} reply message(s)"
|
||
)
|
||
# A deleted session is an intentional discard, not a retryable failure.
|
||
return bool(stored) or not create_if_missing
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Failed to persist messages for session={session_id}: {e}"
|
||
)
|
||
# Artifact indexing happens after the append committed; do not replay it.
|
||
return bool(stored)
|
||
|
||
def remember_context_usage(
|
||
self, agent, session_id: str, agent_id: str = None
|
||
) -> None:
|
||
"""Store the context usage a run ended on, so the chart survives a restart.
|
||
Best-effort: never fails the turn."""
|
||
if not session_id:
|
||
return
|
||
try:
|
||
store = self.get_conversation_store(agent_id)
|
||
store.set_context_usage(session_id, agent.get_context_usage())
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] Context usage snapshot skipped: {e}")
|
||
|
||
# Marker used to identify scheduler-injected user messages so we can apply
|
||
# a sliding window without touching real user turns. The legacy prefix
|
||
# "Scheduled task" (written by the v2 PR) is also recognised when pruning,
|
||
# so old data can be aged out instead of leaking forever.
|
||
_SCHEDULED_MARKER = "[SCHEDULED]"
|
||
_SCHEDULED_LEGACY_MARKERS = ("Scheduled task",)
|
||
|
||
def remember_scheduled_output(
|
||
self,
|
||
session_id: str,
|
||
content: str,
|
||
channel_type: str = "",
|
||
task_description: str = "",
|
||
agent_id: str = None,
|
||
run_id: str = "",
|
||
) -> None:
|
||
"""Add the visible output of a scheduled task to the receiver's session.
|
||
|
||
Scheduled task execution uses an isolated session so internal planning and
|
||
tool calls do not leak into the user's chat. The final message is still
|
||
part of the conversation from the user's point of view, so keep a small
|
||
visible turn in the receiver session for follow-up questions.
|
||
|
||
Configuration:
|
||
scheduler_inject_to_session (bool, default True):
|
||
Master switch. When False, this method is a no-op.
|
||
scheduler_inject_max_per_session (int, default 3):
|
||
Maximum scheduler-injected user/assistant pairs retained per
|
||
session. Older injections are pruned automatically.
|
||
|
||
``run_id`` associates the stored pair with its scheduler execution;
|
||
callers without an id remain supported without guessing association.
|
||
|
||
Content is truncated to 4000 chars to prevent a single high-volume task
|
||
from bloating one entry, while staying long enough that the history
|
||
detail view (which recovers this copy) shows the full message for the
|
||
vast majority of tasks. An ellipsis marks the rare over-limit case.
|
||
"""
|
||
from config import conf
|
||
if not conf().get("scheduler_inject_to_session", True):
|
||
return
|
||
if not session_id or not content:
|
||
return
|
||
|
||
max_len = 4000
|
||
if len(content) > max_len:
|
||
content = content[:max_len].rstrip() + "…"
|
||
|
||
user_text = self._SCHEDULED_MARKER
|
||
if task_description:
|
||
user_text = f"{self._SCHEDULED_MARKER} {task_description}"
|
||
|
||
messages = [
|
||
{"role": "user", "content": [{"type": "text", "text": user_text}]},
|
||
{"role": "assistant", "content": [{"type": "text", "text": content}]},
|
||
]
|
||
|
||
# Association is storage metadata, not a field in model messages.
|
||
persisted_messages = (
|
||
[dict(message, run_id=run_id) for message in messages] if run_id else messages
|
||
)
|
||
|
||
# Persist first so the new pair gets a stable seq, then prune old
|
||
# scheduler pairs in DB, then sync the in-memory agent.messages buffer.
|
||
self._persist_messages(session_id, persisted_messages, channel_type, agent_id)
|
||
|
||
keep_last_n = max(int(conf().get("scheduler_inject_max_per_session", 3) or 0), 0)
|
||
try:
|
||
deleted = self.get_conversation_store(agent_id).prune_scheduled_messages(
|
||
session_id, keep_last_n=keep_last_n
|
||
)
|
||
if deleted:
|
||
logger.debug(
|
||
f"[AgentBridge] Pruned {deleted} old scheduler messages "
|
||
f"for session={session_id} (keep_last_n={keep_last_n})"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Failed to prune scheduled messages "
|
||
f"for session={session_id}: {e}"
|
||
)
|
||
|
||
agent = self.get_cached_agent(session_id, agent_id=agent_id)
|
||
if agent:
|
||
try:
|
||
with agent.messages_lock:
|
||
agent.messages.extend(messages)
|
||
self._prune_scheduled_in_memory(agent, keep_last_n)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[AgentBridge] Failed to update in-memory scheduled output "
|
||
f"for session={session_id}: {e}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _trim_in_memory_to_turns(agent, keep_turns: int) -> None:
|
||
"""Bound ``agent.messages`` to the most recent ``keep_turns`` real
|
||
user/assistant turns, dropping older history together with any
|
||
intermediate tool_use/tool_result blocks that belonged to it.
|
||
|
||
A "real" user message is any user message whose content is not solely a
|
||
tool_result block — matches the heuristic used elsewhere when filtering
|
||
history (see ``AgentInitializer._filter_text_only_messages``).
|
||
|
||
No-op when the session is already within budget. Caller does not need
|
||
to hold the lock; this method acquires it itself.
|
||
"""
|
||
if keep_turns <= 0:
|
||
return
|
||
|
||
def _is_real_user(msg) -> bool:
|
||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||
return False
|
||
content = msg.get("content")
|
||
if isinstance(content, list):
|
||
if any(
|
||
isinstance(b, dict) and b.get("type") == "tool_result"
|
||
for b in content
|
||
):
|
||
return False
|
||
return any(
|
||
isinstance(b, dict) and b.get("type") == "text" and b.get("text")
|
||
for b in content
|
||
)
|
||
if isinstance(content, str):
|
||
return bool(content.strip())
|
||
return False
|
||
|
||
with agent.messages_lock:
|
||
msgs = agent.messages
|
||
real_user_indices = [i for i, m in enumerate(msgs) if _is_real_user(m)]
|
||
if len(real_user_indices) <= keep_turns:
|
||
return
|
||
|
||
# Cut at the (k-th from the end) real user message; keep everything
|
||
# from there onwards so the surviving slice is still a valid
|
||
# user/assistant sequence.
|
||
cut_idx = real_user_indices[-keep_turns]
|
||
if cut_idx == 0:
|
||
return
|
||
|
||
kept = msgs[cut_idx:]
|
||
msgs.clear()
|
||
msgs.extend(kept)
|
||
logger.debug(
|
||
f"[AgentBridge] Trimmed in-memory messages to last "
|
||
f"{keep_turns} turns ({len(kept)} messages remain)"
|
||
)
|
||
|
||
@classmethod
|
||
def _prune_scheduled_in_memory(cls, agent, keep_last_n: int) -> None:
|
||
"""Mirror conversation_store.prune_scheduled_messages on agent.messages.
|
||
|
||
Caller must hold ``agent.messages_lock``.
|
||
"""
|
||
if keep_last_n < 0:
|
||
keep_last_n = 0
|
||
|
||
markers = (cls._SCHEDULED_MARKER,) + cls._SCHEDULED_LEGACY_MARKERS
|
||
|
||
def _is_marker_user(msg) -> bool:
|
||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||
return False
|
||
content = msg.get("content")
|
||
text = ""
|
||
if isinstance(content, str):
|
||
text = content
|
||
elif isinstance(content, list):
|
||
for block in content:
|
||
if isinstance(block, dict) and block.get("type") == "text":
|
||
text = block.get("text", "")
|
||
break
|
||
return any(text.startswith(m) for m in markers)
|
||
|
||
msgs = agent.messages
|
||
pair_indices = [] # list of (user_idx, assistant_idx_or_None)
|
||
for idx, msg in enumerate(msgs):
|
||
if not _is_marker_user(msg):
|
||
continue
|
||
assistant_idx = None
|
||
if idx + 1 < len(msgs):
|
||
nxt = msgs[idx + 1]
|
||
if isinstance(nxt, dict) and nxt.get("role") == "assistant":
|
||
assistant_idx = idx + 1
|
||
pair_indices.append((idx, assistant_idx))
|
||
|
||
if len(pair_indices) <= keep_last_n:
|
||
return
|
||
|
||
to_drop = pair_indices[: len(pair_indices) - keep_last_n]
|
||
drop_set = set()
|
||
for u_idx, a_idx in to_drop:
|
||
drop_set.add(u_idx)
|
||
if a_idx is not None:
|
||
drop_set.add(a_idx)
|
||
|
||
# Rebuild the list in place to keep external references stable.
|
||
kept = [m for i, m in enumerate(msgs) if i not in drop_set]
|
||
msgs.clear()
|
||
msgs.extend(kept)
|
||
|
||
@staticmethod
|
||
def _strip_thinking_blocks(messages: list) -> list:
|
||
"""Return a shallow copy of messages with assistant "thinking" blocks removed."""
|
||
cleaned = []
|
||
for msg in messages:
|
||
if not isinstance(msg, dict):
|
||
cleaned.append(msg)
|
||
continue
|
||
if msg.get("role") != "assistant":
|
||
cleaned.append(msg)
|
||
continue
|
||
content = msg.get("content")
|
||
if not isinstance(content, list):
|
||
cleaned.append(msg)
|
||
continue
|
||
filtered_blocks = [
|
||
b for b in content
|
||
if not (isinstance(b, dict) and b.get("type") == "thinking")
|
||
]
|
||
if len(filtered_blocks) == len(content):
|
||
cleaned.append(msg)
|
||
else:
|
||
new_msg = dict(msg)
|
||
new_msg["content"] = filtered_blocks
|
||
cleaned.append(new_msg)
|
||
return cleaned
|
||
|
||
def clear_session(self, session_id: str, agent_id: str = None):
|
||
"""
|
||
Drop the cached agent for a session. Persisted history is untouched;
|
||
the next request rebuilds the agent and restores from the store.
|
||
|
||
Args:
|
||
session_id: Session identifier to clear
|
||
"""
|
||
if not session_id:
|
||
return
|
||
resolved_agent_id = self._resolve_agent_id(agent_id)
|
||
key = self._runtime_key(resolved_agent_id, session_id)
|
||
with self._agents_lock:
|
||
removed = self._agent_instances.pop(key, None)
|
||
if resolved_agent_id == self.agent_registry.default_agent_id:
|
||
self.agents.pop(session_id, None)
|
||
if removed is not None:
|
||
logger.info(
|
||
f"[AgentBridge] Clearing session: agent={resolved_agent_id}, "
|
||
f"session={session_id}"
|
||
)
|
||
|
||
def clear_agent(self, agent_id: str) -> int:
|
||
"""Evict every live runtime instance for one agent workspace."""
|
||
resolved_agent_id = self._resolve_agent_id(agent_id)
|
||
with self._agents_lock:
|
||
keys = [key for key in self._agent_instances if key[0] == resolved_agent_id]
|
||
for key in keys:
|
||
self._agent_instances.pop(key, None)
|
||
self._default_agents.pop(resolved_agent_id, None)
|
||
if resolved_agent_id == self.agent_registry.default_agent_id:
|
||
self.agents.clear()
|
||
self.default_agent = None
|
||
return len(keys)
|
||
|
||
def clear_all_sessions(self):
|
||
"""Clear all agent sessions"""
|
||
with self._agents_lock:
|
||
count = len(self._agent_instances)
|
||
logger.info(f"[AgentBridge] Clearing all sessions ({count} total)")
|
||
self._agent_instances.clear()
|
||
self._default_agents.clear()
|
||
self.agents.clear()
|
||
self.default_agent = None
|
||
|
||
def refresh_all_skills(self) -> int:
|
||
"""
|
||
Refresh skills and conditional tools in all agent instances after
|
||
environment variable changes. This allows hot-reload without restarting.
|
||
|
||
Returns:
|
||
Number of agent instances refreshed
|
||
"""
|
||
import os
|
||
from dotenv import load_dotenv
|
||
from common.state_dir import env_file as workspace_env_file
|
||
|
||
refreshed_count = 0
|
||
loaded_env_files = set()
|
||
|
||
# Each live agent reloads its own workspace .env, not just the one the
|
||
# caller happens to be routed to.
|
||
for agent_id, session_id, agent in self.iter_agent_instances():
|
||
workspace_root = getattr(agent, "workspace_dir", None)
|
||
env_file = str(workspace_env_file(base=workspace_root)) if workspace_root else None
|
||
if env_file and env_file not in loaded_env_files and os.path.exists(env_file):
|
||
load_dotenv(env_file, override=True)
|
||
loaded_env_files.add(env_file)
|
||
logger.info(
|
||
f"[AgentBridge] Reloaded environment variables from {env_file}"
|
||
)
|
||
# Refresh skills
|
||
if hasattr(agent, 'skill_manager') and agent.skill_manager:
|
||
agent.skill_manager.refresh_skills()
|
||
|
||
# Refresh conditional tools (e.g. web_search depends on API keys)
|
||
self._refresh_conditional_tools(agent)
|
||
|
||
refreshed_count += 1
|
||
|
||
if refreshed_count > 0:
|
||
logger.info(f"[AgentBridge] Refreshed skills & tools in {refreshed_count} agent instance(s)")
|
||
|
||
return refreshed_count
|
||
|
||
@staticmethod
|
||
def _refresh_conditional_tools(agent):
|
||
"""
|
||
Add or remove conditional tools based on current environment variables.
|
||
For example, web_search should only be present when BOCHA_API_KEY or
|
||
LINKAI_API_KEY is set.
|
||
"""
|
||
try:
|
||
from agent.tools.web_search.web_search import WebSearch
|
||
|
||
has_tool = any(t.name == "web_search" for t in agent.tools)
|
||
available = WebSearch.is_available()
|
||
|
||
if available and not has_tool:
|
||
# API key was added - inject the tool
|
||
tool = WebSearch()
|
||
tool.model = agent.model
|
||
agent.tools.append(tool)
|
||
logger.info("[AgentBridge] web_search tool added (API key now available)")
|
||
elif not available and has_tool:
|
||
# API key was removed - remove the tool
|
||
agent.tools = [t for t in agent.tools if t.name != "web_search"]
|
||
logger.info("[AgentBridge] web_search tool removed (API key no longer available)")
|
||
except Exception as e:
|
||
logger.debug(f"[AgentBridge] Failed to refresh conditional tools: {e}")
|