放弃完全隔离策略,改为在现有 MainTerminal/SubAgentManager/SubAgentTask 主链路 按对话级开关 metadata.multi_agent_mode=true 增加多智能体分支。 新增模块: - modules/multi_agent/__init__.py: 模块入口 - modules/multi_agent/role_store.py: 角色 Markdown Frontmatter 解析与归档 - modules/multi_agent/state.py: 多智能体会话状态机与消息格式化 - modules/multi_agent/prompts.py: 主智能体(Team Leader) + 子智能体提示词 - modules/multi_agent/tools.py: 9 个主智能体工具 + 4 个子智能体工具定义 - server/multi_agent.py: /multiagent/new 页面 + /api/multiagent/* 蓝图 现有代码改动: - modules/sub_agent/task.py: 扩展 multi_agent_mode/multi_agent_state/display_name 字段, 增加 ask_master/ask_other_agent/answer_other_agent/list_active_sub_agents 工具处理逻辑, 子智能体自然结束 assistant 输出即本轮结束(不调用 finish_task),上下文保留。 - modules/sub_agent/manager.py: create_sub_agent 增加 multi_agent_mode/role_id/display_name 参数, 增加 get_or_create_multi_agent_state/get_multi_agent_state/inject_message_to_sub_agent/_on_multi_agent_task_done 方法。 - core/main_terminal_parts/tools_definition/agent_tools.py: 多智能体模式下用 modules.multi_agent.tools 替换旧版工具集。 - core/main_terminal_parts/context/messages.py: 多智能体模式下追加 Team Leader 系统提示词。 - core/main_terminal_parts/tools_execution.py: create_sub_agent handler 增加多智能体分支,新增 send_message_to_sub_agent/ask_sub_agent/answer_sub_agent_question/create_custom_agent/list_agents/list_active_sub_agents handler。 - core/web_terminal.py: load_conversation 时检测 metadata.multi_agent_mode 设置 self.multi_agent_mode。 - server/app_legacy.py: 注册 multi_agent_bp 蓝图。 前端改动: - static/src/auth/LoginApp.vue: 登录页增加'多智能体模式(beta)'按钮 - static/src/app/methods/ui/route.ts: 识别 /multiagent/new 和 /multiagent/conv_xxx 路径,进入多智能体模式并创建带 metadata.multi_agent_mode=true 的对话 - static/src/app/state.ts: 增加 multiAgentMode 状态字段 数据: - ~/.astrion/astrion/host/mutiagents/agents/: 4 个预置角色 ui-operator / full-stack-engineer / code-reviewer / researcher - ~/.astrion/astrion/host/mutiagents/conversations/: 会话数据 验证:所有 Python 文件语法检查通过;冒烟测试 test.test_server_refactor_smoke 6 项全通过;前端构建通过(6.04s);模块导入与功能断言测试全部通过。
482 lines
21 KiB
Python
482 lines
21 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import base64
|
||
import json
|
||
import mimetypes
|
||
import time
|
||
import uuid
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||
|
||
from modules.sub_agent.toolkit import (
|
||
SUB_AGENT_TOOLS,
|
||
FINISH_TOOL,
|
||
_format_tool_result,
|
||
_build_sub_agent_profile,
|
||
)
|
||
from utils.api_client import DeepSeekClient
|
||
from utils.logger import setup_logger
|
||
|
||
if TYPE_CHECKING:
|
||
from modules.sub_agent.manager import SubAgentManager
|
||
from modules.multi_agent.state import MultiAgentState
|
||
|
||
logger = setup_logger(__name__)
|
||
|
||
# 多智能体模式下额外加载的工具定义
|
||
def _load_multi_agent_sub_agent_tools() -> List[Dict[str, Any]]:
|
||
try:
|
||
from modules.multi_agent.tools import build_sub_agent_tools_for_role
|
||
return build_sub_agent_tools_for_role()
|
||
except Exception as exc:
|
||
logger.warning(f"[SubAgentTask] 加载多智能体工具失败: {exc}")
|
||
return []
|
||
|
||
|
||
class SubAgentTask:
|
||
"""单个后台子智能体任务。"""
|
||
|
||
def __init__(
|
||
self,
|
||
manager: "SubAgentManager",
|
||
task_record: Dict[str, Any],
|
||
task_message: str,
|
||
system_prompt: str,
|
||
model_key: Optional[str],
|
||
thinking_mode: Optional[str],
|
||
*,
|
||
multi_agent_mode: bool = False,
|
||
multi_agent_state: Optional["MultiAgentState"] = None,
|
||
display_name: Optional[str] = None,
|
||
):
|
||
self.manager = manager
|
||
self.task_record = task_record
|
||
self.task_message = task_message
|
||
self.system_prompt = system_prompt
|
||
self.model_key = model_key
|
||
self.thinking_mode = thinking_mode or "fast"
|
||
self.task_id = task_record["task_id"]
|
||
self.agent_id = task_record["agent_id"]
|
||
self.timeout_seconds = int(task_record.get("timeout_seconds") or 180)
|
||
self.deliverables_dir = Path(task_record["deliverables_dir"])
|
||
|
||
self.output_file = Path(task_record["output_file"])
|
||
self.stats_file = Path(task_record["stats_file"])
|
||
self.progress_file = Path(task_record["progress_file"])
|
||
self.conversation_file = Path(task_record["conversation_file"])
|
||
|
||
self.messages: List[Dict[str, Any]] = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": task_message},
|
||
]
|
||
self.stats = {
|
||
"runtime_start": time.time() * 1000,
|
||
"runtime_seconds": 0,
|
||
"files_read": 0,
|
||
"write_files": 0,
|
||
"edit_files": 0,
|
||
"searches": 0,
|
||
"web_pages": 0,
|
||
"commands": 0,
|
||
"api_calls": 0,
|
||
"token_usage": {"prompt": 0, "completion": 0, "total": 0},
|
||
}
|
||
self._stdout_lines: List[str] = []
|
||
self._cancelled = False
|
||
self._task: Optional[asyncio.Task] = None
|
||
|
||
# 多智能体模式相关字段
|
||
self.multi_agent_mode = bool(multi_agent_mode)
|
||
self.multi_agent_state = multi_agent_state
|
||
# display_name 不传时回退为 'Agent_{agent_id}'
|
||
self.display_name = display_name or f"Agent_{self.agent_id}"
|
||
|
||
def emit(self, type_: str, data: Dict[str, Any]) -> None:
|
||
"""输出一行 JSONL 到 progress 文件并缓存。"""
|
||
line = json.dumps({"type": type_, **data}, ensure_ascii=False)
|
||
self._stdout_lines.append(line)
|
||
try:
|
||
self.progress_file.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(self.progress_file, "a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
async def run(self) -> None:
|
||
"""主 LLM 循环。"""
|
||
try:
|
||
await self._run_loop()
|
||
except asyncio.CancelledError:
|
||
self._cancelled = True
|
||
logger.debug(f"[SubAgent] task={self.task_id} 被取消")
|
||
# shield 避免取消信号中断最终状态落盘
|
||
await asyncio.shield(self._write_failure("子智能体被手动终止"))
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception(f"[SubAgent] task={self.task_id} 执行异常")
|
||
await self._write_failure(f"执行异常: {exc}")
|
||
|
||
async def _run_loop(self) -> None:
|
||
client, model_key = self._build_client()
|
||
if self.multi_agent_mode:
|
||
tools = list(SUB_AGENT_TOOLS)
|
||
tools.extend(_load_multi_agent_sub_agent_tools())
|
||
# 多智能体模式下不要求 finish_task,自然输出结束即本轮任务结束
|
||
else:
|
||
tools = list(SUB_AGENT_TOOLS)
|
||
tools.append(FINISH_TOOL)
|
||
|
||
start_time = time.time()
|
||
max_turns = 50
|
||
|
||
for turn in range(1, max_turns + 1):
|
||
if self._cancelled:
|
||
break
|
||
elapsed = time.time() - start_time
|
||
if elapsed > self.timeout_seconds:
|
||
await self._write_timeout(elapsed)
|
||
return
|
||
|
||
self.stats["api_calls"] += 1
|
||
self.stats["turn_count"] = turn
|
||
self.stats["runtime_seconds"] = int(elapsed)
|
||
self.emit("stats", {**self.stats, "turn_count": turn})
|
||
|
||
assistant_message, reasoning, tool_calls, usage = await self._call_model(client, model_key, tools)
|
||
if usage:
|
||
self._apply_usage(usage)
|
||
|
||
# 多智能体模式:把 assistant 文本输出作为进度/完成 output 转发到主对话
|
||
if self.multi_agent_mode and self.multi_agent_state and assistant_message.strip():
|
||
self._forward_output_to_master(assistant_message)
|
||
|
||
final_message: Dict[str, Any] = {"role": "assistant", "content": assistant_message}
|
||
if reasoning:
|
||
final_message["reasoning_content"] = reasoning
|
||
if tool_calls:
|
||
final_message["tool_calls"] = tool_calls
|
||
self.messages.append(final_message)
|
||
|
||
if not tool_calls:
|
||
# 多智能体模式:没有 tool_calls 表示本轮结束,进入 idle 状态
|
||
if self.multi_agent_mode:
|
||
self._mark_idle()
|
||
return
|
||
# 普通模式:prompt 并要求继续 / finish_task
|
||
self.messages.append({
|
||
"role": "user",
|
||
"content": "如果你已经完成了任务,请调用 finish_task 工具提交完成报告。如果还没有完成,请继续执行任务。",
|
||
})
|
||
continue
|
||
|
||
for tool_call in tool_calls:
|
||
if self._cancelled:
|
||
break
|
||
name = tool_call.get("function", {}).get("name", "")
|
||
args = self._parse_args(tool_call)
|
||
progress_id = tool_call.get("id") or f"tool_{int(time.time() * 1000)}_{uuid.uuid4().hex[:6]}"
|
||
|
||
if name == "finish_task":
|
||
await self._write_finish(args, elapsed)
|
||
return
|
||
|
||
self.emit("progress", {"id": progress_id, "tool": name, "status": "running", "args": args, "ts": int(time.time() * 1000)})
|
||
result = await self._execute_tool(name, args)
|
||
self.emit("progress", {"id": progress_id, "tool": name, "status": "completed" if result.get("success") else "failed", "args": args, "ts": int(time.time() * 1000)})
|
||
|
||
self._update_stats(name)
|
||
|
||
content = _format_tool_result(name, result)
|
||
if name == "read_mediafile" and result.get("success"):
|
||
content = self._build_media_tool_content(result) or content
|
||
self.messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call.get("id", progress_id),
|
||
"content": content,
|
||
})
|
||
|
||
await self._write_failure("任务执行超过最大轮次限制", max_turns_exceeded=True)
|
||
|
||
def _forward_output_to_master(self, output_text: str) -> None:
|
||
"""把子智能体的 assistant 文本输出转发成主对话的 user 消息。"""
|
||
if not self.multi_agent_state:
|
||
return
|
||
try:
|
||
from modules.multi_agent.state import build_sub_agent_output_text
|
||
msg = build_sub_agent_output_text(self.display_name, output_text.strip())
|
||
self.multi_agent_state.push_master_message(msg)
|
||
# 同时记录到实例状态,供 list_active_sub_agents 使用
|
||
inst = self.multi_agent_state.get_instance(self.agent_id)
|
||
if inst:
|
||
inst.last_output = output_text[:500]
|
||
except Exception as exc:
|
||
logger.warning(f"[SubAgentTask] forward output to master failed: {exc}")
|
||
|
||
def _mark_idle(self) -> None:
|
||
"""多智能体模式下,子智能体自然结束']=本轮任务 结束,进入 idle 状态。"""
|
||
if self.multi_agent_state:
|
||
self.multi_agent_state.mark_status(self.agent_id, "idle")
|
||
|
||
def _build_client(self) -> tuple:
|
||
"""加载模型配置并初始化 DeepSeekClient。"""
|
||
config_path = self.manager.models_config_file
|
||
models: List[Dict[str, Any]] = []
|
||
default_key = ""
|
||
if Path(config_path).exists():
|
||
try:
|
||
raw = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
||
models = raw.get("models", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
|
||
default_key = str(raw.get("default_model", "")) if isinstance(raw, dict) else ""
|
||
except Exception as exc:
|
||
logger.error(f"[SubAgent] 加载模型配置失败: {exc}")
|
||
|
||
model_map = {}
|
||
valid_models = []
|
||
for item in models:
|
||
profile = _build_sub_agent_profile(item)
|
||
if profile:
|
||
key = profile["name"]
|
||
model_map[key] = profile
|
||
valid_models.append(key)
|
||
|
||
chosen_key = self.model_key or default_key
|
||
if chosen_key not in model_map and valid_models:
|
||
chosen_key = valid_models[0]
|
||
if chosen_key not in model_map:
|
||
raise RuntimeError(f"未找到可用子智能体模型配置: {config_path}")
|
||
|
||
client = DeepSeekClient(thinking_mode=(self.thinking_mode == "thinking"), web_mode=True)
|
||
client.model_key = chosen_key
|
||
client.project_path = str(self.manager.project_path)
|
||
if self.thinking_mode == "thinking":
|
||
# 子智能体的 thinking 模式应全程使用思考模型
|
||
client.deep_thinking_session = True
|
||
client.apply_profile(model_map[chosen_key])
|
||
return client, chosen_key
|
||
|
||
async def _call_model(
|
||
self,
|
||
client: DeepSeekClient,
|
||
model_key: str,
|
||
tools: List[Dict[str, Any]],
|
||
) -> tuple:
|
||
"""调用模型并解析 assistant 消息。"""
|
||
assistant_message = ""
|
||
reasoning = ""
|
||
tool_calls: List[Dict[str, Any]] = []
|
||
usage = None
|
||
|
||
async for chunk in client.chat(self.messages, tools=tools, stream=True):
|
||
if self._cancelled:
|
||
break
|
||
if chunk.get("error"):
|
||
raise RuntimeError(f"API 调用失败: {chunk.get('error')}")
|
||
choice = (chunk.get("choices") or [{}])[0]
|
||
delta = choice.get("delta") or {}
|
||
if delta.get("content"):
|
||
assistant_message += delta["content"]
|
||
if delta.get("reasoning_content"):
|
||
reasoning += delta["reasoning_content"]
|
||
elif delta.get("reasoning_details"):
|
||
rd = delta["reasoning_details"]
|
||
if isinstance(rd, list):
|
||
reasoning += "".join(str(d.get("text") or "") for d in rd)
|
||
elif isinstance(rd, str):
|
||
reasoning += rd
|
||
elif isinstance(rd, dict):
|
||
reasoning += str(rd.get("text") or "")
|
||
|
||
for tc in delta.get("tool_calls") or []:
|
||
idx = tc.get("index")
|
||
if idx is None:
|
||
continue
|
||
while len(tool_calls) <= idx:
|
||
tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}})
|
||
existing = tool_calls[idx]
|
||
if tc.get("id"):
|
||
existing["id"] = tc["id"]
|
||
fn = tc.get("function") or {}
|
||
if fn.get("name"):
|
||
existing["function"]["name"] += fn["name"]
|
||
if fn.get("arguments"):
|
||
existing["function"]["arguments"] += fn["arguments"]
|
||
|
||
if chunk.get("usage"):
|
||
usage = chunk["usage"]
|
||
|
||
return assistant_message, reasoning, tool_calls, usage
|
||
|
||
def _parse_args(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
|
||
raw = tool_call.get("function", {}).get("arguments") or "{}"
|
||
try:
|
||
return json.loads(raw)
|
||
except Exception:
|
||
return {"_raw": raw}
|
||
|
||
async def _execute_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""通过 manager 调用主进程执行工具。
|
||
|
||
多智能体模式下,对于通信工具(ask_master / ask_other_agent / answer_other_agent/
|
||
list_active_sub_agents)在主进程内直接处理,不再转发到 WebTerminal。
|
||
"""
|
||
if self.multi_agent_mode and self.multi_agent_state:
|
||
result = await self._execute_multi_agent_tool(name, args)
|
||
if result is not None:
|
||
return result
|
||
return await self.manager.execute_tool_for_sub_agent(name, args)
|
||
|
||
async def _execute_multi_agent_tool(self, name: str, args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
"""处理多智能体模式专属的通信工具。返回 None 表示不 属于多智能体工具。"""
|
||
state = self.multi_agent_state
|
||
if not state:
|
||
return None
|
||
try:
|
||
if name == "ask_master":
|
||
question = str(args.get("question") or "").strip()
|
||
question_id = str(args.get("question_id") or f"ask_master_{uuid.uuid4().hex[:10]}")
|
||
if not question:
|
||
return {"success": False, "error": "question 不能为空"}
|
||
# 插入到主对话
|
||
from modules.multi_agent.state import build_sub_agent_ask_master_text
|
||
msg = build_sub_agent_ask_master_text(self.display_name, question, question_id)
|
||
state.push_master_message(msg)
|
||
inst = state.get_instance(self.agent_id)
|
||
if inst:
|
||
inst.last_output = f"[ask_master] {question[:200]}"
|
||
# 阻塞等待回答(状态标为正在等待主智能体回答)
|
||
state.mark_status(self.agent_id, "running")
|
||
answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600))
|
||
state.mark_status(self.agent_id, "running")
|
||
return {"success": True, "answer": answer, "question_id": question_id}
|
||
|
||
if name == "ask_other_agent":
|
||
target_id = int(args.get("target_agent_id") or 0)
|
||
question = str(args.get("question") or "").strip()
|
||
question_id = str(args.get("question_id") or f"ask_other_{uuid.uuid4().hex[:10]}")
|
||
if not target_id or not question:
|
||
return {"success": False, "error": "参数缺失"}
|
||
# 查找目标实例
|
||
target_inst = state.get_instance(target_id)
|
||
if not target_inst:
|
||
return {"success": False, "error": f"agent {target_id} 不存在"}
|
||
# 构造提问消息并插入到目标子对话;同时要求其在下一轮调用 answer_other_agent
|
||
from modules.multi_agent.state import build_sub_agent_ask_other_text
|
||
target_display = target_inst.display_name
|
||
msg = build_sub_agent_ask_other_text(self.display_name, target_display, question, question_id)
|
||
self.manager.inject_message_to_sub_agent(target_id, msg)
|
||
# 阻塞等待回答
|
||
answer = await state.wait_for_answer(question_id, self.agent_id, timeout=float(args.get("timeout_seconds") or 600))
|
||
return {"success": True, "answer": answer, "question_id": question_id}
|
||
|
||
if name == "answer_other_agent":
|
||
source_id = int(args.get("source_agent_id") or 0)
|
||
question_id = str(args.get("question_id") or "")
|
||
answer = str(args.get("answer") or "").strip()
|
||
if not question_id or not answer:
|
||
return {"success": False, "error": "参数缺失"}
|
||
ok = state.provide_answer(question_id, answer)
|
||
return {"success": bool(ok), "question_id": question_id}
|
||
|
||
if name == "list_active_sub_agents":
|
||
return {"success": True, "agents": [a.to_dict() for a in state.list_all()]}
|
||
except asyncio.TimeoutError:
|
||
return {"success": False, "error": "等待回答超时", "question_id": args.get("question_id")}
|
||
except Exception as exc:
|
||
logger.exception(f"[SubAgent] 多智能体工具异常: {name}")
|
||
return {"success": False, "error": f"多智能体工具异常: {exc}"}
|
||
return None
|
||
|
||
def _update_stats(self, name: str) -> None:
|
||
if name == "read_file":
|
||
self.stats["files_read"] += 1
|
||
elif name == "write_file":
|
||
self.stats["write_files"] += 1
|
||
elif name == "edit_file":
|
||
self.stats["edit_files"] += 1
|
||
elif name == "search_workspace":
|
||
self.stats["searches"] += 1
|
||
elif name in ("web_search", "extract_webpage"):
|
||
self.stats["web_pages"] += 1
|
||
elif name == "run_command":
|
||
self.stats["commands"] += 1
|
||
|
||
def _apply_usage(self, usage: Any) -> None:
|
||
try:
|
||
if isinstance(usage, dict):
|
||
prompt = usage.get("prompt_tokens") or usage.get("prompt") or 0
|
||
completion = usage.get("completion_tokens") or usage.get("completion") or 0
|
||
total = usage.get("total_tokens") or usage.get("total") or (prompt + completion)
|
||
self.stats["token_usage"]["prompt"] += int(prompt)
|
||
self.stats["token_usage"]["completion"] += int(completion)
|
||
self.stats["token_usage"]["total"] += int(total)
|
||
except Exception:
|
||
pass
|
||
|
||
def _build_media_tool_content(self, result: Dict[str, Any]) -> Any:
|
||
"""把 read_mediafile 结果转成 OpenAI 多模态 content。"""
|
||
b64 = result.get("b64")
|
||
mime = result.get("mime")
|
||
file_type = result.get("type")
|
||
if not b64 or not mime:
|
||
return None
|
||
if file_type == "image":
|
||
return [
|
||
{"type": "text", "text": f"已附加图片: {result.get('path')}"},
|
||
{"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}},
|
||
]
|
||
if file_type == "video":
|
||
return [
|
||
{"type": "text", "text": f"已附加视频: {result.get('path')}"},
|
||
{"type": "video_url", "video_url": {"url": f"data:{mime};base64,{b64}"}},
|
||
]
|
||
return None
|
||
|
||
async def _write_finish(self, args: Dict[str, Any], elapsed: float) -> None:
|
||
success = bool(args.get("success", False))
|
||
summary = str(args.get("summary") or "").strip()
|
||
self._finalize_task(success, summary, elapsed)
|
||
|
||
async def _write_timeout(self, elapsed: float) -> None:
|
||
self._finalize_task(False, "任务超时未完成", elapsed, timeout=True)
|
||
|
||
async def _write_failure(self, message: str, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
|
||
elapsed = time.time() - (self.stats["runtime_start"] / 1000)
|
||
self._finalize_task(False, message, elapsed, max_turns_exceeded=max_turns_exceeded, timeout=timeout)
|
||
|
||
def _finalize_task(self, success: bool, summary: str, elapsed: float, *, max_turns_exceeded: bool = False, timeout: bool = False) -> None:
|
||
runtime_seconds = int(elapsed)
|
||
output_data = {
|
||
"success": success,
|
||
"summary": summary,
|
||
"timeout": timeout,
|
||
"max_turns_exceeded": max_turns_exceeded,
|
||
"stats": {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)},
|
||
}
|
||
conversation_data = {
|
||
"agent_id": self.agent_id,
|
||
"created_at": datetime.fromtimestamp(self.stats["runtime_start"] / 1000).isoformat(),
|
||
"completed_at": datetime.now().isoformat(),
|
||
"success": success,
|
||
"summary": summary,
|
||
"messages": self.messages,
|
||
"stats": output_data["stats"],
|
||
}
|
||
stats_data = {**self.stats, "runtime_seconds": runtime_seconds, "turn_count": self.stats.get("turn_count", 0)}
|
||
|
||
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||
self.output_file.write_text(json.dumps(output_data, ensure_ascii=False), encoding="utf-8")
|
||
self.stats_file.write_text(json.dumps(stats_data, ensure_ascii=False), encoding="utf-8")
|
||
self.conversation_file.write_text(json.dumps(conversation_data, ensure_ascii=False), encoding="utf-8")
|
||
|
||
self.emit("output", output_data)
|
||
self.emit("conversation", conversation_data)
|
||
|
||
self.manager._mark_task_done(self.task_id, success, summary, runtime_seconds)
|
||
|
||
def cancel(self) -> None:
|
||
self._cancelled = True
|
||
if self._task and not self._task.done():
|
||
self._task.cancel()
|