fix: 修复子代理环境隔离、统计时间戳问题,提取子代理配置

This commit is contained in:
ViperEkura 2026-03-28 13:27:09 +08:00
parent 57e998f896
commit 24e8497230
7 changed files with 92 additions and 21 deletions

View File

@ -36,6 +36,13 @@ frontend_port: 4000
# Max agentic loop iterations (tool call rounds)
max_iterations: 15
# Sub-agent settings (multi_agent tool)
sub_agent:
max_iterations: 3 # Max tool-call rounds per sub-agent
max_tokens: 4096 # Max tokens per LLM call inside a sub-agent
max_agents: 5 # Max number of concurrent sub-agents per request
max_concurrency: 3 # ThreadPoolExecutor max workers
# Available models
# Each model must have its own id, name, api_url, api_key
models:
@ -117,6 +124,7 @@ backend/
│ ├── data.py # 计算器、文本、JSON 处理
│ ├── weather.py # 天气查询(模拟)
│ ├── file_ops.py # 文件操作6 个工具project_id 自动注入)
│ ├── agent.py # 多智能体(子 Agent 并发执行,工具权限隔离)
│ └── code.py # Python 代码执行(沙箱)
└── utils/ # 辅助函数
├── helpers.py # 通用函数ok/err/build_messages 等)
@ -207,6 +215,7 @@ frontend/
| **代码执行** | execute_python | 沙箱环境执行 Python |
| **文件操作** | file_read, file_write, file_delete, file_list, file_exists, file_mkdir | project_id 自动注入 |
| **天气** | get_weather | 天气查询(模拟) |
| **智能体** | multi_agent | 派生子 Agent 并发执行(禁止递归,工具权限与主 Agent 一致) |
## 文档

View File

@ -39,3 +39,10 @@ TOOL_MAX_WORKERS = _cfg.get("tool_max_workers", 4)
# Max character length for a single tool result content (truncated if exceeded)
TOOL_RESULT_MAX_LENGTH = _cfg.get("tool_result_max_length", 4096)
# Sub-agent settings (multi_agent tool)
_sa = _cfg.get("sub_agent", {})
SUB_AGENT_MAX_ITERATIONS = _sa.get("max_iterations", 3)
SUB_AGENT_MAX_TOKENS = _sa.get("max_tokens", 4096)
SUB_AGENT_MAX_AGENTS = _sa.get("max_agents", 5)
SUB_AGENT_MAX_CONCURRENCY = _sa.get("max_concurrency", 3)

View File

@ -4,12 +4,25 @@ Provides:
- multi_agent: Spawn sub-agents with independent LLM conversation loops
"""
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Dict, Any, Optional
from backend.tools.factory import tool
from backend.tools.core import registry
from backend.tools.executor import ToolExecutor
from backend.config import (
DEFAULT_MODEL,
SUB_AGENT_MAX_ITERATIONS,
SUB_AGENT_MAX_TOKENS,
SUB_AGENT_MAX_AGENTS,
SUB_AGENT_MAX_CONCURRENCY,
)
logger = logging.getLogger(__name__)
# Sub-agents are forbidden from using multi_agent to prevent infinite recursion
BLOCKED_TOOLS = {"multi_agent"}
def _to_executor_calls(tool_calls: list, id_prefix: str = "tc") -> list:
@ -68,13 +81,16 @@ def _run_sub_agent(
"error": "LLM client not available",
}
# Build tool list filter to requested tools or use all
# Build tool list filter to requested tools, then remove blocked
all_tools = registry.list_all()
if tool_names:
allowed = set(tool_names)
tools = [t for t in all_tools if t["function"]["name"] in allowed]
else:
tools = all_tools
tools = list(all_tools)
# Remove blocked tools to prevent recursion
tools = [t for t in tools if t["function"]["name"] not in BLOCKED_TOOLS]
executor = ToolExecutor(registry=registry)
context = {"model": model}
@ -128,7 +144,17 @@ def _run_sub_agent(
})
tc_list = message["tool_calls"]
executor_calls = _to_executor_calls(tc_list)
tool_results = executor.process_tool_calls(executor_calls, context)
# Execute tools inside app_context file ops and other DB-
# dependent tools require an active Flask context and session.
with app.app_context():
if len(executor_calls) > 1:
tool_results = executor.process_tool_calls_parallel(
executor_calls, context
)
else:
tool_results = executor.process_tool_calls(
executor_calls, context
)
messages.extend(tool_results)
else:
# Final text response
@ -159,7 +185,7 @@ def _run_sub_agent(
"Spawn multiple sub-agents to work on tasks concurrently. "
"Each agent runs its own independent conversation with the LLM and can use tools. "
"Useful for parallel research, multi-file analysis, or dividing complex tasks into sub-tasks. "
"Each agent is limited to 3 iterations and 4096 tokens to control cost."
"Resource limits (iterations, tokens, concurrency) are configured in config.yml -> sub_agent."
),
parameters={
"type": "object",
@ -221,19 +247,18 @@ def multi_agent(arguments: dict) -> dict:
tasks = arguments["tasks"]
if len(tasks) > 5:
return {"success": False, "error": "Maximum 5 concurrent agents allowed"}
if len(tasks) > SUB_AGENT_MAX_AGENTS:
return {"success": False, "error": f"Maximum {SUB_AGENT_MAX_AGENTS} concurrent agents allowed"}
# Get current conversation context for model/project info
app = current_app._get_current_object()
# Use injected model/project_id from executor context, fall back to defaults
from backend.config import DEFAULT_MODEL
model = arguments.get("_model") or DEFAULT_MODEL
project_id = arguments.get("_project_id")
# Execute agents concurrently (max 3 at a time)
concurrency = min(len(tasks), 3)
# Execute agents concurrently
concurrency = min(len(tasks), SUB_AGENT_MAX_CONCURRENCY)
results = [None] * len(tasks)
with ThreadPoolExecutor(max_workers=concurrency) as pool:
@ -244,9 +269,10 @@ def multi_agent(arguments: dict) -> dict:
task["instruction"],
task.get("tools"),
model,
4096,
SUB_AGENT_MAX_TOKENS,
project_id,
app,
SUB_AGENT_MAX_ITERATIONS,
): i
for i, task in enumerate(tasks)
}

View File

@ -1,6 +1,6 @@
"""Common helper functions"""
import json
from datetime import date, datetime
from datetime import date, datetime, timezone
from typing import Any
from flask import jsonify
from backend import db
@ -97,7 +97,7 @@ def message_to_dict(msg: Message) -> dict:
def record_token_usage(user_id, model, prompt_tokens, completion_tokens):
"""Record token usage"""
today = date.today()
today = datetime.now(timezone.utc).date()
usage = TokenUsage.query.filter_by(
user_id=user_id, date=today, model=model
).first()

View File

@ -66,6 +66,7 @@ backend/
│ ├── data.py # 计算器、文本、JSON
│ ├── weather.py # 天气查询
│ ├── file_ops.py # 文件操作project_id 自动注入)
│ ├── agent.py # 多智能体(子 Agent 并发执行,工具权限隔离)
│ └── code.py # 代码执行
├── utils/ # 辅助函数
@ -1020,6 +1021,13 @@ frontend_port: 4000
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5
max_iterations: 15
# 子代理资源配置multi_agent 工具)
sub_agent:
max_iterations: 3 # 每个子代理的最大工具调用轮数
max_tokens: 4096 # 每次调用的最大 token 数
max_agents: 5 # 每次请求最多派生的子代理数
max_concurrency: 3 # 并发线程数
# 可用模型列表(每个模型必须指定 api_url 和 api_key
# 支持任何 OpenAI 兼容 APIDeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
models:

View File

@ -249,14 +249,25 @@ file_read({"path": "src/main.py", "project_id": "xxx"})
| 工具名称 | 描述 | 参数 |
|---------|------|------|
| `multi_agent` | 派生子 Agent 并发执行任务(最多 5 个) | `tasks`: 任务数组name, instruction, tools<br>`_model`: 模型名称(自动注入)<br>`_project_id`: 项目 ID自动注入 |
| `multi_agent` | 派生子 Agent 并发执行任务 | `tasks`: 任务数组name, instruction, tools<br>`_model`: 模型名称(自动注入)<br>`_project_id`: 项目 ID自动注入 |
**`multi_agent` 工作原理:**
1. 接收任务数组,每个任务指定 name、instruction 和可选的 tools 列表
2. 为每个子 Agent 创建独立线程,各自拥有 LLM 对话循环(最多 3 轮迭代4096 tokens
3. 通过 Service Locator 获取 `llm_client` 实例
4. 子 Agent 在 `app.app_context()` 中运行,可独立调用所有注册工具
5. 返回 `{success, results: [{task_name, success, response/error}], total}`
2. 子 Agent **禁止使用 `multi_agent` 工具**`BLOCKED_TOOLS`),防止无限递归
3. 子 Agent 工具权限与主 Agent 一致(除 multi_agent 外的所有已注册工具),支持并行工具执行
4. 为每个子 Agent 创建独立线程,各自拥有 LLM 对话循环
5. 子 Agent 在 `app.app_context()` 中运行 LLM 调用和工具执行,确保数据库等依赖正常工作
6. 通过 Service Locator 获取 `llm_client` 实例
7. 返回 `{success, results: [{task_name, success, response/error}], total}`
**资源配置**`config.yml` → `sub_agent`
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `max_iterations` | 3 | 每个子代理的最大工具调用轮数 |
| `max_tokens` | 4096 | 每次调用的最大 token 数 |
| `max_agents` | 5 | 每次请求最多派生的子代理数 |
| `max_concurrency` | 3 | ThreadPoolExecutor 并发线程数 |
---

View File

@ -132,8 +132,15 @@ const sortedDaily = computed(() => {
const chartData = computed(() => {
if (period.value === 'daily' && stats.value?.hourly) {
const hourly = stats.value.hourly
// Backend returns UTC hours convert to local timezone for display.
const offset = -new Date().getTimezoneOffset() / 60 // e.g. +8 for UTC+8
const localHourly = {}
for (const [utcH, val] of Object.entries(hourly)) {
const localH = ((parseInt(utcH) + offset) % 24 + 24) % 24
localHourly[localH] = val
}
let minH = 24, maxH = -1
for (const h of Object.keys(hourly)) {
for (const h of Object.keys(localHourly)) {
const hour = parseInt(h)
if (hour < minH) minH = hour
if (hour > maxH) maxH = hour
@ -145,16 +152,19 @@ const chartData = computed(() => {
const h = start + i
return {
label: `${h}:00`,
value: hourly[String(h)]?.total || 0,
value: localHourly[String(h)]?.total || 0,
}
})
}
const data = sortedDaily.value
return Object.entries(data).map(([date, val]) => {
const d = new Date(date)
// date is "YYYY-MM-DD" from backend parse directly to avoid
// new Date() timezone shift (parsed as UTC midnight then
// getMonth/getDate applies local offset, potentially off by one day).
const [year, month, day] = date.split('-')
return {
label: `${d.getMonth() + 1}/${d.getDate()}`,
label: `${parseInt(month)}/${parseInt(day)}`,
value: val.total,
prompt: val.prompt || 0,
completion: val.completion || 0,