feat(engine): 添加MCP连接管理和工具集成功能

- 集成MCP连接管理器,支持MCP服务器连接
- 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、
  PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、
  TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等
- 实现工具注册和装配功能
- 添加技能选择上下文参数
- 支持思考模式控制参数thinking_enabled

feat(coordinator): 重构任务执行计划器参数命名

- 将learning_candidate_enabled重命名为allow_candidate_generation
- 更新TeamGraphScheduler中的参数传递
- 修改LocalAgentRunner中的相关参数处理
- 更新README文档中的相应描述

refactor(context): 标准化工具调用参数格式

- 添加_json导入用于参数序列化
- 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷
- 修复工具调用中参数非字符串类型的序列化问题

refactor(session): 优化消息历史记录过滤逻辑

- 修改get_messages_as_conversation为基于运行状态过滤消息
- 排除未完成、失败或错误结束的运行记录
- 改进对话历史的可见性控制机制

fix(store): 修复FTS索引重建逻辑

- 添加异常处理防止FTS索引创建失败
- 实现_rebuild_fts_index方法重新构建全文搜索索引
- 优化索引触发器和表的维护流程
This commit is contained in:
2026-05-14 09:43:48 +08:00
parent 8a12c30141
commit 30ab74ffb2
149 changed files with 12293 additions and 2812 deletions

View File

@ -22,6 +22,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
@ -224,8 +225,29 @@ class ContextBuilder:
clean = {key: value for key, value in message.items() if key in allowed}
if "name" not in clean and message.get("tool_name"):
clean["name"] = message.get("tool_name")
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = ContextBuilder._provider_tool_calls(clean["tool_calls"])
return clean
@staticmethod
def _provider_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize persisted tool calls to OpenAI-compatible provider payloads."""
normalized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
normalized.append(clean)
return normalized
def add_tool_result(
self,
messages: list[dict[str, Any]],
@ -278,7 +300,7 @@ class ContextBuilder:
"content": content,
}
if tool_calls:
message["tool_calls"] = tool_calls
message["tool_calls"] = self._provider_tool_calls(tool_calls)
if reasoning_content is not None:
message["reasoning_content"] = reasoning_content
messages.append(message)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from pathlib import Path
@ -11,6 +12,7 @@ from beaver.coordinator.registry import AgentRegistry
from beaver.engine.context import ContextBuilder
from beaver.engine.session import SessionManager
from beaver.foundation.config import BeaverConfig, load_config
from beaver.integrations.mcp import MCPConnectionManager
from beaver.memory.curated.store import MemoryStore
from beaver.memory.runs import RunMemoryStore
from beaver.memory.skills import SkillLearningStore
@ -27,13 +29,27 @@ from beaver.tasks.skill_resolver import TaskSkillResolver
from beaver.skills import SkillAssembler, SkillsLoader
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
EchoTool,
ExecuteCodeTool,
ListDirectoryTool,
MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool,
SpawnTool,
SessionSearchTool,
SkillViewTool,
SkillManageTool,
SkillsListTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
)
@ -76,6 +92,8 @@ class EngineLoadResult:
task_service: TaskService | None = None
task_execution_planner: TaskExecutionPlanner | None = None
validation_service: ValidationService | None = None
mcp_manager: MCPConnectionManager | None = None
mcp_report: dict[str, dict] = field(default_factory=dict)
closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False)
closed: bool = False
@ -198,11 +216,25 @@ class EngineLoader:
[
ObjectBackedTool(EchoTool()),
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
ObjectBackedTool(SessionSearchTool(db=session_manager)),
ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()),
ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
SkillManageTool(),
CronTool(),
]
)
@ -240,6 +272,11 @@ class EngineLoader:
task_service = self._task_service or TaskService(workspace / "tasks")
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
validation_service = self._validation_service or ValidationService()
mcp_manager = MCPConnectionManager(
self.config.tools.mcp_servers,
authz_config=self.config.authz,
backend_identity=self.config.backend_identity,
)
result = EngineLoadResult(
workspace=workspace,
@ -270,7 +307,18 @@ class EngineLoader:
task_service=task_service,
task_execution_planner=task_execution_planner,
validation_service=validation_service,
mcp_manager=mcp_manager,
)
if self._session_manager is None:
result.register_closeable("session_manager", session_manager.close)
result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager))
return result
def _close_mcp_manager(manager: MCPConnectionManager) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(manager.close())
return
loop.create_task(manager.close())

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
@ -64,6 +65,7 @@ class AgentLoop:
self.profile = profile or AgentProfile()
self.loader = loader or EngineLoader()
self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._running = False
self._stop_requested = False
@ -190,6 +192,7 @@ class AgentLoop:
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -202,6 +205,9 @@ class AgentLoop:
embedding_model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
@ -210,7 +216,7 @@ class AgentLoop:
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> AgentRunResult:
"""跑通最小 direct run 主链。
@ -234,6 +240,7 @@ class AgentLoop:
user_id=user_id,
title=title,
execution_context=execution_context,
skill_selection_context=skill_selection_context,
model=model,
provider_name=provider_name,
api_key=api_key,
@ -246,6 +253,9 @@ class AgentLoop:
embedding_model=embedding_model,
max_tokens=max_tokens,
temperature=temperature,
thinking_enabled=thinking_enabled,
include_skill_assembly=include_skill_assembly,
include_tools=include_tools,
max_tool_iterations=max_tool_iterations,
provider_bundle=provider_bundle,
parent_session_id=parent_session_id,
@ -254,7 +264,7 @@ class AgentLoop:
attempt_index=attempt_index,
pinned_skill_names=pinned_skill_names,
pinned_skill_contexts=pinned_skill_contexts,
learning_candidate_enabled=learning_candidate_enabled,
allow_candidate_generation=allow_candidate_generation,
)
async def _process_direct_impl(
@ -266,6 +276,7 @@ class AgentLoop:
user_id: str | None = None,
title: str | None = None,
execution_context: str | None = None,
skill_selection_context: str | None = None,
model: str | None = None,
provider_name: str | None = None,
api_key: str | None = None,
@ -278,6 +289,9 @@ class AgentLoop:
embedding_model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
thinking_enabled: bool | None = None,
include_skill_assembly: bool = True,
include_tools: bool = True,
max_tool_iterations: int | None = None,
provider_bundle: ProviderBundle | None = None,
parent_session_id: str | None = None,
@ -286,7 +300,7 @@ class AgentLoop:
attempt_index: int | None = None,
pinned_skill_names: list[str] | None = None,
pinned_skill_contexts: list[SkillContext] | None = None,
learning_candidate_enabled: bool = False,
allow_candidate_generation: bool = False,
) -> AgentRunResult:
"""真正执行一轮 direct run 的内部实现。
@ -306,6 +320,10 @@ class AgentLoop:
skills_loader = self._require_loaded("skills_loader")
skill_assembler = self._require_loaded("skill_assembler")
skill_learning_service = self._require_loaded("skill_learning_service")
mcp_manager = getattr(loaded, "mcp_manager", None)
if mcp_manager is not None:
loaded.mcp_report = await mcp_manager.connect_all(tool_registry)
loaded.tools = [spec.name for spec in tool_registry.list_specs()]
config = loaded.config
configured_provider = config.resolve_provider_target(model=model, provider_name=provider_name)
@ -357,6 +375,9 @@ class AgentLoop:
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
"thinking_enabled": thinking_enabled,
"include_skill_assembly": include_skill_assembly,
"skill_selection_context_present": bool(skill_selection_context),
"parent_session_id": parent_session_id,
"pinned_skill_names": list(pinned_skill_names or []),
"pinned_skill_context_names": [skill.name for skill in pinned_skill_contexts or []],
@ -396,19 +417,39 @@ class AgentLoop:
if bundle.auxiliary_runtime is not None
else bundle.main_runtime.model
)
assembled_skills = await skill_assembler.assemble(
task_description=task,
provider=skill_selector_provider,
model=skill_selector_model,
embedding_runtime=bundle.embedding_runtime,
)
activated_skills = self._merge_skill_contexts(
[
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
],
assembled_skills.activated_skills,
)
pinned_skills = [
*(pinned_skill_contexts or []),
*self._load_pinned_skill_contexts(skills_loader, pinned_skill_names or []),
]
if not include_skill_assembly or thinking_enabled is False:
activated_skills = self._merge_skill_contexts(pinned_skills, [])
else:
skill_query = skill_selection_context or task
assembled_skills = await skill_assembler.assemble(
task_description=skill_query,
provider=skill_selector_provider,
model=skill_selector_model,
embedding_runtime=bundle.embedding_runtime,
thinking_enabled=thinking_enabled,
)
for interaction in getattr(assembled_skills, "llm_interactions", []) or []:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_assembler_llm_interaction_snapshotted",
event_payload=interaction,
content=json.dumps(interaction, ensure_ascii=False, default=str),
context_visible=False,
source=source,
title=title,
model=skill_selector_model,
user_id=user_id,
)
activated_skills = self._merge_skill_contexts(
pinned_skills,
assembled_skills.activated_skills,
)
skill_activation_messages = context_builder.build_skill_activation_messages(
activated_skills
)
@ -444,14 +485,19 @@ class AgentLoop:
user_id=user_id,
)
selected_tool_specs = await tool_assembler.assemble(
task_description=task,
registry=tool_registry,
skills_loader=skills_loader,
activated_skills=activated_skills,
embedding_runtime=bundle.embedding_runtime,
top_k=10,
)
if not include_tools:
selected_tool_specs = []
elif thinking_enabled is False:
selected_tool_specs = tool_registry.list_specs()
else:
selected_tool_specs = await tool_assembler.assemble(
task_description=task,
registry=tool_registry,
skills_loader=skills_loader,
activated_skills=activated_skills,
embedding_runtime=bundle.embedding_runtime,
top_k=10,
)
tool_schemas = tool_registry.export_selected_provider_schemas(selected_tool_specs)
session_manager.append_message(
resolved_session_id,
@ -486,6 +532,25 @@ class AgentLoop:
execution_context=execution_context,
)
context_result = context_builder.build_messages(build_input)
if skill_selection_context:
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="skill_selection_context_snapshotted",
event_payload={
"skill_selection_context": skill_selection_context,
"task_id": task_id,
"task_mode": task_mode,
"attempt_index": attempt_index,
},
content=skill_selection_context,
context_visible=False,
source=source,
title=title,
model=resolved_model,
user_id=user_id,
)
session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt)
session_manager.append_message(
resolved_session_id,
@ -528,6 +593,9 @@ class AgentLoop:
"memory_service": memory_service,
"memory_store": memory_service.get_store(),
"tool_registry": tool_registry,
"skills_loader": skills_loader,
"draft_service": getattr(loaded, "draft_service", None),
**self.runtime_services,
},
metadata={
"source": source,
@ -541,13 +609,45 @@ class AgentLoop:
final_model = bundle.main_runtime.model
while True:
response = await provider.chat(
messages=messages,
tools=tool_schemas,
chat_kwargs: dict[str, Any] = {
"messages": messages,
"tools": tool_schemas,
"model": final_model,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
}
if thinking_enabled is not None:
chat_kwargs["thinking_enabled"] = thinking_enabled
session_manager.append_message(
resolved_session_id,
run_id=resolved_run_id,
role="system",
event_type="llm_request_snapshotted",
event_payload={
"iteration": iterations,
"provider_name": final_provider_name,
"model": final_model,
"messages": messages,
"tools": tool_schemas,
"max_tokens": resolved_max_tokens,
"temperature": resolved_temperature,
"thinking_enabled": thinking_enabled,
},
content=json.dumps(
{
"messages": messages,
"tools": tool_schemas,
},
ensure_ascii=False,
default=str,
),
context_visible=False,
source=source,
title=title,
model=final_model,
max_tokens=resolved_max_tokens,
temperature=resolved_temperature,
user_id=user_id,
)
response = await provider.chat(**chat_kwargs)
final_provider_name = response.provider_name or final_provider_name
final_model = response.model or final_model
final_usage = self._merge_usage(final_usage, response.usage or {})
@ -650,7 +750,7 @@ class AgentLoop:
model=final_model,
user_id=user_id,
)
self._record_skill_learning(
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
@ -663,7 +763,7 @@ class AgentLoop:
success=(final_finish_reason == "stop"),
task_id=task_id,
attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled,
allow_candidate_generation=False,
)
return AgentRunResult(
session_id=resolved_session_id,
@ -703,7 +803,7 @@ class AgentLoop:
usage=final_usage,
task_id=task_id,
)
self._record_skill_learning(
self._record_run_receipts(
skill_learning_service=skill_learning_service,
session_manager=session_manager,
session_id=resolved_session_id,
@ -716,7 +816,7 @@ class AgentLoop:
success=False,
task_id=task_id,
attempt_index=attempt_index,
generate_candidates=learning_candidate_enabled,
allow_candidate_generation=False,
)
return result
@ -771,13 +871,16 @@ class AgentLoop:
def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
payload: list[dict[str, Any]] = []
for tool_call in tool_calls:
arguments = tool_call.arguments
if not isinstance(arguments, str):
arguments = json.dumps(arguments or {}, ensure_ascii=False, default=str)
payload.append(
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": tool_call.arguments,
"arguments": arguments,
},
}
)
@ -877,7 +980,7 @@ class AgentLoop:
)
@staticmethod
def _record_skill_learning(
def _record_run_receipts(
*,
skill_learning_service: Any,
session_manager: Any,
@ -891,7 +994,7 @@ class AgentLoop:
success: bool,
task_id: str | None = None,
attempt_index: int | None = None,
generate_candidates: bool = False,
allow_candidate_generation: bool = False,
) -> None:
run_record = RunRecord(
run_id=run_id,
@ -921,7 +1024,7 @@ class AgentLoop:
try:
candidates = skill_learning_service.collect_run_receipts(
RunReceiptContext(run_record=run_record, effect_records=effect_records),
generate_candidates=generate_candidates,
generate_candidates=allow_candidate_generation,
)
except Exception as exc: # pragma: no cover - defensive hot-path guard
session_manager.append_message(
@ -948,7 +1051,7 @@ class AgentLoop:
"run_record": run_record.to_dict(),
"skill_effects": [item.to_dict() for item in effect_records],
"learning_candidates": [candidate.to_dict() for candidate in candidates],
"learning_candidate_enabled": generate_candidates,
"candidate_generation_allowed": allow_candidate_generation,
},
content=f"Recorded {len(effect_records)} skill effect record(s).",
context_visible=False,

View File

@ -45,6 +45,7 @@ class AnthropicProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
try:
client = self._client_or_raise()

View File

@ -90,6 +90,7 @@ class LLMProvider(ABC):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
"""统一聊天接口。"""

View File

@ -58,6 +58,7 @@ class FallbackProviderChain(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
self._last_provider = self.primary_provider
self._last_runtime = self.primary_runtime
@ -71,6 +72,7 @@ class FallbackProviderChain(LLMProvider):
model=model or self.primary_runtime.model,
max_tokens=max_tokens,
temperature=temperature,
thinking_enabled=thinking_enabled,
)
response = self._decorate_response(response, self.primary_runtime)
if not self._should_activate_fallback(response):
@ -91,6 +93,7 @@ class FallbackProviderChain(LLMProvider):
model=self.fallback_runtime.model,
max_tokens=max_tokens,
temperature=temperature,
thinking_enabled=thinking_enabled,
)
return self._decorate_response(response, self.fallback_runtime)
@ -114,6 +117,7 @@ class FallbackProviderChain(LLMProvider):
model: str,
max_tokens: int,
temperature: float,
thinking_enabled: bool | None,
) -> LLMResponse:
"""把 provider 抛出的异常也收敛成统一 error response。
@ -121,13 +125,16 @@ class FallbackProviderChain(LLMProvider):
"""
try:
return await provider.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
)
kwargs = {
"messages": messages,
"tools": tools,
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
}
if thinking_enabled is not None:
kwargs["thinking_enabled"] = thinking_enabled
return await provider.chat(**kwargs)
except Exception as exc:
return LLMResponse(
content=f"Error: {exc}",

View File

@ -41,6 +41,7 @@ class OpenAICodexProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
if httpx is None or get_codex_token is None:
return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex")

View File

@ -49,6 +49,7 @@ class CustomProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
client = self._client_or_raise()
kwargs: dict[str, Any] = {

View File

@ -123,6 +123,25 @@ class LiteLLMProvider(LLMProvider):
clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS}
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
sanitized.append(clean)
return sanitized
@staticmethod
def _sanitize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
sanitized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
sanitized.append(clean)
return sanitized
@ -155,6 +174,18 @@ class LiteLLMProvider(LLMProvider):
if provider_payload:
kwargs["provider"] = provider_payload
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if enabled is None:
return
model_key = f"{original_model} {resolved_model}".lower()
if "qwen" not in model_key:
return
extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = bool(enabled)
extra_body["chat_template_kwargs"] = chat_template_kwargs
kwargs["extra_body"] = extra_body
async def chat(
self,
messages: list[dict[str, Any]],
@ -162,6 +193,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
if acompletion is None:
return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name)
@ -174,6 +206,7 @@ class LiteLLMProvider(LLMProvider):
"messages": sanitized_messages,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
"timeout": self.request_timeout_seconds or 45.0,
}
if self.api_key:
kwargs["api_key"] = self.api_key
@ -186,6 +219,7 @@ class LiteLLMProvider(LLMProvider):
kwargs["tool_choice"] = "auto"
self._apply_model_overrides(original_model, kwargs)
self._apply_openrouter_routing(kwargs)
self._apply_thinking_mode(original_model, resolved_model, kwargs, thinking_enabled)
env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model)
try:

View File

@ -121,7 +121,37 @@ class SessionManager:
3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段”
"""
history = self.get_messages_as_conversation(session_id)
records = self.get_event_records(session_id)
completed_run_ids = {
record.run_id
for record in records
if record.run_id and record.event_type == "run_completed"
}
failed_run_ids = {
record.run_id
for record in records
if record.run_id
and record.event_type == "run_completed"
and (
record.finish_reason == "error"
or (record.event_payload or {}).get("finish_reason") == "error"
)
}
history = []
for record in records:
if not record.context_visible or record.role == "system":
continue
if record.role == "tool":
continue
if record.role == "assistant" and record.tool_calls:
continue
if record.run_id and record.run_id not in completed_run_ids:
continue
if record.run_id and record.run_id in failed_run_ids:
continue
if record.role == "assistant" and record.finish_reason == "error":
continue
history.append(record.to_conversation_message())
sliced = history[-max_messages:]
for index, message in enumerate(sliced):
if message.get("role") == "user":

View File

@ -88,6 +88,15 @@ class MessageRecord:
payload["feedback_state"] = self.event_payload.get("feedback_state")
if self.event_payload.get("feedback_error"):
payload["feedback_error"] = self.event_payload.get("feedback_error")
for key in (
"message_type",
"scheduled_job_id",
"scheduled_run_id",
"cron_job_name",
"mode",
):
if self.event_payload.get(key):
payload[key] = self.event_payload.get(key)
if self.tool_name:
payload["tool_name"] = self.tool_name
if self.tool_calls:

View File

@ -70,6 +70,7 @@ class SessionSearchService:
include_children: bool = False,
source: str | None = None,
exclude_sources: list[str] | None = None,
exclude_end_reasons: list[str] | None = None,
) -> list[dict[str, Any]]:
"""列出最近活跃的 session 及其摘要元数据。"""
@ -85,6 +86,10 @@ class SessionSearchService:
placeholders = ",".join("?" for _ in exclude_sources)
clauses.append(f"source NOT IN ({placeholders})")
params.extend(exclude_sources)
if exclude_end_reasons:
placeholders = ",".join("?" for _ in exclude_end_reasons)
clauses.append(f"(end_reason IS NULL OR end_reason NOT IN ({placeholders}))")
params.extend(exclude_end_reasons)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset])

View File

@ -128,19 +128,46 @@ class SessionStore:
self._conn.executescript(SCHEMA_SQL)
try:
self._conn.execute("SELECT * FROM messages_fts LIMIT 0")
except sqlite3.OperationalError:
self._conn.executescript(FTS_TABLE_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
except sqlite3.Error:
self._rebuild_fts_index()
return
# 旧版本可能把 hidden 事件也写进了 FTS初始化时顺手清掉这些噪声项。
self._conn.execute(
"""
INSERT INTO messages_fts(messages_fts, rowid, content)
SELECT 'delete', id, content
FROM messages
WHERE context_visible = 0 AND content IS NOT NULL
"""
)
self._conn.commit()
try:
self._conn.execute(
"""
INSERT INTO messages_fts(messages_fts, rowid, content)
SELECT 'delete', id, content
FROM messages
WHERE context_visible = 0 AND content IS NOT NULL
"""
)
self._conn.commit()
except sqlite3.Error:
self._rebuild_fts_index()
def _rebuild_fts_index(self) -> None:
"""Recreate the derived FTS index without touching canonical session rows."""
self._conn.executescript(
"""
DROP TRIGGER IF EXISTS messages_fts_insert;
DROP TRIGGER IF EXISTS messages_fts_delete;
DROP TRIGGER IF EXISTS messages_fts_update;
DROP TABLE IF EXISTS messages_fts;
"""
)
self._conn.executescript(FTS_TABLE_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.execute(
"""
INSERT INTO messages_fts(rowid, content)
SELECT id, content
FROM messages
WHERE context_visible = 1 AND content IS NOT NULL
"""
)
self._conn.commit()
def close(self) -> None:
with self._lock: