Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions backend/app/api/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@

router = APIRouter(tags=["feishu"])

_FEISHU_GROUP_PASSIVE_INSTRUCTION = (
"You are passively listening in a Feishu group. A message directly addresses you if it "
"@mentions you, names you or your Agent name, asks you a question or gives you an "
"instruction, or explicitly asks you to reply. You must visibly answer every directly "
"addressed message even when it is outside your usual responsibilities. For messages "
"that do not directly address you, reply normally only when your responsibilities require "
"a visible response; otherwise your entire final response must be exactly NO_REPLY, with "
"no other text. Your final response is automatically delivered to the input Feishu group. "
"Never call send_channel_message to reply to the current conversation. Use that Tool only "
"when the user explicitly asks you to send a separate message to another person or group, "
"and then set cross_session_confirmed=true."
)

_USER_RESOLUTION_ERROR_TIP = (
"抱歉,我暂时无法稳定识别你的飞书账号,已停止本次处理以避免重复创建账号。"
"请稍后重试,或联系管理员检查飞书 Contact API 权限。"
Expand Down Expand Up @@ -402,10 +415,17 @@ async def _accept_feishu_runtime_message(
created_by_user_id=user.id,
)
_, model, _ = await _load_agent_and_model(db, agent_id)
sender_name = (user.display_name or "").strip()
executable_content = (
f"[发送者: {sender_name}] {content}" if sender_name else content
sender_name = (user.display_name or "").strip() or "未知用户"
sender_identity = " | ".join(
part
for part in (
f"飞书发送者: {sender_name}",
f"user_id: {sender_user_id.strip()}" if sender_user_id.strip() else "",
f"open_id: {sender_open_id.strip()}" if sender_open_id.strip() else "",
)
if part
)
executable_content = f"[{sender_identity}] {content}"
intake = await enqueue_channel_chat_runtime(
db,
agent=agent,
Expand All @@ -414,6 +434,9 @@ async def _accept_feishu_runtime_message(
model=model,
content=executable_content,
display_content=display_content,
runtime_instruction=(
_FEISHU_GROUP_PASSIVE_INSTRUCTION if is_group else ""
),
source_channel="feishu",
channel_delivery_target={
"receive_id": chat_id if is_group else sender_open_id,
Expand Down Expand Up @@ -492,12 +515,20 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict):
if event_type == "im.message.receive_v1":
message = event.get("message", {})
sender = event.get("sender", {}).get("sender_id", {})
sender_type = event.get("sender", {}).get("sender_type", "")
sender_open_id = sender.get("open_id", "")
sender_user_id_from_event = sender.get("user_id", "") # tenant-stable ID, available directly in event body
msg_type = message.get("message_type", "text")
chat_type = message.get("chat_type", "p2p") # p2p or group
chat_id = message.get("chat_id", "")

if chat_type == "group" and sender_type and sender_type != "user":
logger.info(
"[Feishu] Ignoring non-user group message sender_type={}",
sender_type,
)
return {"code": 0, "msg": "non-user group message ignored"}

logger.info(f"[Feishu] Received {msg_type} message, chat_type={chat_type}, open_id={sender_open_id!r}, user_id_from_event={sender_user_id_from_event!r}")

# ── Normalize post (rich text) → extract text + schedule image downloads ──
Expand Down Expand Up @@ -574,7 +605,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict):
sender_user_id=sender_user_id_from_event,
chat_type=chat_type,
chat_id=chat_id,
external_event_id=event_id or message.get("message_id"),
external_event_id=message.get("message_id") or event_id,
)
if attachment is not None:
if event_id:
Expand Down Expand Up @@ -612,7 +643,7 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict):
chat_id=chat_id,
content=user_text,
display_content=display_content,
external_event_id=event_id or message.get("message_id"),
external_event_id=message.get("message_id") or event_id,
)
except Exception as exc:
from app.services.channel_user_service import ChannelUserResolutionError
Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/agent_runtime/channel_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ async def enqueue_channel_chat_runtime(
channel_delivery_target: dict,
display_content: str = "",
file_name: str = "",
runtime_instruction: str = "",
) -> ChatRuntimeIntake:
"""Atomically attach a channel message to a new or waiting Chat Run."""
if agent.tenant_id is None or model is None:
Expand All @@ -139,6 +140,7 @@ async def enqueue_channel_chat_runtime(
content=content,
display_content=display_content,
file_name=file_name,
runtime_instruction=runtime_instruction,
message_id=message_id,
resume_run_id=resume[0] if resume is not None else None,
resume_correlation_id=resume[1] if resume is not None else None,
Expand Down
38 changes: 34 additions & 4 deletions backend/app/services/agent_runtime/chat_intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ def _direct_lane_key(tenant_id: uuid.UUID, session_id: uuid.UUID) -> str:
return f"direct_chat_thread:{tenant_id}:{session_id}"


def _external_group_lane_key(tenant_id: uuid.UUID, session_id: uuid.UUID) -> str:
return f"external_group_thread:{tenant_id}:{session_id}"


async def _direct_lane_holder(
db: AsyncSession,
*,
Expand Down Expand Up @@ -673,6 +677,12 @@ async def enqueue_chat_runtime(
if channel_delivery_route is not None:
delivery_target["channel_delivery"] = channel_delivery_route
is_direct_thread = session.session_type == "direct"
is_external_group_thread = (
session.session_type == "group"
and session.group_id is None
and normalized_channel != "web"
)
uses_session_thread = is_direct_thread or is_external_group_thread
scheduling_position_created_at = (
persisted_message.created_at
if persisted_message is not None
Expand All @@ -692,16 +702,26 @@ async def enqueue_chat_runtime(
goal=_chat_goal(content, display_content, file_name),
run_kind="foreground",
model_id=model.id,
runtime_thread_id=(str(session.id) if is_direct_thread else None),
runtime_thread_id=(str(session.id) if uses_session_thread else None),
scheduling_lane_key=(
_direct_lane_key(tenant_id, session.id)
if is_direct_thread
else None
else (
_external_group_lane_key(tenant_id, session.id)
if is_external_group_thread
else None
)
),
scheduling_position_created_at=(
scheduling_position_created_at if is_direct_thread else None
scheduling_position_created_at
if session.session_type in {"direct", "group"}
else None
),
scheduling_position_id=(
resolved_message_id
if session.session_type in {"direct", "group"}
else None
),
scheduling_position_id=(resolved_message_id if is_direct_thread else None),
delivery_status="pending",
delivery_target=delivery_target,
idempotency_key=f"start:{source_execution_id}",
Expand All @@ -711,6 +731,16 @@ async def enqueue_chat_runtime(
"source_channel": normalized_channel,
"user_id": str(user.id),
"application_tools_enabled": application_tools_enabled,
**(
{
"context_cutoff": {
"message_id": str(resolved_message_id),
"created_at": scheduling_position_created_at.isoformat(),
}
}
if session.session_type == "group"
else {}
),
**(
{"runtime_instruction": normalized_runtime_instruction}
if normalized_runtime_instruction
Expand Down
30 changes: 23 additions & 7 deletions backend/app/services/agent_runtime/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -919,14 +919,30 @@ async def deliver_runtime_message(
)
chat_message_dao.add_scoped(db, message, tenant_id=run.tenant_id)
session.last_message_at = now()
channel_delivery = stage_channel_delivery(
db,
run=run,
session=session,
message_id=message.id,
idempotency_key=request.idempotency_key,
clock=now,
route = (run.delivery_target or {}).get("channel_delivery")
route_target = route.get("target") if isinstance(route, dict) else None
suppress_feishu_group_reply = (
request.kind == "terminal"
and request.lifecycle_status == "completed"
and session.session_type == "group"
and session.group_id is None
and session.source_channel == "feishu"
and isinstance(route, dict)
and route.get("channel") == "feishu"
and isinstance(route_target, dict)
and route_target.get("receive_id_type") == "chat_id"
and message.content.strip().casefold() == "no_reply"
)
channel_delivery = None
if not suppress_feishu_group_reply:
channel_delivery = stage_channel_delivery(
db,
run=run,
session=session,
message_id=message.id,
idempotency_key=request.idempotency_key,
clock=now,
)
receipt = DeliveryReceipt(
tenant_id=run.tenant_id,
run_id=run.id,
Expand Down
82 changes: 66 additions & 16 deletions backend/app/services/agent_runtime/session_context_background.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,40 @@ async def resolve(
)

if session.group_id is None:
raise SessionContextBackgroundError(
"session_compact_budget_unavailable",
"Group session has no group identity",
if session.source_channel != "feishu" or session.agent_id is None:
raise SessionContextBackgroundError(
"session_compact_budget_unavailable",
"External Group session has no owning Agent",
)
agent_result = await db.execute(
select(Agent).where(
Agent.id == session.agent_id,
Agent.tenant_id == tenant_id,
Agent.status.in_(_ACTIVE_AGENT_STATUSES),
Agent.is_expired.is_(False),
Agent.deleted_at.is_(None),
)
)
agent = agent_result.scalar_one_or_none()
if agent is None:
raise SessionContextBackgroundError(
"session_compact_budget_unavailable",
"External Group Session Agent is unavailable",
)
model = await resolve_active_agent_model(db, agent)
if model is None:
raise SessionContextBackgroundError(
"session_compact_budget_unavailable",
"External Group Session Agent has no active model",
)
try:
threshold = _model_threshold(model, self._settings)
except ModelCapabilityError as exc:
raise SessionContextBackgroundError(exc.code, str(exc)) from exc
return SessionCompactPolicy(
source_agent_id=agent.id,
threshold_tokens=threshold,
contributing_model_ids=(model.id,),
)
group_result = await db.execute(
select(Group.id).where(
Expand Down Expand Up @@ -411,19 +442,16 @@ def __init__(

async def scan_once(self) -> int:
async with self._session_factory() as db:
statement = (
select(ChatSession.tenant_id, ChatSession.id)
.join(
Group,
(Group.id == ChatSession.group_id)
& (Group.tenant_id == ChatSession.tenant_id),
)
.where(
ChatSession.deleted_at.is_(None),
ChatSession.last_message_at.is_not(None),
ChatSession.session_type == "group",
Group.deleted_at.is_(None),
sa.exists(
native_group = sa.and_(
ChatSession.group_id.is_not(None),
sa.exists(
select(1).where(
Group.id == ChatSession.group_id,
Group.tenant_id == ChatSession.tenant_id,
Group.deleted_at.is_(None),
)
),
sa.exists(
select(1)
.select_from(GroupMember)
.join(
Expand All @@ -445,6 +473,28 @@ async def scan_once(self) -> int:
Agent.deleted_at.is_(None),
)
),
)
external_feishu_group = sa.and_(
ChatSession.group_id.is_(None),
ChatSession.source_channel == "feishu",
ChatSession.agent_id.is_not(None),
sa.exists(
select(1).where(
Agent.id == ChatSession.agent_id,
Agent.tenant_id == ChatSession.tenant_id,
Agent.status.in_(_ACTIVE_AGENT_STATUSES),
Agent.is_expired.is_(False),
Agent.deleted_at.is_(None),
)
),
)
statement = (
select(ChatSession.tenant_id, ChatSession.id)
.where(
ChatSession.deleted_at.is_(None),
ChatSession.last_message_at.is_not(None),
ChatSession.session_type == "group",
sa.or_(native_group, external_feishu_group),
)
.order_by(ChatSession.id)
.limit(self._settings.AGENT_RUNTIME_SESSION_COMPACT_SCAN_BATCH_SIZE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ async def _resolve_models(
"session_context_unavailable",
"Session Compact target no longer exists",
)
if session.session_type == "group":
if session.session_type == "group" and session.group_id is not None:
model = await resolve_multi_agent_compact_model(
db,
self._settings,
Expand All @@ -308,6 +308,11 @@ async def _resolve_models(
usage_agent_id=None,
)

if session.session_type == "group" and session.source_channel != "feishu":
raise SessionContextCompactorError(
"session_compact_model_unavailable",
"External Group Session channel is unsupported for compaction",
)
if session.agent_id is None or session.agent_id != request.source_agent_id:
raise SessionContextCompactorError(
"session_context_agent_mismatch",
Expand Down
7 changes: 7 additions & 0 deletions backend/app/services/agent_runtime/session_context_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,13 @@ def _message_scope(tenant_id: uuid.UUID, session_id: uuid.UUID):
ChatMessage.conversation_id == sa_cast(ChatSession.id, String),
ChatMessage.role.in_(_USER_VISIBLE_ROLES),
ChatMessage.created_at.is_not(None),
~and_(
ChatSession.session_type == "group",
ChatSession.group_id.is_(None),
ChatSession.source_channel == "feishu",
ChatMessage.role == "assistant",
func.lower(func.btrim(ChatMessage.content)) == "no_reply",
),
)


Expand Down
Loading