feat(engine): 添加技能查看工具并优化异步任务管理

- 添加SkillViewTool到引擎加载器中,增强技能管理功能
- 在AgentLoop中引入_active_direct_task来跟踪活跃任务
- 实现直接任务执行时的同步处理逻辑
- 更新工具实例化方式以支持依赖注入

feat(config): 增加智能体运行时参数配置支持

- 扩展AgentDefaultsConfig添加max_tokens和temperature字段
- 实现配置解析函数_first_config_value处理多个配置源
- 支持通过Web API动态更新智能体运行时参数
- 添加前端页面配置表单和验证逻辑

refactor(provider): 统一最大令牌数参数类型为可选整型

- 将所有LLM提供者的max_tokens参数改为int | None类型
- 为AnthropicProvider实现模型特定的最大令牌数默认值
- 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值
- 移除硬编码的默认值,改用条件判断

feat(process): 增强事件投影功能

- 添加工具调用开始/结束事件的映射逻辑
- 实现技能激活事件的识别和展示
- 添加辅助函数处理工具调用名称和参数提取
- 优化运行记录关联逻辑,提升事件匹配准确性

fix(web): 更新网络请求客户端信任环境设置

- 将WebFetchTool和WebSearchTool的trust_env参数设为True
- 确保HTTP客户端能够正确使用系统代理配置
- 修复可能的网络连接问题

test: 添加配置加载和事件投影相关测试

- 新增智能体默认参数配置测试用例
- 实现API配置持久化和重载测试
- 添加技能卡片和工具事件的投影测试
```
This commit is contained in:
2026-05-27 13:37:06 +08:00
parent 55b39563a0
commit 33a9845566
75 changed files with 2599 additions and 114 deletions

View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-27T05:25:11.756341+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756349+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-27T05:25:11.756351+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756353+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-27T05:25:11.756355+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756356+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-27T05:25:11.756358+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756358+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-27T05:25:11.756360+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-27T05:25:11.756360+00:00"
}
],
"version": 1
}

View File

@ -44,6 +44,7 @@ from beaver.tools.builtins import (
SpawnTool,
SessionSearchTool,
SkillManageTool,
SkillViewTool,
SkillsListTool,
TerminalTool,
TodoTool,
@ -220,16 +221,17 @@ class EngineLoader:
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(),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
SkillManageTool(),
CronTool(),
]

View File

@ -48,7 +48,7 @@ class AgentProfile:
name: str = "default"
system_prompt: str = ""
default_model: str = "gpt-4.1-mini"
max_tokens: int = 4096
max_tokens: int | None = None
max_context_messages: int = 1000
temperature: float = 0.2
max_tool_iterations: int = 30
@ -89,6 +89,7 @@ class AgentLoop:
self.loaded: EngineLoadResult | None = None
self.runtime_services: dict[str, Any] = {}
self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None
self._active_direct_task: asyncio.Task[Any] | None = None
self._running = False
self._stop_requested = False
@ -130,6 +131,8 @@ class AgentLoop:
if item.future.cancelled():
continue
previous_direct_task = self._active_direct_task
self._active_direct_task = asyncio.current_task()
try:
result = await self._process_direct_impl(item.task, **item.kwargs)
except asyncio.CancelledError:
@ -142,6 +145,8 @@ class AgentLoop:
else:
if not item.future.done():
item.future.set_result(result)
finally:
self._active_direct_task = previous_direct_task
finally:
if self._run_queue is not None:
while True:
@ -183,6 +188,9 @@ class AgentLoop:
if self._stop_requested:
raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()")
if asyncio.current_task() is self._active_direct_task:
return await self._process_direct_impl(task, **kwargs)
future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future()
await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future))
return await future
@ -363,7 +371,7 @@ class AgentLoop:
resolved_request_timeout_seconds = configured_provider.get("request_timeout_seconds")
resolved_embedding_model = embedding_model or config.default_embedding_model
resolved_embedding_target = embedding_target or config.resolve_embedding_target()
resolved_max_tokens = max_tokens or self.profile.max_tokens
resolved_max_tokens = self.profile.max_tokens if max_tokens is None else max_tokens
resolved_temperature = self.profile.temperature if temperature is None else temperature
resolved_max_tool_iterations = (
self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations
@ -892,7 +900,7 @@ class AgentLoop:
provider: Any,
messages: list[dict[str, Any]],
model: str,
max_tokens: int,
max_tokens: int | None,
temperature: float,
thinking_enabled: bool | None,
) -> str:

View File

@ -43,7 +43,7 @@ class AnthropicProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -57,9 +57,14 @@ class AnthropicProvider(LLMProvider):
"model": model or self.default_model,
"system": system_prompt or "",
"messages": anthropic_messages,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
resolved_max_tokens = (
_default_max_tokens_for_model(model or self.default_model)
if max_tokens is None
else max(1, max_tokens)
)
kwargs["max_tokens"] = resolved_max_tokens
if tools:
kwargs["tools"] = _convert_tools(tools)
@ -100,6 +105,17 @@ class AnthropicProvider(LLMProvider):
return self.default_model
def _default_max_tokens_for_model(model: str) -> int:
"""Return a conservative native output ceiling for Anthropic Messages."""
normalized = model.lower().replace("_", "-")
if "sonnet-4" in normalized or "opus-4" in normalized or "3-7" in normalized or "3.7" in normalized:
return 64_000
if "haiku" in normalized:
return 4_096
return 8_192
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
converted: list[dict[str, Any]] = []

View File

@ -88,7 +88,7 @@ class LLMProvider(ABC):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:

View File

@ -56,7 +56,7 @@ class FallbackProviderChain(LLMProvider):
messages: list[dict],
tools: list[dict] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -115,7 +115,7 @@ class FallbackProviderChain(LLMProvider):
messages: list[dict],
tools: list[dict] | None,
model: str,
max_tokens: int,
max_tokens: int | None,
temperature: float,
thinking_enabled: bool | None,
) -> LLMResponse:

View File

@ -39,7 +39,7 @@ class OpenAICodexProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:

View File

@ -47,7 +47,7 @@ class CustomProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -55,9 +55,10 @@ class CustomProvider(LLMProvider):
kwargs: dict[str, Any] = {
"model": model or self.default_model,
"messages": self.sanitize_empty_content(messages),
"max_tokens": max(1, max_tokens),
"temperature": temperature,
}
if max_tokens is not None:
kwargs["max_tokens"] = max(1, max_tokens)
if tools:
kwargs.update(tools=tools, tool_choice="auto")
try:

View File

@ -197,7 +197,7 @@ class LiteLLMProvider(LLMProvider):
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
max_tokens: int | None = None,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
@ -210,10 +210,11 @@ class LiteLLMProvider(LLMProvider):
kwargs: dict[str, Any] = {
"model": resolved_model,
"messages": sanitized_messages,
"max_tokens": max(1, max_tokens),
"temperature": temperature,
"timeout": self.request_timeout_seconds or 45.0,
}
if max_tokens is not None:
kwargs["max_tokens"] = max(1, max_tokens)
if self.api_key:
kwargs["api_key"] = self.api_key
if self.api_base:

View File

@ -86,18 +86,25 @@ def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
model=_string(defaults.get("model") or data.get("model")),
provider=_string(defaults.get("provider") or data.get("provider")),
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
max_tokens=_int(_first_config_value(
defaults.get("maxTokens"),
defaults.get("max_tokens"),
data.get("maxTokens"),
data.get("max_tokens"),
)),
temperature=_float(_first_config_value(defaults.get("temperature"), data.get("temperature"))),
max_context_messages=_int(
defaults.get("maxContextMessages")
or defaults.get("max_context_messages")
or data.get("maxContextMessages")
or data.get("max_context_messages")
),
max_tool_iterations=_int(
defaults.get("maxToolIterations")
or defaults.get("max_tool_iterations")
or data.get("maxToolIterations")
or data.get("max_tool_iterations")
),
max_tool_iterations=_int(_first_config_value(
defaults.get("maxToolIterations"),
defaults.get("max_tool_iterations"),
data.get("maxToolIterations"),
data.get("max_tool_iterations"),
)),
)
@ -204,6 +211,13 @@ def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _first_config_value(*values: Any) -> Any:
for value in values:
if value not in (None, ""):
return value
return None
def _string(value: Any) -> str | None:
if value is None:
return None

View File

@ -25,6 +25,8 @@ class AgentDefaultsConfig:
model: str | None = None
provider: str | None = None
embedding_model: str | None = None
max_tokens: int | None = None
temperature: float | None = None
max_context_messages: int | None = None
max_tool_iterations: int | None = None

View File

@ -51,6 +51,8 @@ from .schemas import (
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebStatusResponse,
@ -595,6 +597,38 @@ def create_app(
_reload_agent_config(agent_service, config_path)
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
@app.post("/api/agent-config", response_model=WebAgentConfigResponse)
async def update_agent_config(
request: Request,
payload: WebAgentConfigRequest,
) -> WebAgentConfigResponse:
if payload.max_tokens is not None and payload.max_tokens <= 0:
raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null")
if payload.temperature < 0 or payload.temperature > 2:
raise HTTPException(status_code=400, detail="temperature must be between 0 and 2")
if payload.max_tool_iterations < 0:
raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
agents = _ensure_dict(raw, "agents")
defaults = _ensure_dict(agents, "defaults")
if payload.max_tokens is None:
defaults.pop("maxTokens", None)
defaults.pop("max_tokens", None)
else:
defaults["maxTokens"] = payload.max_tokens
defaults.pop("max_tokens", None)
defaults["temperature"] = payload.temperature
defaults["maxToolIterations"] = payload.max_tool_iterations
defaults.pop("max_tool_iterations", None)
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebAgentConfigResponse(ok=True)
@app.get("/api/sessions")
async def list_sessions(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()

View File

@ -8,6 +8,8 @@ from .chat import (
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebAgentConfigRequest,
WebAgentConfigResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebProviderTarget,
@ -22,6 +24,8 @@ __all__ = [
"WebChatRequest",
"WebChatResponse",
"WebErrorResponse",
"WebAgentConfigRequest",
"WebAgentConfigResponse",
"WebProviderConfigRequest",
"WebProviderConfigResponse",
"WebProviderTarget",

View File

@ -139,6 +139,20 @@ class WebProviderConfigResponse(BaseModel):
enabled: bool
class WebAgentConfigRequest(BaseModel):
"""Agent runtime defaults update from the settings page."""
max_tokens: int | None = None
temperature: float
max_tool_iterations: int
class WebAgentConfigResponse(BaseModel):
"""Agent runtime defaults update result."""
ok: bool
class WebStatusResponse(BaseModel):
"""Web 宿主层状态响应。"""

View File

@ -68,6 +68,14 @@ class AgentService:
def _apply_configured_profile_defaults(self) -> None:
defaults = self.loader.config.agents_defaults
self.profile.max_tokens = None
self.profile.temperature = 0.2
self.profile.max_context_messages = 1000
self.profile.max_tool_iterations = 30
if defaults.max_tokens is not None:
self.profile.max_tokens = max(1, defaults.max_tokens)
if defaults.temperature is not None:
self.profile.temperature = defaults.temperature
if defaults.max_context_messages is not None:
self.profile.max_context_messages = max(1, defaults.max_context_messages)
if defaults.max_tool_iterations is not None:

View File

@ -50,10 +50,11 @@ class SessionProcessProjector:
for record in records:
payload = dict(record.event_payload or {})
task_id = payload.get("task_id")
run_record_for_event = run_records.get(str(record.run_id)) if record.run_id else None
task_id = payload.get("task_id") or getattr(run_record_for_event, "task_id", None)
if not task_id:
continue
attempt_index = int(payload.get("attempt_index") or 1)
attempt_index = int(payload.get("attempt_index") or getattr(run_record_for_event, "attempt_index", None) or 1)
root_run_id = f"task:{task_id}:attempt:{attempt_index}"
created_at = _timestamp(record.timestamp)
root = runs.setdefault(
@ -73,7 +74,61 @@ class SessionProcessProjector:
},
)
if record.event_type == "task_execution_planned":
if record.event_type == "assistant_message_added" and record.tool_calls:
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
for index, tool_call in enumerate(record.tool_calls):
if not isinstance(tool_call, dict):
continue
tool_name = _tool_call_name(tool_call)
add_event(
event_id=f"{_event_id(record, 'tool-call')}:{index}",
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_started",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=f"Calling tool: {tool_name}.",
created_at=created_at,
status="running",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_call",
"tool_name": tool_name,
"tool_call_id": tool_call.get("id"),
"arguments": _tool_call_arguments(tool_call),
},
)
elif record.event_type == "tool_result_recorded":
run_id = record.run_id or root_run_id
parent_run_id = root_run_id if run_id != root_run_id else None
tool_name = str(record.tool_name or payload.get("tool_name") or "tool")
add_event(
event_id=_event_id(record, "tool-result"),
run_id=run_id,
parent_run_id=parent_run_id,
kind="tool_call_finished",
actor_type="tool",
actor_id=tool_name,
actor_name=tool_name,
text=_truncate(str(record.content or payload.get("error") or "")),
created_at=created_at,
status="done" if payload.get("success", True) else "error",
metadata={
**dict(payload),
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "tool_result",
"tool_name": tool_name,
"tool_call_id": record.tool_call_id,
"result_summary": _truncate(str(record.content or payload.get("error") or "")),
},
)
elif record.event_type == "task_execution_planned":
plan_mode = payload.get("plan_mode") or "single"
strategy = payload.get("strategy") or "single"
node_ids = payload.get("node_ids") or []
@ -241,6 +296,7 @@ class SessionProcessProjector:
main_run_id = str(payload.get("main_run_id") or "")
if main_run_id:
run_record = run_records.get(main_run_id)
activated_skill_names = _activated_skill_names(run_record)
runs[main_run_id] = {
"run_id": main_run_id,
"parent_run_id": root_run_id,
@ -254,8 +310,32 @@ class SessionProcessProjector:
"started_at": run_record.started_at if run_record is not None else created_at,
"finished_at": run_record.ended_at if run_record is not None else created_at,
"summary": _truncate(run_record.task_text if run_record is not None else ""),
"metadata": {"task_id": task_id, "attempt_index": attempt_index},
"metadata": {
"task_id": task_id,
"attempt_index": attempt_index,
"skill_names": activated_skill_names,
},
}
if activated_skill_names:
add_event(
event_id=_event_id(record, "synthesis-skills"),
run_id=main_run_id,
parent_run_id=root_run_id,
kind="skill_selected",
actor_type="system",
actor_id="skill-selector",
actor_name="Skill Selector",
text=f"Selected skill guidance: {', '.join(activated_skill_names)}.",
created_at=created_at,
status="done",
metadata={
"task_id": task_id,
"attempt_index": attempt_index,
"timeline_type": "skill",
"skill_names": activated_skill_names,
"activation_reasons": _activated_skill_reasons(run_record),
},
)
add_event(
event_id=_event_id(record, "synthesis"),
run_id=main_run_id,
@ -335,3 +415,49 @@ def _truncate(text: str, limit: int = 800) -> str:
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1] + "..."
def _activated_skill_names(run_record: Any | None) -> list[str]:
if run_record is None:
return []
names = []
for receipt in getattr(run_record, "activated_skills", []) or []:
skill_name = str(getattr(receipt, "skill_name", "") or "").strip()
if skill_name:
names.append(skill_name)
return list(dict.fromkeys(names))
def _activated_skill_reasons(run_record: Any | None) -> list[str]:
if run_record is None:
return []
reasons = []
for receipt in getattr(run_record, "activated_skills", []) or []:
reason = str(getattr(receipt, "activation_reason", "") or "").strip()
if reason:
reasons.append(reason)
return reasons
def _tool_call_name(tool_call: dict[str, Any]) -> str:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict):
name = function_payload.get("name")
if name:
return str(name)
for key in ("name", "tool_name"):
value = tool_call.get(key)
if value:
return str(value)
return "tool"
def _tool_call_arguments(tool_call: dict[str, Any]) -> Any:
function_payload = tool_call.get("function")
if isinstance(function_payload, dict) and "arguments" in function_payload:
return function_payload.get("arguments")
if "arguments" in tool_call:
return tool_call.get("arguments")
if "args" in tool_call:
return tool_call.get("args")
return None

View File

@ -51,7 +51,7 @@ class WebFetchTool:
try:
safe_url = _safe_url(url)
limit = max(1000, min(int(max_chars or 12000), 50000))
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
response = await client.get(
safe_url,
headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"},
@ -96,7 +96,7 @@ class WebSearchTool:
raise ValueError("query is required")
bounded = max(1, min(int(limit or 5), 10))
url = f"https://duckduckgo.com/html/?q={quote_plus(query)}"
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=False) as client:
async with httpx.AsyncClient(timeout=20, follow_redirects=True, trust_env=True) as client:
response = await client.get(url, headers={"User-Agent": "Mozilla/5.0 Beaver/1.0"})
response.raise_for_status()
html = response.text

View File

@ -0,0 +1,47 @@
import asyncio
from contextlib import suppress
from typing import Any
from beaver.engine import AgentLoop, AgentRunResult, EngineLoader
def _run_result(run_id: str, output_text: str) -> AgentRunResult:
return AgentRunResult(
session_id="web:test",
run_id=run_id,
output_text=output_text,
finish_reason="stop",
tool_iterations=0,
)
def test_running_loop_handles_reentrant_submit_direct(tmp_path) -> None:
async def run_case() -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
calls: list[str] = []
async def fake_process_direct(task: str, **kwargs: Any) -> AgentRunResult:
calls.append(task)
if task == "outer":
return await loop.submit_direct("inner", session_id="web:test")
return _run_result(task, "inner completed")
loop._process_direct_impl = fake_process_direct # type: ignore[method-assign]
loop_task = asyncio.create_task(loop.run())
await asyncio.sleep(0)
try:
result = await asyncio.wait_for(loop.submit_direct("outer", session_id="web:test"), timeout=1)
finally:
await loop.stop()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(loop_task, timeout=1)
if not loop_task.done():
loop_task.cancel()
with suppress(asyncio.CancelledError):
await loop_task
assert result.output_text == "inner completed"
assert calls == ["outer", "inner"]
asyncio.run(run_case())

View File

@ -1,10 +1,12 @@
import json
from fastapi.testclient import TestClient
from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.engine.providers.litellm import LiteLLMProvider
from beaver.foundation.config import load_config
from beaver.interfaces.web.app import _reload_agent_config
from beaver.interfaces.web.app import create_app, _reload_agent_config
from beaver.services.agent_service import AgentService
@ -161,6 +163,88 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close()
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"maxTokens": 12345,
"temperature": 0.4,
"maxToolIterations": 9,
}
}
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
service = AgentService(config_path=config_path)
assert config.agents_defaults.max_tokens == 12345
assert config.agents_defaults.temperature == 0.4
assert config.agents_defaults.max_tool_iterations == 9
assert service.profile.max_tokens == 12345
assert service.profile.temperature == 0.4
assert service.profile.max_tool_iterations == 9
service.close()
def test_agent_config_api_persists_and_reloads_defaults(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({"agents": {"defaults": {}}}), encoding="utf-8")
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post(
"/api/agent-config",
json={"max_tokens": 8192, "temperature": 0.6, "max_tool_iterations": 12},
)
status = client.get("/api/status")
saved = json.loads(config_path.read_text(encoding="utf-8"))
defaults = saved["agents"]["defaults"]
assert response.status_code == 200
assert response.json() == {"ok": True}
assert defaults["maxTokens"] == 8192
assert defaults["temperature"] == 0.6
assert defaults["maxToolIterations"] == 12
assert service.profile.max_tokens == 8192
assert service.profile.temperature == 0.6
assert service.profile.max_tool_iterations == 12
assert status.json()["max_tokens"] == 8192
assert status.json()["temperature"] == 0.6
assert status.json()["max_tool_iterations"] == 12
service.close()
def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> None:
config_path = tmp_path / "config.json"
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post(
"/api/agent-config",
json={"max_tokens": None, "temperature": 0, "max_tool_iterations": 0},
)
config = load_config(config_path=config_path)
assert response.status_code == 200
assert config.agents_defaults.max_tokens is None
assert config.agents_defaults.temperature == 0
assert config.agents_defaults.max_tool_iterations == 0
assert service.profile.max_tokens is None
assert service.profile.temperature == 0
assert service.profile.max_tool_iterations == 0
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",

View File

@ -0,0 +1,58 @@
from __future__ import annotations
import json
from pathlib import Path
from beaver.engine import EngineLoader
from beaver.skills.catalog.utils import parse_frontmatter
REPO_ROOT = Path(__file__).resolve().parents[4]
EXPECTED_INITIAL_SKILL_TOOLS = {
"cron-scheduler": ["cron"],
"filesystem-operation": ["read_file", "write_file", "patch_file", "search_files", "list_directory"],
"memory-management": ["memory"],
"outlook-mail": [
"mcp_outlook_mcp_mail_list_folders",
"mcp_outlook_mcp_mail_list_messages",
"mcp_outlook_mcp_mail_search_messages",
"mcp_outlook_mcp_mail_get_message",
"mcp_outlook_mcp_mail_send_email",
"mcp_outlook_mcp_mail_reply_to_message",
"mcp_outlook_mcp_mail_forward_message",
"mcp_outlook_mcp_mail_move_message",
"mcp_outlook_mcp_mail_delta_sync",
"mcp_outlook_mcp_calendar_list_events",
"mcp_outlook_mcp_calendar_create_event",
"mcp_outlook_mcp_calendar_update_event",
"mcp_outlook_mcp_calendar_get_schedule",
"mcp_outlook_mcp_calendar_find_meeting_times",
"mcp_outlook_mcp_calendar_delta_sync",
],
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
"terminal-operation": ["terminal", "process", "execute_code"],
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
"web-operation": ["web_fetch", "web_search"],
}
def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
for skill_name, expected_tools in EXPECTED_INITIAL_SKILL_TOOLS.items():
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
assert frontmatter["tools"] == expected_tools
assert version["frontmatter"]["tools"] == expected_tools
assert version["tool_hints"] == expected_tools
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
loaded = EngineLoader(workspace=tmp_path).load()
try:
assert "skill_view" in loaded.tools
assert loaded.tool_registry is not None
assert loaded.tool_registry.get("skill_view") is not None
finally:
loaded.close()

View File

@ -0,0 +1,64 @@
import asyncio
from types import SimpleNamespace
from beaver.engine.loop import AgentProfile
from beaver.engine.providers.anthropic import AnthropicProvider
from beaver.engine.providers.litellm import LiteLLMProvider
def test_agent_profile_uses_provider_output_default() -> None:
assert AgentProfile().max_tokens is None
def test_litellm_omits_max_tokens_when_unset(monkeypatch) -> None:
captured_kwargs: dict = {}
async def fake_acompletion(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content="ok", tool_calls=[]),
finish_reason="stop",
)
],
usage=None,
)
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
async def run_case():
provider = LiteLLMProvider(default_model="openai/gpt-test")
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
response = asyncio.run(run_case())
assert response.content == "ok"
assert "max_tokens" not in captured_kwargs
def test_anthropic_uses_model_output_ceiling_when_unset(monkeypatch) -> None:
captured_kwargs: dict = {}
class FakeMessages:
async def create(self, **kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(
content=[SimpleNamespace(type="text", text="ok")],
usage=None,
stop_reason="stop",
)
class FakeClient:
messages = FakeMessages()
monkeypatch.setattr(AnthropicProvider, "_client_or_raise", lambda self: FakeClient())
async def run_case():
provider = AnthropicProvider(default_model="claude-sonnet-4-5")
return await provider.chat(messages=[{"role": "user", "content": "hi"}], max_tokens=None)
response = asyncio.run(run_case())
assert response.content == "ok"
assert captured_kwargs["max_tokens"] == 64_000

View File

@ -5,6 +5,7 @@ from pathlib import Path
from beaver.engine.session import SessionManager
from beaver.memory.runs import RunMemoryStore, RunRecord
from beaver.services.process_service import SessionProcessProjector
from beaver.skills.specs import SkillActivationReceipt
def test_process_projection_maps_task_team_events(tmp_path: Path) -> None:
@ -238,6 +239,130 @@ def test_process_projection_uses_normalized_plan_metadata_defaults(tmp_path: Pat
assert planned_event["metadata"]["strategy"] == "single"
def test_process_projection_emits_skill_card_from_main_run_receipts(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="main-run",
session_id="web:test",
task_id="task-1",
attempt_index=1,
task_text="main task",
started_at="2026-01-01T00:00:03+00:00",
ended_at="2026-01-01T00:00:04+00:00",
success=True,
finish_reason="stop",
activated_skills=[
SkillActivationReceipt(
run_id="main-run",
session_id="web:test",
skill_name="web-operation",
skill_version="1",
content_hash="hash",
activated_at="2026-01-01T00:00:03+00:00",
activation_reason="Needs live web lookup.",
)
],
)
)
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={
"task_id": "task-1",
"attempt_index": 1,
"plan_mode": "single",
"strategy": "single",
"selected_skill_names": [],
},
context_visible=False,
)
session.append_message(
"web:test",
role="system",
event_type="task_synthesis_completed",
event_payload={"task_id": "task-1", "attempt_index": 1, "main_run_id": "main-run"},
context_visible=False,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
skill_events = [
event
for event in projection["events"]
if event["kind"] == "skill_selected" and event["run_id"] == "main-run"
]
assert skill_events
assert skill_events[0]["metadata"]["timeline_type"] == "skill"
assert skill_events[0]["metadata"]["skill_names"] == ["web-operation"]
def test_process_projection_emits_tool_cards_from_run_messages(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")
run_store.append_run_record(
RunRecord(
run_id="main-run",
session_id="web:test",
task_id="task-1",
attempt_index=1,
task_text="main task",
started_at="2026-01-01T00:00:03+00:00",
ended_at="2026-01-01T00:00:04+00:00",
success=True,
finish_reason="stop",
)
)
session.append_message(
"web:test",
role="system",
event_type="task_execution_planned",
event_payload={"task_id": "task-1", "attempt_index": 1},
context_visible=False,
)
session.append_message(
"web:test",
run_id="main-run",
role="assistant",
event_type="assistant_message_added",
event_payload={"task_id": "task-1"},
content="Searching",
tool_calls=[
{
"id": "call-1",
"name": "multi_search",
"arguments": {"query": "Macau cafe near Bóvia"},
}
],
context_visible=False,
)
session.append_message(
"web:test",
run_id="main-run",
role="tool",
event_type="tool_result_recorded",
event_payload={"success": True, "error": None},
content="Found 3 restaurants",
tool_name="multi_search",
tool_call_id="call-1",
context_visible=True,
)
projection = SessionProcessProjector(session, run_store).project("web:test")
tool_call = next(event for event in projection["events"] if event["kind"] == "tool_call_started")
assert tool_call["metadata"]["timeline_type"] == "tool_call"
assert tool_call["metadata"]["tool_name"] == "multi_search"
assert tool_call["run_id"] == "main-run"
tool_result = next(event for event in projection["events"] if event["kind"] == "tool_call_finished")
assert tool_result["metadata"]["timeline_type"] == "tool_result"
assert tool_result["metadata"]["tool_name"] == "multi_search"
assert tool_result["metadata"]["success"] is True
def test_process_projection_exposes_ephemeral_guidance_artifacts(tmp_path: Path) -> None:
session = SessionManager(tmp_path)
run_store = RunMemoryStore(tmp_path / "memory" / "runs")

View File

@ -0,0 +1,44 @@
from __future__ import annotations
import asyncio
from beaver.tools.builtins import web
class _FakeResponse:
headers = {"content-type": "text/html"}
status_code = 200
text = '<a class="result__a" href="https://example.com">Example</a>'
url = "https://example.com"
def raise_for_status(self) -> None:
return None
class _FakeAsyncClient:
calls: list[dict[str, object]] = []
def __init__(self, **kwargs: object) -> None:
self.calls.append(kwargs)
async def __aenter__(self) -> "_FakeAsyncClient":
return self
async def __aexit__(self, *args: object) -> None:
return None
async def get(self, *args: object, **kwargs: object) -> _FakeResponse:
return _FakeResponse()
def test_web_tools_use_environment_proxy_settings(monkeypatch) -> None:
_FakeAsyncClient.calls = []
monkeypatch.setattr(web.httpx, "AsyncClient", _FakeAsyncClient)
async def _run() -> None:
await web.WebFetchTool().execute(url="https://example.com")
await web.WebSearchTool().execute(query="example")
asyncio.run(_run())
assert [call.get("trust_env") for call in _FakeAsyncClient.calls] == [True, True]

View File

@ -15,7 +15,7 @@ import {
Settings2,
ScrollText,
} from 'lucide-react';
import { getStatus, updateProviderConfig } from '@/lib/api';
import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -42,6 +42,12 @@ type ProviderFormState = {
requestTimeoutSeconds: string;
};
type AgentFormState = {
maxTokens: string;
temperature: string;
maxToolIterations: string;
};
export default function StatusPage() {
const { locale } = useAppI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
@ -57,6 +63,13 @@ export default function StatusPage() {
}));
const [savingProvider, setSavingProvider] = useState(false);
const [providerError, setProviderError] = useState<string | null>(null);
const [agentForm, setAgentForm] = useState<AgentFormState>(() => ({
maxTokens: '',
temperature: '0.2',
maxToolIterations: '30',
}));
const [savingAgent, setSavingAgent] = useState(false);
const [agentError, setAgentError] = useState<string | null>(null);
const loadStatus = async () => {
setLoading(true);
@ -64,6 +77,11 @@ export default function StatusPage() {
try {
const data = await getStatus();
setStatus(data);
setAgentForm({
maxTokens: data.max_tokens == null ? '' : String(data.max_tokens),
temperature: String(data.temperature),
maxToolIterations: String(data.max_tool_iterations),
});
} catch (err: any) {
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
} finally {
@ -115,6 +133,39 @@ export default function StatusPage() {
}
};
const handleSaveAgentConfig = async () => {
setSavingAgent(true);
setAgentError(null);
try {
const maxTokensText = agentForm.maxTokens.trim();
const maxTokens = maxTokensText ? Number(maxTokensText) : null;
const temperature = Number(agentForm.temperature.trim());
const maxToolIterations = Number(agentForm.maxToolIterations.trim());
if (
maxTokens !== null &&
(!Number.isInteger(maxTokens) || maxTokens <= 0)
) {
throw new Error(pickAppText(locale, '最大令牌数必须为空或正整数', 'Max tokens must be blank or a positive integer'));
}
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
throw new Error(pickAppText(locale, '温度必须在 0 到 2 之间', 'Temperature must be between 0 and 2'));
}
if (!Number.isInteger(maxToolIterations) || maxToolIterations < 0) {
throw new Error(pickAppText(locale, '最大工具迭代次数必须是非负整数', 'Max tool iterations must be a non-negative integer'));
}
await updateAgentConfig({
max_tokens: maxTokens,
temperature,
max_tool_iterations: maxToolIterations,
});
await loadStatus();
} catch (err: any) {
setAgentError(err.message || pickAppText(locale, '保存智能体配置失败', 'Failed to save agent configuration'));
} finally {
setSavingAgent(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@ -207,14 +258,47 @@ export default function StatusPage() {
{pickAppText(locale, '智能体配置', 'Agent configuration')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-5">
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
<InfoRow
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
value={String(status.max_tool_iterations)}
/>
<div className="grid gap-4 border-t pt-5 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="agent-max-tokens">{pickAppText(locale, '最大令牌数', 'Max tokens')}</Label>
<Input
id="agent-max-tokens"
inputMode="numeric"
value={agentForm.maxTokens}
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxTokens: event.target.value }))}
placeholder={pickAppText(locale, '模型默认', 'Model default')}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="agent-temperature">{pickAppText(locale, '温度', 'Temperature')}</Label>
<Input
id="agent-temperature"
inputMode="decimal"
value={agentForm.temperature}
onChange={(event) => setAgentForm((prev) => ({ ...prev, temperature: event.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="agent-max-tool-iterations">
{pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
</Label>
<Input
id="agent-max-tool-iterations"
inputMode="numeric"
value={agentForm.maxToolIterations}
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxToolIterations: event.target.value }))}
/>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-destructive">{agentError || ''}</div>
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
</Button>
</div>
</CardContent>
</Card>

View File

@ -6,7 +6,6 @@ import React, { useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
import {
TaskAcceptanceCard,
TaskLiveHeader,
TaskSideRail,
TaskTimeline,
@ -19,10 +18,12 @@ import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import type { BackendTask } from '@/types';
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
const TASK_RESULT_REVIEW_ID = 'task-result-review';
export default function TaskDetailPage() {
const { locale } = useAppI18n();
@ -81,12 +82,12 @@ export default function TaskDetailPage() {
const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false;
React.useEffect(() => {
if (!isTaskLive || wsStatus === 'connected') return;
if (!shouldPollTaskDetail(backendTask)) return;
const id = window.setInterval(() => {
void loadBackendTask();
}, 4000);
return () => window.clearInterval(id);
}, [isTaskLive, loadBackendTask, wsStatus]);
}, [backendTask, loadBackendTask]);
const taskRunIds = useMemo(() => {
const ids = new Set<string>();
@ -129,7 +130,7 @@ export default function TaskDetailPage() {
const activeLabel =
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
const durationMs = backendTask ? taskDurationMs(backendTask) : null;
const durationMs = backendTask ? taskDetailDurationMs(backendTask) : null;
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
const runAction = async (key: string, action: () => Promise<unknown>) => {
@ -161,7 +162,7 @@ export default function TaskDetailPage() {
return (
<div className="min-h-screen bg-background">
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} />
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
@ -187,30 +188,32 @@ export default function TaskDetailPage() {
</Card>
) : null}
<TaskTimeline cards={timelineCards} isLive={isTaskLive && wsStatus === 'connected'} />
<TaskAcceptanceCard
sessionId={backendTask.session_id}
runId={feedbackRunId}
taskStatus={backendTask.status}
feedbackItems={feedbackItems as TaskFeedbackItem[]}
actionBusy={actionBusy}
revision={revision}
onRevisionChange={setRevision}
onSubmit={(feedbackType: TaskFeedbackType, comment?: string) =>
runAction(`backend-feedback-${feedbackType}`, async () => {
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
await submitChatFeedback({
sessionId: backendTask.session_id,
runId: feedbackRunId,
feedbackType,
comment,
});
updateMessageFeedback(feedbackRunId, feedbackType);
setRevision('');
await loadBackendTask();
})
}
<TaskTimeline
cards={timelineCards}
isLive={isTaskLive && wsStatus === 'connected'}
reviewTargetId={TASK_RESULT_REVIEW_ID}
resultAcceptance={{
sessionId: backendTask.session_id,
runId: feedbackRunId,
taskStatus: backendTask.status,
feedbackItems: feedbackItems as TaskFeedbackItem[],
actionBusy,
revision,
onRevisionChange: setRevision,
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
runAction(`backend-feedback-${feedbackType}`, async () => {
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
await submitChatFeedback({
sessionId: backendTask.session_id,
runId: feedbackRunId,
feedbackType,
comment,
});
updateMessageFeedback(feedbackRunId, feedbackType);
setRevision('');
await loadBackendTask();
}),
}}
/>
</div>
@ -252,10 +255,3 @@ function pickFeedbackRunId(task: BackendTask): string | null {
if (runs.length > 0) return runs[runs.length - 1].run_id;
return null;
}
function taskDurationMs(task: BackendTask): number | null {
const start = new Date(task.created_at).getTime();
const end = new Date(task.closed_at || task.updated_at).getTime();
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return Math.max(0, end - start);
}

View File

@ -113,19 +113,6 @@ export function TaskAcceptanceCard({
onSubmit,
}: Props) {
const { locale } = useAppI18n();
const [localComment, setLocalComment] = React.useState('');
const comment = revision ?? localComment;
const setComment = onRevisionChange ?? setLocalComment;
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
const trimmedComment = comment.trim();
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
if (!runId || !canSubmit) return;
void onSubmit(feedbackType, nextComment);
};
return (
<Card>
@ -141,7 +128,49 @@ export function TaskAcceptanceCard({
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent>
<TaskAcceptanceControls
sessionId={sessionId}
runId={runId}
taskStatus={taskStatus}
feedbackItems={feedbackItems}
actionBusy={actionBusy}
revision={revision}
onRevisionChange={onRevisionChange}
onSubmit={onSubmit}
/>
</CardContent>
</Card>
);
}
export function TaskAcceptanceControls({
sessionId,
runId,
taskStatus,
feedbackItems,
actionBusy,
revision,
onRevisionChange,
onSubmit,
}: Props) {
const { locale } = useAppI18n();
const [localComment, setLocalComment] = React.useState('');
const comment = revision ?? localComment;
const setComment = onRevisionChange ?? setLocalComment;
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
const trimmedComment = comment.trim();
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
if (!runId || !canSubmit) return;
void onSubmit(feedbackType, nextComment);
};
return (
<div className="space-y-4">
{recordedFeedback ? (
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
<div className="flex items-center gap-2 font-medium">
@ -207,7 +236,6 @@ export function TaskAcceptanceCard({
{pickAppText(locale, '会话:', 'Session: ')}
<span className="font-mono">{sessionId}</span>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
import { ArrowLeft, MessageSquare } from 'lucide-react';
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { Badge } from '@/components/ui/badge';
@ -15,6 +15,7 @@ type Props = {
task: BackendTask;
activeLabel: string;
durationMs: number | null;
reviewTargetId?: string;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
@ -36,9 +37,10 @@ function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
return item ? pickAppText(locale, item[0], item[1]) : status;
}
export function TaskLiveHeader({ task, activeLabel, durationMs }: Props) {
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
const { locale } = useAppI18n();
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
@ -67,6 +69,14 @@ export function TaskLiveHeader({ task, activeLabel, durationMs }: Props) {
</Badge>
)}
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
{showReviewLink ? (
<Button asChild variant="default" size="sm">
<a href={`#${reviewTargetId}`}>
<CheckCircle2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '验收', 'Review')}
</a>
</Button>
) : null}
</div>
</div>

View File

@ -34,11 +34,28 @@ function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
needs_revision: ['需要修改', 'Needs revision'],
closed: ['已完成', 'Closed'],
abandoned: ['已放弃', 'Abandoned'],
accept: ['已接受', 'Accepted'],
satisfied: ['已接受', 'Accepted'],
revise: ['已请求修改', 'Revision requested'],
abandon: ['已放弃', 'Abandoned'],
};
const item = map[status];
return item ? pickAppText(locale, item[0], item[1]) : status;
}
function latestFeedback(task: BackendTask): Record<string, unknown> | null {
return [...(task.feedback ?? [])].reverse()[0] ?? null;
}
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
const feedback = latestFeedback(task);
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
if (kind) return humanTaskStatus(kind, locale);
if (task.status === 'awaiting_acceptance') return pickAppText(locale, '等待验收', 'Awaiting acceptance');
if (task.status === 'needs_revision') return pickAppText(locale, '等待修改', 'Awaiting revision');
return pickAppText(locale, '未验收', 'No acceptance yet');
}
function toTime(value: string): number {
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : 0;
@ -135,6 +152,9 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
<div className="text-xs text-muted-foreground">
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
</div>
<div className="text-xs text-muted-foreground">
{pickAppText(locale, '验收', 'Acceptance')}: {acceptanceState(task, locale)}
</div>
</CardContent>
</Card>

View File

@ -7,14 +7,16 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
import { TaskTimelineCard } from './TaskTimelineCard';
import { TaskTimelineCard, type TaskResultAcceptance } from './TaskTimelineCard';
type Props = {
cards: TaskTimelineCardView[];
isLive: boolean;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
};
export function TaskTimeline({ cards, isLive }: Props) {
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
const { locale } = useAppI18n();
return (
@ -42,7 +44,7 @@ export function TaskTimeline({ cards, isLive }: Props) {
) : (
<div className="space-y-3">
{cards.map((card) => (
<TaskTimelineCard key={card.id} card={card} />
<TaskTimelineCard key={card.id} card={card} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
))}
</div>
)}

View File

@ -6,8 +6,10 @@ import {
Bot,
CheckCircle2,
ClipboardList,
ChevronDown,
FileText,
GitBranch,
History,
ListChecks,
Sparkles,
TerminalSquare,
@ -24,8 +26,23 @@ import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
type Props = {
card: TaskTimelineCardView;
resultAcceptance?: TaskResultAcceptance;
reviewTargetId?: string;
};
export type TaskResultAcceptance = {
sessionId: string;
runId: string | null;
taskStatus: string;
feedbackItems: TaskFeedbackItem[];
actionBusy: string | null;
revision?: string;
onRevisionChange?: (value: string) => void;
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise<unknown>;
};
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
@ -60,6 +77,8 @@ function iconForType(type: TaskTimelineCardType) {
return AlertTriangle;
case 'result':
return CheckCircle2;
case 'result_history':
return History;
case 'acceptance':
return ThumbsUp;
}
@ -87,6 +106,7 @@ function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
artifact: ['产物', 'Artifact'],
error: ['异常', 'Error'],
result: ['结果', 'Result'],
result_history: ['历史结果', 'Result history'],
acceptance: ['验收', 'Acceptance'],
};
const label = labels[type];
@ -111,12 +131,57 @@ function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
return label ? pickAppText(locale, label[0], label[1]) : status;
}
export function TaskTimelineCard({ card }: Props) {
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
const versions = details?.versions;
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
}
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
const status = String(version.acceptanceType || version.status || '');
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
}
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
const { locale } = useAppI18n();
const Icon = iconForType(card.type);
const versions = historyVersions(card.details);
return (
<Card className="rounded-md">
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</summary>
<div className="mt-3 space-y-3">
{versions.map((version, index) => (
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium">
{pickAppText(locale, `${index + 1} 轮结果`, `Version ${index + 1}`)}
</div>
<Badge variant="outline" className="text-[11px]">
{renderHistoryStatus(version, locale)}
</Badge>
</div>
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
{version.comment ? (
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
</div>
) : null}
</div>
))}
</div>
</details>
);
}
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
const { locale } = useAppI18n();
const Icon = iconForType(card.type);
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
return (
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
<CardContent className="p-4">
<div className="flex gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
@ -150,7 +215,13 @@ export function TaskTimelineCard({ card }: Props) {
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
{card.details ? (
{shouldRenderResultAcceptance ? (
<div className="mt-4 border-t border-border pt-4">
<TaskAcceptanceControls {...resultAcceptance!} />
</div>
) : null}
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
{pickAppText(locale, '详情 JSON', 'Details JSON')}

View File

@ -4,6 +4,7 @@ import type {
AuthzStatus,
AuthUser,
ActiveTask,
AgentConfigPayload,
ChatLogsResponse,
BackendTask,
ChatMessage,
@ -620,6 +621,13 @@ export async function getStatus(): Promise<SystemStatus> {
return fetchJSON('/api/status');
}
export async function updateAgentConfig(payload: AgentConfigPayload): Promise<{ ok: boolean }> {
return fetchJSON('/api/agent-config', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function updateProviderConfig(
providerId: string,
payload: ProviderConfigPayload

View File

@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
import type { BackendTask } from '@/types';
const baseTask: BackendTask = {
task_id: 'task-1',
session_id: 'web:test',
description: '查找餐厅',
goal: '查找餐厅',
constraints: [],
priority: 0,
status: 'running',
creator: 'main-agent',
created_at: '2026-05-27T02:02:41.000Z',
updated_at: '2026-05-27T02:02:41.500Z',
run_ids: [],
skill_names: [],
feedback: [],
metadata: {},
};
describe('task detail refresh helpers', () => {
it('polls executing task details regardless of websocket status', () => {
expect(shouldPollTaskDetail({ ...baseTask, status: 'running' })).toBe(true);
expect(shouldPollTaskDetail({ ...baseTask, status: 'open' })).toBe(true);
expect(shouldPollTaskDetail({ ...baseTask, status: 'awaiting_acceptance' })).toBe(false);
expect(shouldPollTaskDetail({ ...baseTask, status: 'closed' })).toBe(false);
});
it('uses current time for active task duration instead of stale updated_at', () => {
vi.setSystemTime(new Date('2026-05-27T02:03:41.000Z'));
expect(taskDetailDurationMs(baseTask)).toBe(60_000);
expect(taskDetailDurationMs({ ...baseTask, status: 'awaiting_acceptance', updated_at: '2026-05-27T02:10:55.000Z' })).toBe(494_000);
});
});

View File

@ -0,0 +1,18 @@
import type { BackendTask } from '@/types';
const EXECUTING_TASK_STATUSES = new Set(['open', 'queued', 'running']);
const FINISHED_FOR_DURATION_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
export function shouldPollTaskDetail(task: Pick<BackendTask, 'status'> | null): boolean {
return Boolean(task && EXECUTING_TASK_STATUSES.has(task.status));
}
export function taskDetailDurationMs(task: Pick<BackendTask, 'created_at' | 'updated_at' | 'closed_at' | 'status'>): number | null {
const start = new Date(task.created_at).getTime();
const end = FINISHED_FOR_DURATION_STATUSES.has(task.status)
? new Date(task.closed_at || task.updated_at).getTime()
: Date.now();
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return Math.max(0, end - start);
}

View File

@ -166,6 +166,133 @@ describe('buildTaskTimelineCards', () => {
expect(cards.at(-1)?.summary).toContain('可以');
});
it('uses the latest assistant message from the acceptance run as the result body', () => {
const task = makeTask({
status: 'awaiting_acceptance',
updated_at: '2026-05-26T10:04:00.000Z',
run_ids: ['run-main'],
runs: [
{
run_id: 'run-main',
title: '主 Agent',
session_id: 'web:default',
messages: [
{ role: 'assistant', content: 'Draft answer', created_at: '2026-05-26T10:03:00.000Z' },
{ role: 'assistant', content: 'Final user-visible answer', created_at: '2026-05-26T10:04:00.000Z' },
],
},
],
});
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-result-ready',
run_id: 'run-main',
parent_run_id: null,
kind: 'task_result_ready',
actor_type: 'system',
actor_id: 'evidence',
actor_name: 'Evidence',
text: 'The task result is ready for user acceptance.',
created_at: '2026-05-26T10:04:00.000Z',
metadata: {
result_summary: 'Summary should not replace the final answer.',
},
},
];
const cards = buildTaskTimelineCards({ task, processEvents });
const result = cards.find((card) => card.type === 'result');
expect(result?.summary).toBe('Final user-visible answer');
expect(result?.details?.result_summary).toBe('Summary should not replace the final answer.');
});
it('collapses previous result and acceptance cards into a history pack', () => {
const task = makeTask({
status: 'awaiting_acceptance',
updated_at: '2026-05-26T10:12:00.000Z',
run_ids: ['run-1', 'run-2'],
feedback: [
{
acceptance_type: 'revise',
comment: 'Add decisions',
created_at: '2026-05-26T10:06:00.000Z',
run_id: 'run-1',
},
],
runs: [
{
run_id: 'run-1',
title: '主 Agent',
session_id: 'web:default',
messages: [{ role: 'assistant', content: 'Version one answer', created_at: '2026-05-26T10:05:00.000Z' }],
},
{
run_id: 'run-2',
title: '主 Agent',
session_id: 'web:default',
messages: [{ role: 'assistant', content: 'Version two answer', created_at: '2026-05-26T10:12:00.000Z' }],
},
],
});
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-result-1',
run_id: 'run-1',
parent_run_id: null,
kind: 'task_result_ready',
actor_type: 'system',
actor_id: 'evidence',
actor_name: 'Evidence',
text: 'Result one ready.',
created_at: '2026-05-26T10:05:00.000Z',
},
{
event_id: 'evt-plan-2',
run_id: 'run-2',
parent_run_id: null,
kind: 'task_planned',
actor_type: 'system',
actor_id: 'planner',
actor_name: 'Task Planner',
text: 'Second attempt planned.',
created_at: '2026-05-26T10:08:00.000Z',
},
{
event_id: 'evt-result-2',
run_id: 'run-2',
parent_run_id: null,
kind: 'task_result_ready',
actor_type: 'system',
actor_id: 'evidence',
actor_name: 'Evidence',
text: 'Result two ready.',
created_at: '2026-05-26T10:12:00.000Z',
},
];
const cards = buildTaskTimelineCards({ task, processEvents });
expect(cards.map((card) => card.type)).toEqual([
'task_created',
'result_history',
'plan',
'result',
]);
const history = cards.find((card) => card.type === 'result_history');
expect(history?.summary).toBe('1 历史结果版本');
expect(history?.details?.versions).toEqual([
expect.objectContaining({
runId: 'run-1',
result: 'Version one answer',
acceptanceType: 'revise',
comment: 'Add decisions',
}),
]);
expect(cards.find((card) => card.id === 'evt-plan-2')).toBeTruthy();
expect(cards.at(-1)?.summary).toBe('Version two answer');
});
it('does not add fallback progress when a child run already has progress events', () => {
const task = makeTask();
const processRuns: ProcessRun[] = [
@ -201,6 +328,51 @@ describe('buildTaskTimelineCards', () => {
expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress');
});
it('marks a tool call as finished when a matching tool result exists', () => {
const task = makeTask();
const processEvents: ProcessEvent[] = [
{
event_id: 'evt-tool-start',
run_id: 'run-main',
parent_run_id: null,
kind: 'tool_call_started',
actor_type: 'mcp',
actor_id: 'web_search',
actor_name: 'web_search',
text: 'Calling tool: web_search.',
status: 'running',
created_at: '2026-05-26T10:02:00.000Z',
metadata: {
tool_call_id: 'call-1',
tool_name: 'web_search',
},
},
{
event_id: 'evt-tool-finish',
run_id: 'run-main',
parent_run_id: null,
kind: 'tool_call_finished',
actor_type: 'mcp',
actor_id: 'web_search',
actor_name: 'web_search',
text: 'Search failed.',
status: 'error',
created_at: '2026-05-26T10:03:00.000Z',
metadata: {
tool_call_id: 'call-1',
tool_name: 'web_search',
result_summary: 'Search failed.',
},
},
];
const cards = buildTaskTimelineCards({ task, processEvents });
expect(cards.find((card) => card.id === 'evt-tool-start')?.status).toBe('error');
expect(cards.find((card) => card.id === 'evt-tool-finish')?.type).toBe('tool_result');
expect(cards.find((card) => card.id === 'evt-tool-finish')?.summary).toBe('Search failed.');
});
it('maps agent_finished events without timeline metadata to agent progress cards', () => {
const task = makeTask();
const processEvents: ProcessEvent[] = [

View File

@ -27,6 +27,7 @@ const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
'artifact',
'error',
'result',
'result_history',
'acceptance',
]);
@ -77,10 +78,6 @@ function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
return timelineType;
}
if (event.status === 'error') {
return 'error';
}
switch (String(event.kind)) {
case 'task_planned':
case 'run_started':
@ -106,6 +103,9 @@ function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
case 'task_error':
return 'error';
default:
if (event.status === 'error') {
return 'error';
}
return null;
}
}
@ -136,6 +136,8 @@ function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
return '执行遇到问题';
case 'result':
return '本轮结果';
case 'result_history':
return '历史结果版本';
case 'acceptance':
return '任务验收';
}
@ -182,6 +184,22 @@ function resultSummary(task: BackendTask): string | undefined {
);
}
function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined {
if (!runId) return undefined;
const run = (task.runs ?? []).find((item) => item.run_id === runId);
if (!run) return undefined;
const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim());
return lastItem(assistantMessages)?.content.trim();
}
function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined {
return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event));
}
function fallbackResultSummary(task: BackendTask): string | undefined {
return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task));
}
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
const map = new Map<string, ProcessRun>();
for (const run of processRuns) {
@ -239,12 +257,106 @@ function isCoveredByAcceptanceEvent(
return matchingTypeEvents.length === 1;
}
function cardTime(card: TaskTimelineCard): number {
return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER;
}
function cardComment(card: TaskTimelineCard): string | undefined {
return firstString(card.details?.comment, card.summary);
}
function toolCallKeyFromEvent(event: ProcessEvent): string | null {
const toolCallId = firstString(event.metadata?.tool_call_id);
if (toolCallId) return `${event.run_id}:${toolCallId}`;
const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id);
if (toolName) return `${event.run_id}:${toolName}`;
return null;
}
function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string, string> {
const statuses = new Map<string, string>();
for (const event of processEvents) {
if (cardTypeForEvent(event) !== 'tool_result') continue;
const key = toolCallKeyFromEvent(event);
if (!key) continue;
statuses.set(key, event.status || 'done');
}
return statuses;
}
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
const versions = resultCards.map((resultCard) => {
const acceptanceCard = acceptanceCards
.filter((card) => card.runId === resultCard.runId)
.sort((a, b) => cardTime(a) - cardTime(b))
.at(-1);
return {
runId: resultCard.runId ?? null,
result: resultCard.summary ?? '',
createdAt: resultCard.createdAt,
status: acceptanceCard?.status ?? resultCard.status ?? null,
acceptanceType: acceptanceCard?.status ?? null,
comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '',
acceptedAt: acceptanceCard?.createdAt ?? null,
};
});
return {
id: `${task.task_id}:result-history`,
taskId: task.task_id,
type: 'result_history',
title: titleForCard('result_history'),
summary: `${resultCards.length} 历史结果版本`,
createdAt: resultCards[0]?.createdAt ?? task.created_at,
details: { versions },
};
}
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
const resultCards = cards.filter((card) => card.type === 'result');
if (resultCards.length <= 1) return cards;
const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id);
const latestResult =
(finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ??
[...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1);
if (!latestResult) return cards;
const oldResults = resultCards
.filter((card) => card.id !== latestResult.id)
.sort((a, b) => cardTime(a) - cardTime(b));
if (oldResults.length === 0) return cards;
const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean));
const oldAcceptances = cards
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
.sort((a, b) => cardTime(a) - cardTime(b));
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
const output: TaskTimelineCard[] = [];
for (let index = 0; index < cards.length; index += 1) {
if (index === firstOldResultIndex) {
output.push(historyCard);
}
if (!foldedIds.has(cards[index].id)) {
output.push(cards[index]);
}
}
return output;
}
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
const { task } = input;
const processRuns = input.processRuns ?? task.process_runs ?? [];
const processEvents = input.processEvents ?? task.process_events ?? [];
const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? [];
const runsById = buildRunMap(processRuns);
const toolResultStatusByCall = buildToolResultStatusByCall(processEvents);
const runsWithProgressEvents = new Set<string>();
const acceptanceEvents: AcceptanceEventIdentity[] = [];
let hasResultEventCard = false;
@ -285,9 +397,12 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
parentRunId: event.parent_run_id,
type,
title: titleForCard(type, event.actor_name),
summary: summaryForEvent(event),
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
actorName: event.actor_name,
status: event.status,
status:
type === 'tool_call'
? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status
: event.status,
createdAt: event.created_at,
details: detailsForEvent(event),
});
@ -340,7 +455,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
runId: lastItem(task.run_ids),
type: 'result',
title: titleForCard('result'),
summary: resultSummary(task),
summary: fallbackResultSummary(task),
status: task.status,
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
details: task.validation_result ?? undefined,
@ -366,8 +481,10 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
});
}
return cards
const sortedCards = cards
.map((card, index) => ({ card, index }))
.sort(compareCardsByCreatedAt)
.map(({ card }) => card);
return collapseHistoricalResults(task, sortedCards);
}

View File

@ -142,6 +142,12 @@ export interface ProviderConfigPayload {
request_timeout_seconds?: number;
}
export interface AgentConfigPayload {
max_tokens: number | null;
temperature: number;
max_tool_iterations: number;
}
export interface ChannelStatus {
name: string;
enabled: boolean;
@ -153,7 +159,7 @@ export interface SystemStatus {
workspace: string;
workspace_exists: boolean;
model: string;
max_tokens: number;
max_tokens: number | null;
max_context_messages?: number;
temperature: number;
max_tool_iterations: number;
@ -794,6 +800,7 @@ export type TaskTimelineCardType =
| 'artifact'
| 'error'
| 'result'
| 'result_history'
| 'acceptance';
export interface TaskTimelineCard {