feat(task): 添加任务修订功能和超时处理机制

添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。
实现了工具失败指导原则,防止相同类别工具重复失败。
为任务规划器添加了超时处理机制,避免长时间等待。

BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。

fix(api): 修复任务详情API返回完整流程投影

修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息,
并确保时间戳字段正确序列化。

refactor(engine): 优化任务技能解析器摘要节点处理

改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节
点不再分配具体技能,直接使用依赖项输出进行汇总。

test: 增加任务修订和超时处理测试用例

添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及
摘要节点技能解析等新功能。
This commit is contained in:
2026-05-21 16:40:44 +08:00
parent 0caca8db8a
commit a27560102b
22 changed files with 855 additions and 93 deletions

View File

@ -20,6 +20,13 @@ from beaver.tools import ToolContext
from .loader import EngineLoader, EngineLoadResult
TOOL_FAILURE_GUIDANCE_PROMPT = (
"# Tool Failure Guidance\n\n"
"If the same class of tools fails repeatedly in a run, stop retrying with query variants. "
"Use available materials, state uncertainty clearly, and provide partial confirmed results."
)
@dataclass(slots=True)
class AgentProfile:
"""Runtime profile for a Beaver agent instance."""
@ -548,6 +555,7 @@ class AgentLoop:
parent_session_id=parent_session_id,
),
execution_context=execution_context,
extra_sections=[TOOL_FAILURE_GUIDANCE_PROMPT],
)
context_result = context_builder.build_messages(build_input)
if skill_selection_context:

View File

@ -75,6 +75,8 @@ class MessageRecord:
"role": self.role,
"content": self.content,
}
if self.timestamp is not None:
payload["timestamp"] = self.timestamp
if self.run_id:
payload["run_id"] = self.run_id
if self.event_payload:

View File

@ -1635,6 +1635,8 @@ def create_app(
@app.get("/api/tasks/{task_id}")
async def get_task(task_id: str, request: Request) -> dict[str, Any]:
from beaver.services.process_service import SessionProcessProjector
loaded = get_agent_service(request).create_loop().boot()
task_service = loaded.task_service
if task_service is None:
@ -1642,10 +1644,18 @@ def create_app(
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
process_projection = SessionProcessProjector(
loaded.session_manager,
loaded.run_memory_store,
).project(task.session_id)
filtered_process = _filter_task_process_projection(process_projection, task_id)
return {
**task_service.to_api_dict(task),
"events": [event.to_dict() for event in task_service.list_events(task_id)],
"runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store), # type: ignore[arg-type]
"process_runs": filtered_process["runs"],
"process_events": filtered_process["events"],
"process_artifacts": filtered_process["artifacts"],
}
@app.delete("/api/tasks/{task_id}")
@ -2153,6 +2163,33 @@ def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memo
return views
def _filter_task_process_projection(projection: dict[str, Any], task_id: str) -> dict[str, list[dict[str, Any]]]:
def belongs_to_task(item: dict[str, Any]) -> bool:
metadata = item.get("metadata")
return isinstance(metadata, dict) and metadata.get("task_id") == task_id
def with_task_metadata(item: dict[str, Any]) -> dict[str, Any]:
copied = dict(item)
metadata = dict(copied.get("metadata") or {})
metadata.setdefault("task_id", task_id)
copied["metadata"] = metadata
return copied
runs = [with_task_metadata(item) for item in projection.get("runs", []) if isinstance(item, dict) and belongs_to_task(item)]
run_ids = {str(item.get("run_id")) for item in runs if item.get("run_id")}
events = [
with_task_metadata(item)
for item in projection.get("events", [])
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
]
artifacts = [
with_task_metadata(item)
for item in projection.get("artifacts", [])
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
]
return {"runs": runs, "events": events, "artifacts": artifacts}
def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]:
labels: dict[str, str] = {}
for event in events:

View File

@ -581,8 +581,96 @@ class AgentService:
if active_task is None or decision.starts_new_task
else active_task
)
if active_task is not None and decision.action == "revise_task" and task.task_id == active_task.task_id:
task = self._record_revision_feedback_for_task(
loaded,
task=task,
session_id=session_id,
comment=message,
)
return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task)
def _record_revision_feedback_for_task(
self,
loaded: Any,
*,
task: TaskRecord,
session_id: str,
comment: str,
) -> TaskRecord:
"""Mark the latest feedback-eligible run as revised before continuing a task."""
if task.status not in {"awaiting_feedback", "needs_revision"}:
return task
run_id = next((item for item in reversed(task.run_ids) if item), None)
if not run_id:
return task
existing = next((item for item in task.feedback if item.get("run_id") == run_id), None)
if existing is not None:
if existing.get("feedback_type") != "revise":
return task
updated = task
already_recorded = True
else:
task_service = self._require_loaded(loaded, "task_service")
updated = task_service.add_feedback(
task.task_id,
feedback_type="revise",
comment=comment,
run_id=run_id,
)
already_recorded = False
session_manager = self._require_loaded(loaded, "session_manager")
session_manager.update_latest_assistant_event_payload(
session_id,
run_id,
{
"task_id": updated.task_id,
"task_status": updated.status,
"feedback_state": "revise",
},
)
if already_recorded:
return updated
session_manager.append_message(
session_id,
run_id=run_id,
role="system",
event_type="task_feedback_recorded",
event_payload={
"task_id": updated.task_id,
"feedback_type": "revise",
"comment": comment,
"task_status": updated.status,
"auto_recorded": True,
},
content=comment,
context_visible=False,
)
validation = ValidationResult.from_dict(updated.validation_result)
run_memory_store = self._require_loaded(loaded, "run_memory_store")
run_memory_store.update_run_record(
run_id,
success=False,
feedback={
"feedback_type": "revise",
"comment": comment,
"task_status": updated.status,
},
)
run_memory_store.update_skill_effects_for_run(
run_id,
success=False,
feedback_score=self._feedback_score_for_learning("revise", validation),
notes=comment.strip() or "revise",
)
skill_learning_service = self._require_loaded(loaded, "skill_learning_service")
skill_learning_service.rescore_skill_versions()
return updated
async def _run_task_mode(
self,
message: str,
@ -1018,7 +1106,11 @@ class AgentService:
if plan.final_synthesis_instruction
else None
),
"Use the team outputs as internal evidence. Produce the final user-facing answer yourself.",
(
"Use successful team outputs as internal evidence. If one or more nodes failed, "
"do not blindly repeat failed tool calls. Produce a user-visible fallback answer "
"with available evidence and clearly state any missing or uncertain data."
),
]
if item
)
@ -1031,7 +1123,11 @@ class AgentService:
f"Planner reason: {plan.reason}",
f"Strategy: {plan.graph.strategy if plan.graph else ''}",
f"Error: {error}",
"Proceed as the main agent and produce the best possible final answer.",
(
"Proceed as the main agent. Do not blindly repeat failed tool calls; "
"produce a user-visible fallback answer with available evidence and clearly "
"state any missing or uncertain data."
),
]
)

View File

@ -13,6 +13,7 @@ Your only job is to classify the current user message into one routing decision:
- `simple_chat`
- `continue_task`
- `revise_task`
- `new_task`
- `close_task`
- `abandon_task`
@ -27,6 +28,23 @@ Choose `new_task` when the user asks for anything that needs the main Task agent
The Intent Agent has no tools. If a request needs a tool, do not apologize and do not say you cannot access it. Route it to Task mode so the main agent can use tools.
When there is an active task, do not force every new user message into that task. Use the active task and recent conversation to decide:
- Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result.
- Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task.
- Choose `new_task` when the user asks for clearly unrelated work.
- Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good".
- Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task.
Examples with an active weather task:
- "再详细一点" -> `revise_task`
- "加上明后天穿衣建议" -> `revise_task`
- "顺便查一下深圳" -> `continue_task`
- "帮我写一个采购合同" -> `new_task`
- "可以了" -> `close_task`
- "不用了" -> `abandon_task`
## Must Create Task
Choose `new_task` when there is no active task and the request asks to:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from typing import Any, Literal
@ -77,6 +78,7 @@ class TaskExecutionPlanner:
attempt_index: int,
latest_validation: ValidationResult | None = None,
provider_bundle: ProviderBundle | None = None,
timeout_seconds: float = 30.0,
) -> TaskExecutionPlan:
provider = None
model = None
@ -87,29 +89,32 @@ class TaskExecutionPlanner:
if provider is None:
return TaskExecutionPlan.single("planner_provider_unavailable")
try:
response = await provider.chat(
messages=[
{
"role": "system",
"content": (
"You choose whether an internal Beaver Task attempt should run as a single "
"main-agent pass or use a small sub-agent team first. Return only compact JSON."
),
},
{
"role": "user",
"content": self._prompt(
task=task,
user_message=user_message,
attempt_index=attempt_index,
latest_validation=latest_validation,
),
},
],
tools=None,
model=model,
max_tokens=4096,
temperature=0.0,
response = await asyncio.wait_for(
provider.chat(
messages=[
{
"role": "system",
"content": (
"You choose whether an internal Beaver Task attempt should run as a single "
"main-agent pass or use a small sub-agent team first. Return only compact JSON."
),
},
{
"role": "user",
"content": self._prompt(
task=task,
user_message=user_message,
attempt_index=attempt_index,
latest_validation=latest_validation,
),
},
],
tools=None,
model=model,
max_tokens=4096,
temperature=0.0,
),
timeout=timeout_seconds,
)
plan = self.from_json(response.content or "")
return await self._resolve_plan(
@ -120,7 +125,9 @@ class TaskExecutionPlanner:
provider_bundle=provider_bundle,
)
except Exception as exc:
return TaskExecutionPlan.single("planner_failed", fallback_error=str(exc))
detail = str(exc)
error = f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
return TaskExecutionPlan.single("planner_failed", fallback_error=error)
async def _resolve_plan(
self,

View File

@ -69,6 +69,14 @@ class MainAgentRouter:
reason = str(payload.get("reason") or raw_action or "llm_router")
short_title = _clean_short_title(payload.get("short_title") or payload.get("title"))
if raw_action in {"revise_task", "revise", "revision", "needs_revision"}:
return MainAgentDecision(
mode="task",
reason=reason,
starts_new_task=active_task is None,
short_title=short_title,
action="revise_task" if active_task is not None else "create_task",
)
if raw_action in {"continue_task", "continue", "task"}:
return MainAgentDecision(
mode="task",
@ -146,13 +154,16 @@ class MainAgentRouter:
"Actions:\n"
"- simple_chat: no Task should be created or continued.\n"
"- continue_task: keep the user in the active Task.\n"
"- revise_task: user asks to change, correct, refine, expand, reformat, or redo the latest active Task result.\n"
"- new_task: start a separate new Task.\n"
"- close_task: user explicitly says the active Task is done/satisfactory/finished.\n"
"- abandon_task: user explicitly says to stop, cancel, abandon, or no longer do the active Task.\n\n"
"Critical policy:\n"
"- If there is an active Task, choose continue_task unless the user's topic is completely unrelated "
"- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated "
"to that Task or the user explicitly closes/abandons it.\n"
"- Follow-up questions, corrections, partial changes, extra constraints, and result discussion stay in continue_task.\n"
"- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes "
"such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n"
"- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n"
"- Use new_task only when the user clearly asks to start a different task.\n"
"- If there is no active Task, choose new_task only for work that requires execution, iteration, tools, files, "
"implementation, validation, or multi-step completion. Otherwise choose simple_chat.\n"

View File

@ -93,6 +93,29 @@ class TaskSkillResolver:
for item in node.agent.metadata.get("required_capabilities", [])
if str(item).strip()
]
if self._is_summary_only_node(node, skill_query=skill_query, required_capabilities=required_capabilities):
resolved = self._generic_node(
node,
pinned_skill_names=[],
pinned_skill_contexts=[],
metadata={
**node.agent.metadata,
"skill_query": skill_query,
"required_capabilities": required_capabilities,
"selected_skill_names": [],
"ephemeral_skill_names": [],
"summary_uses_dependency_outputs_only": True,
},
)
return resolved, SkillResolutionReport(
node_id=node.node_id,
skill_query=skill_query,
required_capabilities=required_capabilities,
selected_skill_names=[],
ephemeral_used=False,
reason="summary node uses dependency outputs directly",
)
selected = await self._select_published_skills(
query="\n".join(
part
@ -226,6 +249,34 @@ class TaskSkillResolver:
selected.append(name)
return selected
@staticmethod
def _is_summary_only_node(
node: ExecutionNode,
*,
skill_query: str,
required_capabilities: list[str],
) -> bool:
node_id = node.node_id.strip().lower()
query = skill_query.strip().lower()
capabilities = {item.strip().lower() for item in required_capabilities}
task_text = node.task.strip().lower()
summary_identity = node_id in {"summarize", "summary", "synthesis"} or query in {
"summarization",
"summary",
"synthesis",
"final synthesis",
}
text_only_capabilities = not capabilities or capabilities.issubset(
{"text generation", "summarization", "summary", "synthesis"}
)
dependency_summary_task = (
"summary" in task_text
or "summarize" in task_text
or "synthesis" in task_text
or "compile" in task_text
)
return summary_identity and text_only_capabilities and dependency_summary_task
@staticmethod
def _generic_node(
node: ExecutionNode,
@ -246,7 +297,9 @@ class TaskSkillResolver:
},
),
inherited_pinned_skills=pinned_skill_names,
inherited_pinned_skill_contexts=list(pinned_skill_contexts or node.inherited_pinned_skill_contexts),
inherited_pinned_skill_contexts=list(
node.inherited_pinned_skill_contexts if pinned_skill_contexts is None else pinned_skill_contexts
),
)
@staticmethod