feat(engine): 添加运行时上下文支持并重构工具迭代限制

添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
This commit is contained in:
2026-05-26 11:18:35 +08:00
parent 16347caf5e
commit 6e9e74d1ee
57 changed files with 5710 additions and 1582 deletions

View File

@ -44,6 +44,8 @@ from .files import (
workspace_file_path,
)
from .schemas import (
WebChatAcceptanceRequest,
WebChatAcceptanceResponse,
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
@ -155,6 +157,13 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
return decorator
RAW_TOOL_CALL_DISPLAY_FALLBACK = (
"The run reached the configured tool-call limit before producing a reliable final answer. "
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
"Please request a revision to continue the task."
)
@asynccontextmanager
async def _app_lifespan(
app: FastAPI,
@ -365,6 +374,7 @@ def create_app(
"workspace_exists": loaded.workspace.exists(),
"model": config.default_model or agent_service.profile.default_model,
"max_tokens": agent_service.profile.max_tokens,
"max_context_messages": agent_service.profile.max_context_messages,
"temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status,
@ -1719,7 +1729,8 @@ def create_app(
usage=result.usage,
task_id=result.task_id,
task_status=result.task_status,
validation_result=result.validation_result,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
fallback_target = _model_dump(payload.fallback_target)
@ -1769,7 +1780,8 @@ def create_app(
usage=result.usage,
task_id=result.task_id,
task_status=result.task_status,
validation_result=result.validation_result,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
@app.websocket("/ws/{session_id:path}")
@ -1882,6 +1894,30 @@ def create_app(
}
)
@app.post(
"/api/chat/acceptance",
response_model=WebChatAcceptanceResponse,
responses={
400: {"model": WebErrorResponse},
404: {"model": WebErrorResponse},
},
)
async def chat_acceptance(request: Request, payload: WebChatAcceptanceRequest) -> WebChatAcceptanceResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
acceptance_type=payload.acceptance_type,
comment=payload.comment,
)
except ValueError as exc:
detail = str(exc)
status_code = 404 if "No internal task" in detail else 400
raise HTTPException(status_code=status_code, detail=detail) from exc
return WebChatAcceptanceResponse(**result)
@app.post(
"/api/chat/feedback",
response_model=WebChatFeedbackResponse,
@ -1893,10 +1929,10 @@ def create_app(
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_feedback(
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
feedback_type=payload.feedback_type,
acceptance_type=payload.feedback_type,
comment=payload.comment,
)
except ValueError as exc:
@ -1915,15 +1951,21 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An
role = event.get("role")
if role not in {"user", "assistant"}:
continue
content = event.get("content") or ""
comparable_content = str(content).replace("\u200b", "").replace("\u200c", "").replace("\u200d", "").replace("\ufeff", "")
if role == "assistant" and not comparable_content.strip():
continue
content = _sanitize_user_visible_assistant_content(role=role, content=content)
messages.append(
{
"role": role,
"content": event.get("content") or "",
"content": content,
"timestamp": _iso_from_timestamp(event.get("timestamp")),
"run_id": event.get("run_id"),
"task_id": event.get("task_id"),
"task_status": event.get("task_status"),
"validation_status": event.get("validation_status"),
"evidence_status": event.get("evidence_status"),
"acceptance_state": event.get("acceptance_state"),
"feedback_state": event.get("feedback_state"),
"feedback_error": event.get("feedback_error"),
"message_type": event.get("message_type"),
@ -2142,6 +2184,7 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
content = (record.content or "").strip()
if not content:
continue
content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
messages.append(
{
"role": record.role,
@ -2150,7 +2193,6 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
"tool_name": record.tool_name,
}
)
validation = run_record.validation_result if run_record is not None else None
views.append(
{
"run_id": run_id,
@ -2163,7 +2205,8 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
"attempt_index": run_record.attempt_index if run_record is not None else None,
"task_text": run_record.task_text if run_record is not None else "",
"messages": messages,
"validation_result": validation,
"evidence_status": "recorded",
"validation_result": None,
}
)
return views
@ -2428,12 +2471,6 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
return dict(value)
def _validation_status(validation_result: dict[str, Any] | None) -> str:
if validation_result is None:
return "unknown"
return "passed" if validation_result.get("accepted") is True else "failed"
def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
result: dict[str, Any] = dict(metadata)
@ -2467,13 +2504,15 @@ def _int_or_none(value: Any) -> int | None:
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
validation_result = getattr(result, "validation_result", None)
task_id = getattr(result, "task_id", None)
task_status = getattr(result, "task_status", None)
return {
"type": "message",
"role": "assistant",
"content": getattr(result, "output_text", "") or "",
"content": _sanitize_user_visible_assistant_content(
role="assistant",
content=getattr(result, "output_text", "") or "",
),
"session_id": getattr(result, "session_id", None),
"run_id": getattr(result, "run_id", None),
"finish_reason": getattr(result, "finish_reason", None),
@ -2483,17 +2522,39 @@ def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) ->
"usage": dict(getattr(result, "usage", {}) or {}),
"task_id": task_id,
"task_status": task_status,
"validation_result": validation_result,
"validation_status": _validation_status(validation_result),
"evidence_status": "recorded" if task_id else None,
"validation_result": None,
"metadata": {
"task_id": task_id,
"task_status": task_status,
"validation_result": validation_result,
"evidence_status": "recorded" if task_id else None,
"input_metadata": _websocket_input_metadata(input_payload),
},
}
def _sanitize_user_visible_assistant_content(*, role: str, content: str) -> str:
if role != "assistant":
return content
if _looks_like_raw_tool_call(content):
return RAW_TOOL_CALL_DISPLAY_FALLBACK
return content
def _looks_like_raw_tool_call(content: str | None) -> bool:
if not content:
return False
stripped = content.strip()
lowered = stripped.lower()
return (
lowered.startswith("<tool_call")
and lowered.endswith("</tool_call>")
) or (
lowered.startswith("<function=")
and lowered.endswith("</function>")
)
def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
if provider_cfg is None or provider_name == "custom":
return False
@ -2980,6 +3041,7 @@ def _write_config_json(path: Path, data: dict[str, Any]) -> None:
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
config = load_config(config_path=config_path)
agent_service.loader.config = config
agent_service._apply_configured_profile_defaults() # noqa: SLF001
loop = getattr(agent_service, "_loop", None)
loaded = getattr(loop, "loaded", None) if loop is not None else None
if loaded is not None: