312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""本地委派执行器。
|
||
|
||
这个类不再负责“后台任务管理”和“结果回流”,只保留一件事:
|
||
在统一委派层要求执行本地任务时,提供一个受限工具集的本地 agent 执行环境。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import time as _time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||
|
||
from loguru import logger
|
||
|
||
from nanobot.agent.run_result import AgentRunResult, has_meaningful_summary
|
||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||
from nanobot.agent.tools.registry import ToolRegistry
|
||
from nanobot.agent.tools.shell import ExecTool
|
||
from nanobot.agent.tools.spawn import NestedDelegateTool
|
||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||
from nanobot.providers.base import LLMProvider
|
||
|
||
if TYPE_CHECKING:
|
||
from nanobot.agent.delegation import DelegationManager
|
||
from nanobot.config.schema import ExecToolConfig
|
||
|
||
|
||
class SubagentManager:
|
||
"""用受限工具集在本地执行委派任务。"""
|
||
|
||
def __init__(
|
||
self,
|
||
provider: LLMProvider,
|
||
workspace: Path,
|
||
model: str | None = None,
|
||
temperature: float = 0.7,
|
||
max_tokens: int = 4096,
|
||
brave_api_key: str | None = None,
|
||
exec_config: ExecToolConfig | None = None,
|
||
restrict_to_workspace: bool = False,
|
||
):
|
||
from nanobot.config.schema import ExecToolConfig
|
||
|
||
# 这里保存的都是本地执行所需的静态配置,不再维护后台任务表。
|
||
self.provider = provider
|
||
self.workspace = workspace
|
||
self.model = model or provider.get_default_model()
|
||
self.temperature = temperature
|
||
self.max_tokens = max_tokens
|
||
self.brave_api_key = brave_api_key
|
||
self.exec_config = exec_config or ExecToolConfig()
|
||
self.restrict_to_workspace = restrict_to_workspace
|
||
self._nested_delegate: DelegationManager | None = None
|
||
|
||
def set_nested_delegate(self, manager: "DelegationManager | None") -> None:
|
||
"""注入 delegated worker 可用的受控下游委派器。"""
|
||
self._nested_delegate = manager
|
||
|
||
async def run_local_task(
|
||
self,
|
||
task: str,
|
||
label: str | None = None,
|
||
agent_id: str = "local-subagent",
|
||
agent_name: str = "Local Subagent",
|
||
system_prompt: str | None = None,
|
||
model: str | None = None,
|
||
progress_callback: Callable[..., Awaitable[None]] | None = None,
|
||
allow_nested_delegation: bool = True,
|
||
skill_context: str = "",
|
||
skill_names: list[str] | None = None,
|
||
) -> AgentRunResult:
|
||
"""执行一次本地委派任务,并返回结构化结果。"""
|
||
# 每次任务都新建一套局部工具注册表,避免不同任务之间共享临时状态。
|
||
tools = self._build_local_tools(
|
||
allow_nested_delegation=allow_nested_delegation,
|
||
skill_names=skill_names,
|
||
)
|
||
prompt = self._build_subagent_prompt(
|
||
task,
|
||
agent_name=agent_name,
|
||
custom_system_prompt=system_prompt,
|
||
allow_nested_delegation=allow_nested_delegation,
|
||
skill_context=skill_context,
|
||
)
|
||
# 本地委派不共享主会话历史,只带“专用 system prompt + 当前任务”。
|
||
messages: list[dict[str, Any]] = [
|
||
{"role": "system", "content": prompt},
|
||
{"role": "user", "content": task},
|
||
]
|
||
|
||
# 本地子 agent 也走“模型 -> 工具 -> 模型”的短循环,但轮数更保守。
|
||
max_iterations = 15
|
||
iteration = 0
|
||
final_result: str | None = None
|
||
|
||
while iteration < max_iterations:
|
||
iteration += 1
|
||
response = await self.provider.chat(
|
||
messages=messages,
|
||
tools=tools.get_definitions(),
|
||
model=model or self.model,
|
||
temperature=self.temperature,
|
||
max_tokens=self.max_tokens,
|
||
)
|
||
|
||
if response.has_tool_calls:
|
||
if progress_callback:
|
||
# 进度回调只发对用户有价值的文本,不把 `<think>` 之类内部推理暴露出去。
|
||
clean = self._strip_think(response.content)
|
||
if clean:
|
||
await progress_callback(clean, tool_hint=False)
|
||
# 额外补一条短工具提示,让上层 UI 知道当前在做什么。
|
||
await progress_callback(self._tool_hint(response.tool_calls), tool_hint=True)
|
||
|
||
tool_call_dicts = [
|
||
{
|
||
"id": tc.id,
|
||
"type": "function",
|
||
"function": {
|
||
"name": tc.name,
|
||
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
|
||
},
|
||
}
|
||
for tc in response.tool_calls
|
||
]
|
||
messages.append({
|
||
"role": "assistant",
|
||
"content": response.content or "",
|
||
"tool_calls": tool_call_dicts,
|
||
})
|
||
for tool_call in response.tool_calls:
|
||
args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
|
||
logger.debug("Agent [{}] executing: {} with arguments: {}", agent_id, tool_call.name, args_str)
|
||
# 真正执行工具后,把结果回填到 messages,让下一轮模型能看到执行结果。
|
||
result = await tools.execute(tool_call.name, tool_call.arguments)
|
||
messages.append({
|
||
"role": "tool",
|
||
"tool_call_id": tool_call.id,
|
||
"name": tool_call.name,
|
||
"content": result,
|
||
})
|
||
else:
|
||
# 没有继续调用工具时,视为任务已收敛,直接采纳当前回复。
|
||
final_result = response.content
|
||
break
|
||
|
||
status = "ok"
|
||
raw: dict[str, Any] | None = None
|
||
if not has_meaningful_summary(final_result):
|
||
# 兜底避免出现“任务做完了但完全没文本”的空结果,并显式标记为失败,
|
||
# 防止上层把这类占位结果学习成 procedure。
|
||
final_result = "Task completed but no final response was generated."
|
||
status = "error"
|
||
raw = {
|
||
"reason": "no_final_response_generated",
|
||
"iterations": iteration,
|
||
}
|
||
|
||
return AgentRunResult(
|
||
agent_id=agent_id,
|
||
agent_name=agent_name,
|
||
status=status,
|
||
summary=final_result,
|
||
raw=raw,
|
||
)
|
||
|
||
def _build_local_tools(
|
||
self,
|
||
*,
|
||
allow_nested_delegation: bool,
|
||
skill_names: list[str] | None = None,
|
||
) -> ToolRegistry:
|
||
"""构建本地委派可用的受限工具集。"""
|
||
tools = ToolRegistry()
|
||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||
protected_skill_paths = [self.workspace / "skills"]
|
||
# 文件工具统一按相同的 workspace / allowed_dir 约束注册。
|
||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||
tools.register(
|
||
WriteFileTool(
|
||
workspace=self.workspace,
|
||
allowed_dir=allowed_dir,
|
||
protected_paths=protected_skill_paths,
|
||
)
|
||
)
|
||
tools.register(
|
||
EditFileTool(
|
||
workspace=self.workspace,
|
||
allowed_dir=allowed_dir,
|
||
protected_paths=protected_skill_paths,
|
||
)
|
||
)
|
||
# 本地命令执行沿用主配置里的超时和 workspace 限制。
|
||
tools.register(ExecTool(
|
||
working_dir=str(self.workspace),
|
||
timeout=self.exec_config.timeout,
|
||
restrict_to_workspace=self.restrict_to_workspace,
|
||
protected_paths=protected_skill_paths,
|
||
))
|
||
# 网络能力保持只读:搜索和抓取,不提供消息发送/再次委派等工具。
|
||
tools.register(WebSearchTool(api_key=self.brave_api_key))
|
||
tools.register(WebFetchTool())
|
||
if allow_nested_delegation and self._nested_delegate is not None:
|
||
tools.register(NestedDelegateTool(manager=self._nested_delegate, default_skills=skill_names))
|
||
return tools
|
||
|
||
@staticmethod
|
||
def _strip_think(text: str | None) -> str | None:
|
||
"""Remove provider-specific think blocks from visible progress text."""
|
||
if not text:
|
||
return None
|
||
return re.sub(r"<think>[\s\S]*?</think>", "", text).strip() or None
|
||
|
||
@staticmethod
|
||
def _tool_hint(tool_calls: list) -> str:
|
||
"""把工具调用列表格式化成简短进度提示。"""
|
||
|
||
def _fmt(tc):
|
||
val = next(iter(tc.arguments.values()), None) if tc.arguments else None
|
||
if not isinstance(val, str):
|
||
return tc.name
|
||
return f'{tc.name}("{val[:40]}...")' if len(val) > 40 else f'{tc.name}("{val}")'
|
||
|
||
return ", ".join(_fmt(tc) for tc in tool_calls)
|
||
|
||
def _build_subagent_prompt(
|
||
self,
|
||
task: str,
|
||
agent_name: str = "Local Subagent",
|
||
custom_system_prompt: str | None = None,
|
||
allow_nested_delegation: bool = True,
|
||
skill_context: str = "",
|
||
) -> str:
|
||
"""构建子代理专用 system prompt。"""
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||
tz = _time.strftime("%Z") or "UTC"
|
||
# plugin agent 的自定义系统提示拼到末尾,保留通用约束,再叠加个性化指令。
|
||
extra = f"\n\n## Agent Instructions\n{custom_system_prompt.strip()}" if custom_system_prompt else ""
|
||
can_do_lines = [
|
||
"- Read and write files in the workspace",
|
||
"- Execute shell commands",
|
||
"- Search the web and fetch web pages",
|
||
"- Complete the task thoroughly",
|
||
]
|
||
cannot_do_lines = [
|
||
"- Send messages directly to users (no message tool available)",
|
||
"- Access the main agent's conversation history",
|
||
]
|
||
delegation_section = (
|
||
"\n## Downstream Delegation\n"
|
||
"- Do not delegate further. Complete the task yourself with the tools you have."
|
||
)
|
||
if allow_nested_delegation and self._nested_delegate is not None:
|
||
can_do_lines.append(
|
||
"- Use `delegate_task` for controlled downstream delegation when specialized help is required"
|
||
)
|
||
cannot_do_lines.append("- Do not start agent teams or use background delegation tools")
|
||
nested_summary = self._nested_delegate.build_nested_agents_summary()
|
||
summary_block = f"\n\n{nested_summary}" if nested_summary else ""
|
||
delegation_section = (
|
||
"\n## Downstream Delegation\n"
|
||
"- Use `delegate_task` only when a specialized downstream worker is actually needed.\n"
|
||
"- `strategy=\"a2a\"` delegates directly to an available A2A agent.\n"
|
||
"- `strategy=\"ephemeral_subagent\"` runs a temporary local worker for this task only.\n"
|
||
"- Never create, register, or persist a new local sub-agent through `subagentctl.py`, `/api/subagents`, or registry edits."
|
||
f"{summary_block}"
|
||
)
|
||
else:
|
||
cannot_do_lines.append("- Spawn other subagents or downstream workers")
|
||
skill_section = f"\n## Required Skills\n{skill_context.strip()}" if skill_context.strip() else ""
|
||
|
||
return f"""# {agent_name}
|
||
|
||
## Current Time
|
||
{now} ({tz})
|
||
|
||
You are a delegated agent spawned by the main agent to complete a specific task.
|
||
|
||
## Rules
|
||
1. Stay focused - complete only the assigned task, nothing else
|
||
2. Your final response will be reported back to the main agent
|
||
3. Do not initiate conversations or take on side tasks
|
||
4. Be concise but informative in your findings
|
||
5. Do not create or modify persistent local sub-agents unless the task explicitly requires that workflow
|
||
|
||
## What You Can Do
|
||
{chr(10).join(can_do_lines)}
|
||
|
||
## What You Cannot Do
|
||
{chr(10).join(cannot_do_lines)}
|
||
|
||
{delegation_section}
|
||
|
||
{skill_section}
|
||
|
||
## Workspace
|
||
Your workspace is at: {self.workspace}
|
||
Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
|
||
|
||
## Special Workflow
|
||
- If the task is about creating, updating, repairing, or deleting a persistent local sub-agent, read `skills/subagent-manager/SKILL.md` before making changes.
|
||
- For persistent local sub-agents, follow only the canonical workflow from that skill.
|
||
- Do not manually create `workspace/agents/<id>/agent.json` as a substitute for a persistent sub-agent.
|
||
- Do not manually edit `workspace/agents/registry.json` to register a persistent sub-agent.
|
||
- A valid persistent sub-agent must be created through `subagentctl.py` or `/api/subagents` and must end up at `workspace/agents/<id>_agent/AGENTS.json`.
|
||
|
||
When you have completed the task, provide a clear summary of your findings or actions.{extra}"""
|