更新为llm判断工具函数调用
Browse files- README.md +24 -5
- agent.py +154 -125
- app.py +8 -8
- config.py +10 -1
- tools/executor.py +316 -0
- tools/llm_client.py +72 -3
- tools/tool_specs.py +25 -0
README.md
CHANGED
|
@@ -14,11 +14,12 @@ hf_oauth_expiration_minutes: 480
|
|
| 14 |
|
| 15 |
这是 Hugging Face Agents Course Final Assignment 的基础 Space,用于运行并提交 GAIA 风格问题的 Agent 作答结果。
|
| 16 |
|
| 17 |
-
当前版本已经改成工具
|
| 18 |
|
| 19 |
- `app.py` 会通过课程评测接口拉取问题并提交答案。
|
| 20 |
-
- `agent.py` 中的 `GaiaAgent` 会
|
| 21 |
-
- `tools/`
|
|
|
|
| 22 |
- 登录 Hugging Face 后,界面会使用当前 HF 用户名提交答案。
|
| 23 |
- 提交时会携带当前 Space 的代码仓库链接,便于评测系统记录实现来源。
|
| 24 |
|
|
@@ -44,7 +45,9 @@ Space 配置参考:https://huggingface.co/docs/hub/spaces-config-reference
|
|
| 44 |
│ ├── code_runner.py
|
| 45 |
│ ├── spreadsheet_solver.py
|
| 46 |
│ ├── structured_web_tools.py
|
| 47 |
-
│
|
|
|
|
|
|
|
| 48 |
├── data/
|
| 49 |
│ ├── questions.json
|
| 50 |
│ ├── gold_local.jsonl
|
|
@@ -65,7 +68,21 @@ Space 配置参考:https://huggingface.co/docs/hub/spaces-config-reference
|
|
| 65 |
6. 维护 gold 文件:`gold_local.jsonl` 每行包含 `task_id`、`expected_answer`、`match_type` 和备注。没有标准答案的问题只做流程测试,不纳入正确率统计。
|
| 66 |
7. 分层运行:先跑 1 到 3 道 smoke test,再跑全部本地题;确认稳定后再通过 Space 按官方流程提交。
|
| 67 |
|
| 68 |
-
当前代码采用
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
当前优先覆盖的题型:
|
| 71 |
|
|
@@ -85,6 +102,8 @@ Space 配置参考:https://huggingface.co/docs/hub/spaces-config-reference
|
|
| 85 |
python3 gaia_local_eval.py
|
| 86 |
```
|
| 87 |
|
|
|
|
|
|
|
| 88 |
如果本地没有安装依赖,先执行:
|
| 89 |
|
| 90 |
```bash
|
|
|
|
| 14 |
|
| 15 |
这是 Hugging Face Agents Course Final Assignment 的基础 Space,用于运行并提交 GAIA 风格问题的 Agent 作答结果。
|
| 16 |
|
| 17 |
+
当前版本已经改成 LLM 工具调用 Agent:
|
| 18 |
|
| 19 |
- `app.py` 会通过课程评测接口拉取问题并提交答案。
|
| 20 |
+
- `agent.py` 中的 `GaiaAgent` 会让 LLM planner 输出 JSON action,决定下一步调用哪个工具。
|
| 21 |
+
- `tools/executor.py` 和 `tools/tool_specs.py` 负责注册、描述并执行工具。
|
| 22 |
+
- `tools/` 目录包含规则题、附件下载、Python 执行、Excel 计算、结构化网页、网页检索和体育数据工具。
|
| 23 |
- 登录 Hugging Face 后,界面会使用当前 HF 用户名提交答案。
|
| 24 |
- 提交时会携带当前 Space 的代码仓库链接,便于评测系统记录实现来源。
|
| 25 |
|
|
|
|
| 45 |
│ ├── code_runner.py
|
| 46 |
│ ├── spreadsheet_solver.py
|
| 47 |
│ ├── structured_web_tools.py
|
| 48 |
+
│ ├── sports_solver.py
|
| 49 |
+
│ ├── tool_specs.py
|
| 50 |
+
│ └── executor.py
|
| 51 |
├── data/
|
| 52 |
│ ├── questions.json
|
| 53 |
│ ├── gold_local.jsonl
|
|
|
|
| 68 |
6. 维护 gold 文件:`gold_local.jsonl` 每行包含 `task_id`、`expected_answer`、`match_type` 和备注。没有标准答案的问题只做流程测试,不纳入正确率统计。
|
| 69 |
7. 分层运行:先跑 1 到 3 道 smoke test,再跑全部本地题;确认稳定后再通过 Space 按官方流程提交。
|
| 70 |
|
| 71 |
+
当前代码采用 LLM JSON action loop:LLM 每一步只能输出一个 JSON 对象,要么调用工具,要么返回最终答案。Python 不再按固定顺序手动选择 solver,而是只负责执行 LLM 指定的工具。
|
| 72 |
+
|
| 73 |
+
工具调用格式:
|
| 74 |
+
|
| 75 |
+
```json
|
| 76 |
+
{"thought":"需要先查 Wikipedia 表格","action":"wikipedia_tool","args":{"query":"1928 Summer Olympics athletes"}}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
最终答案格式:
|
| 80 |
+
|
| 81 |
+
```json
|
| 82 |
+
{"thought":"工具已给出高置信答案","action":"final_answer","answer":"CUB","confidence":"high"}
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
当前仍采用 40% 优先策略:为了先冲过最低分,不处理音频和视频题,只集中处理文本、网页、Python、Excel、体育统计和少量确定性规则题。
|
| 86 |
|
| 87 |
当前优先覆盖的题型:
|
| 88 |
|
|
|
|
| 102 |
python3 gaia_local_eval.py
|
| 103 |
```
|
| 104 |
|
| 105 |
+
注意:现在本地回归会调用 LLM planner,因此需要 `HF_TOKEN`,并会消耗少量 Hugging Face Inference Providers 额度。这个 token 必须具备 `Make calls to Inference Providers` 权限,否则第一步 planner 请求会返回 403。`MAX_AGENT_STEPS` 可用于限制每题最多工具调用步数。
|
| 106 |
+
|
| 107 |
如果本地没有安装依赖,先执行:
|
| 108 |
|
| 109 |
```bash
|
agent.py
CHANGED
|
@@ -1,94 +1,142 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
from
|
| 5 |
-
from tools.
|
| 6 |
-
from tools.
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class GaiaAgent:
|
| 25 |
-
"""
|
| 26 |
|
| 27 |
def __init__(self):
|
| 28 |
-
print("GAIA 工具
|
| 29 |
print(f"文本模型:{HF_TEXT_MODEL}")
|
| 30 |
print(f"视觉模型:{HF_VISION_MODEL or '未启用'}")
|
|
|
|
| 31 |
|
| 32 |
def answer_task(self, question: str, task_id: str = "", file_name: str = "") -> SolverResult:
|
| 33 |
print(f"Agent 收到问题(前 100 个字符):{question[:100]}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
# 2. 附件类确定性题。
|
| 45 |
-
result = solve_python_output(question, task_id, file_name)
|
| 46 |
-
if result.solved:
|
| 47 |
-
return result
|
| 48 |
-
|
| 49 |
-
result = solve_excel_food_sales(question, task_id, file_name)
|
| 50 |
-
if result.solved:
|
| 51 |
-
return result
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
)
|
| 70 |
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
try:
|
| 74 |
-
answer = answer_with_light_model(question, evidence)
|
| 75 |
-
if answer:
|
| 76 |
-
return SolverResult(
|
| 77 |
-
normalize_answer(answer),
|
| 78 |
-
source="llm_fallback",
|
| 79 |
-
confidence="medium",
|
| 80 |
-
evidence=evidence,
|
| 81 |
-
)
|
| 82 |
-
except Exception as exc:
|
| 83 |
return SolverResult(
|
| 84 |
-
"
|
| 85 |
-
source="
|
| 86 |
-
confidence="
|
| 87 |
-
evidence=
|
| 88 |
-
error=
|
| 89 |
)
|
| 90 |
|
| 91 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
def __call__(self, question: str, task_id: str = "", file_name: str = "") -> str:
|
| 94 |
result = self.answer_task(question, task_id=task_id, file_name=file_name)
|
|
@@ -98,59 +146,40 @@ class GaiaAgent:
|
|
| 98 |
)
|
| 99 |
return normalize_answer(result.answer or "无法确定")
|
| 100 |
|
| 101 |
-
def
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
)
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
for url in urls:
|
| 136 |
-
if not is_youtube_url(url):
|
| 137 |
-
evidence_parts.append(f"网页 {url} 内容:\n{fetch_url_text(url)}")
|
| 138 |
-
|
| 139 |
-
if not urls and not self._is_self_contained(question):
|
| 140 |
-
evidence_parts.append(f"搜索证据:\n{collect_web_evidence(question)}")
|
| 141 |
-
|
| 142 |
-
return "\n\n".join(evidence_parts) or "没有额外工具证据。"
|
| 143 |
-
|
| 144 |
-
def _is_self_contained(self, question: str) -> bool:
|
| 145 |
-
lower_question = question.lower()
|
| 146 |
-
if "|---|" in question or "given this table" in lower_question:
|
| 147 |
-
return True
|
| 148 |
-
if "grocery list" in lower_question or "here's the list" in lower_question:
|
| 149 |
-
return True
|
| 150 |
-
reversed_question = question[::-1].lower()
|
| 151 |
-
if "if you understand this sentence" in reversed_question:
|
| 152 |
-
return True
|
| 153 |
-
return False
|
| 154 |
|
| 155 |
|
| 156 |
# 兼容原模板里的 BasicAgent 名称。
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from config import HF_TEXT_MODEL, HF_VISION_MODEL, MAX_AGENT_STEPS, MAX_TOOL_OUTPUT_CHARS
|
| 5 |
+
from tools.common import normalize_answer, truncate_text
|
| 6 |
+
from tools.executor import execute_tool, tool_prompt
|
| 7 |
+
from tools.llm_client import AgentAction, plan_next_action
|
| 8 |
+
from tools.types import SolverResult
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SYSTEM_PROMPT = """
|
| 12 |
+
You are a GAIA tool-using agent. You must decide which tool to call based on the question, file name,
|
| 13 |
+
and prior tool observations.
|
| 14 |
+
|
| 15 |
+
Rules:
|
| 16 |
+
1. You control tool selection. Do not ask the user for clarification.
|
| 17 |
+
2. Use low-cost deterministic tools before generic web search when they fit.
|
| 18 |
+
3. Do not guess. If enabled tools cannot solve a task, return final_answer "无法确定".
|
| 19 |
+
4. Audio, video, and image/chess tools are currently disabled. If a task requires them, return "无法确定".
|
| 20 |
+
5. Every response must be one valid JSON object and nothing else.
|
| 21 |
+
6. To call a tool, output:
|
| 22 |
+
{"thought":"...","action":"tool_name","args":{...}}
|
| 23 |
+
7. To finish, output:
|
| 24 |
+
{"thought":"...","action":"final_answer","answer":"...","confidence":"high|medium|low"}
|
| 25 |
+
8. Final answers must obey the requested format exactly. Do not include explanations.
|
| 26 |
+
""".strip()
|
| 27 |
|
| 28 |
|
| 29 |
class GaiaAgent:
|
| 30 |
+
"""LLM 主导的 JSON action-loop Agent。"""
|
| 31 |
|
| 32 |
def __init__(self):
|
| 33 |
+
print("GAIA LLM 工具调用 Agent 已初始化。")
|
| 34 |
print(f"文本模型:{HF_TEXT_MODEL}")
|
| 35 |
print(f"视觉模型:{HF_VISION_MODEL or '未启用'}")
|
| 36 |
+
print(f"最大 Agent 步数:{MAX_AGENT_STEPS}")
|
| 37 |
|
| 38 |
def answer_task(self, question: str, task_id: str = "", file_name: str = "") -> SolverResult:
|
| 39 |
print(f"Agent 收到问题(前 100 个字符):{question[:100]}...")
|
| 40 |
+
context = {
|
| 41 |
+
"question": question,
|
| 42 |
+
"task_id": task_id,
|
| 43 |
+
"file_name": file_name,
|
| 44 |
+
}
|
| 45 |
+
messages = self._initial_messages(context)
|
| 46 |
+
trace: list[dict[str, Any]] = []
|
| 47 |
+
best_tool_answer: dict[str, Any] | None = None
|
| 48 |
+
|
| 49 |
+
for step_index in range(1, MAX_AGENT_STEPS + 1):
|
| 50 |
+
try:
|
| 51 |
+
action = plan_next_action(messages)
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
trace.append(
|
| 54 |
+
{
|
| 55 |
+
"step": step_index,
|
| 56 |
+
"event": "planner_error",
|
| 57 |
+
"error": str(exc),
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
return SolverResult(
|
| 61 |
+
"无法确定",
|
| 62 |
+
source="llm_agent.planner_error",
|
| 63 |
+
confidence="low",
|
| 64 |
+
evidence=self._trace_text(trace),
|
| 65 |
+
error=str(exc),
|
| 66 |
+
)
|
| 67 |
|
| 68 |
+
trace.append(
|
| 69 |
+
{
|
| 70 |
+
"step": step_index,
|
| 71 |
+
"event": "planner_action",
|
| 72 |
+
"action": action.action,
|
| 73 |
+
"args": action.args,
|
| 74 |
+
"thought": action.thought,
|
| 75 |
+
}
|
| 76 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
+
if action.is_final:
|
| 79 |
+
final_answer = self._extract_final_answer(action)
|
| 80 |
+
return SolverResult(
|
| 81 |
+
final_answer or "无法确定",
|
| 82 |
+
source="llm_agent.final",
|
| 83 |
+
confidence=action.confidence,
|
| 84 |
+
evidence=self._trace_text(trace),
|
| 85 |
+
)
|
| 86 |
|
| 87 |
+
observation = execute_tool(action.action, action.args, context)
|
| 88 |
+
if observation.get("answer"):
|
| 89 |
+
best_tool_answer = observation
|
| 90 |
+
compact_observation = self._compact_observation(observation)
|
| 91 |
+
trace.append(
|
| 92 |
+
{
|
| 93 |
+
"step": step_index,
|
| 94 |
+
"event": "tool_observation",
|
| 95 |
+
"observation": compact_observation,
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
|
| 99 |
+
messages.append(
|
| 100 |
+
{
|
| 101 |
+
"role": "assistant",
|
| 102 |
+
"content": json.dumps(
|
| 103 |
+
{
|
| 104 |
+
"thought": action.thought,
|
| 105 |
+
"action": action.action,
|
| 106 |
+
"args": action.args,
|
| 107 |
+
},
|
| 108 |
+
ensure_ascii=False,
|
| 109 |
+
),
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
messages.append(
|
| 113 |
+
{
|
| 114 |
+
"role": "user",
|
| 115 |
+
"content": (
|
| 116 |
+
"TOOL_OBSERVATION:\n"
|
| 117 |
+
+ json.dumps(compact_observation, ensure_ascii=False)
|
| 118 |
+
+ "\n\nDecide the next tool call or return final_answer as JSON."
|
| 119 |
+
),
|
| 120 |
+
}
|
| 121 |
)
|
| 122 |
|
| 123 |
+
if best_tool_answer and best_tool_answer.get("answer"):
|
| 124 |
+
# 达到步数上限时,使用模型已经选择过的工具返回的候选答案兜底。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
return SolverResult(
|
| 126 |
+
normalize_answer(str(best_tool_answer["answer"])),
|
| 127 |
+
source=f"llm_agent.step_limit_best_tool:{best_tool_answer.get('source')}",
|
| 128 |
+
confidence=str(best_tool_answer.get("confidence", "medium")),
|
| 129 |
+
evidence=self._trace_text(trace),
|
| 130 |
+
error="达到 MAX_AGENT_STEPS,使用最佳工具候选答案。",
|
| 131 |
)
|
| 132 |
|
| 133 |
+
return SolverResult(
|
| 134 |
+
"无法确定",
|
| 135 |
+
source="llm_agent.step_limit",
|
| 136 |
+
confidence="low",
|
| 137 |
+
evidence=self._trace_text(trace),
|
| 138 |
+
error="达到 MAX_AGENT_STEPS,且没有工具候选答案。",
|
| 139 |
+
)
|
| 140 |
|
| 141 |
def __call__(self, question: str, task_id: str = "", file_name: str = "") -> str:
|
| 142 |
result = self.answer_task(question, task_id=task_id, file_name=file_name)
|
|
|
|
| 146 |
)
|
| 147 |
return normalize_answer(result.answer or "无法确定")
|
| 148 |
|
| 149 |
+
def _initial_messages(self, context: dict[str, str]) -> list[dict[str, str]]:
|
| 150 |
+
task_payload = {
|
| 151 |
+
"task_id": context.get("task_id", ""),
|
| 152 |
+
"file_name": context.get("file_name", ""),
|
| 153 |
+
"question": context["question"],
|
| 154 |
+
}
|
| 155 |
+
user_prompt = (
|
| 156 |
+
"Available tools:\n"
|
| 157 |
+
+ tool_prompt()
|
| 158 |
+
+ "\n\nTask:\n"
|
| 159 |
+
+ json.dumps(task_payload, ensure_ascii=False)
|
| 160 |
+
+ "\n\nChoose the first tool call or return final_answer. Output JSON only."
|
| 161 |
+
)
|
| 162 |
+
return [
|
| 163 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 164 |
+
{"role": "user", "content": user_prompt},
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
def _extract_final_answer(self, action: AgentAction) -> str:
|
| 168 |
+
answer = action.answer
|
| 169 |
+
if answer is None:
|
| 170 |
+
answer = action.args.get("answer")
|
| 171 |
+
if answer is None:
|
| 172 |
+
return "无法确定"
|
| 173 |
+
return normalize_answer(str(answer))
|
| 174 |
+
|
| 175 |
+
def _compact_observation(self, observation: dict[str, Any]) -> dict[str, Any]:
|
| 176 |
+
compact = dict(observation)
|
| 177 |
+
if compact.get("evidence"):
|
| 178 |
+
compact["evidence"] = truncate_text(str(compact["evidence"]), MAX_TOOL_OUTPUT_CHARS)
|
| 179 |
+
return compact
|
| 180 |
+
|
| 181 |
+
def _trace_text(self, trace: list[dict[str, Any]]) -> str:
|
| 182 |
+
return truncate_text(json.dumps(trace, ensure_ascii=False, indent=2), MAX_TOOL_OUTPUT_CHARS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
# 兼容原模板里的 BasicAgent 名称。
|
app.py
CHANGED
|
@@ -150,19 +150,19 @@ def run_and_submit_all(profile: gr.OAuthProfile | None):
|
|
| 150 |
|
| 151 |
|
| 152 |
with gr.Blocks() as demo:
|
| 153 |
-
gr.Markdown("# 工具
|
| 154 |
gr.Markdown(
|
| 155 |
f"""
|
| 156 |
**当前策略:**
|
| 157 |
|
| 158 |
-
1.
|
| 159 |
-
2. 规则
|
| 160 |
-
3.
|
| 161 |
-
4.
|
| 162 |
-
5.
|
| 163 |
|
| 164 |
**提交前检查:**
|
| 165 |
-
Space Secrets 至少需要 `HF_TOKEN`,
|
| 166 |
"""
|
| 167 |
)
|
| 168 |
|
|
@@ -192,5 +192,5 @@ if __name__ == "__main__":
|
|
| 192 |
print("未找到 SPACE_ID 环境变量(可能是在本地运行)。无法确定仓库地址。")
|
| 193 |
|
| 194 |
print("-" * (60 + len(" 应用启动中 ")) + "\n")
|
| 195 |
-
print("正在启动工具
|
| 196 |
demo.launch(debug=True, share=False)
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
with gr.Blocks() as demo:
|
| 153 |
+
gr.Markdown("# LLM 工具调用 GAIA Agent 评测运行器")
|
| 154 |
gr.Markdown(
|
| 155 |
f"""
|
| 156 |
**当前策略:**
|
| 157 |
|
| 158 |
+
1. LLM planner 会输出 JSON action,决定调用哪个工具。
|
| 159 |
+
2. 工具执行器会调用规则、Python、Excel、Wikipedia、体育统计、网页搜索等工具。
|
| 160 |
+
3. 每题最多运行有限步数,避免无限搜索和过度消耗额度。
|
| 161 |
+
4. 音频、视频和棋局图工具当前禁用;LLM 遇到这类题应返回“无法确定”。
|
| 162 |
+
5. 当前文本模型:`{HF_TEXT_MODEL}`;当前视觉模型:`{HF_VISION_MODEL or "未启用"}`。
|
| 163 |
|
| 164 |
**提交前检查:**
|
| 165 |
+
Space Secrets 至少需要 `HF_TOKEN`,因为每道题都需要 LLM planner 决定工具调用。
|
| 166 |
"""
|
| 167 |
)
|
| 168 |
|
|
|
|
| 192 |
print("未找到 SPACE_ID 环境变量(可能是在本地运行)。无法确定仓库地址。")
|
| 193 |
|
| 194 |
print("-" * (60 + len(" 应用启动中 ")) + "\n")
|
| 195 |
+
print("正在启动 LLM 工具调用 GAIA Agent 评测 Gradio 界面...")
|
| 196 |
demo.launch(debug=True, share=False)
|
config.py
CHANGED
|
@@ -8,6 +8,8 @@ HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions"
|
|
| 8 |
HF_TEXT_MODEL = os.getenv("HF_TEXT_MODEL", "openai/gpt-oss-20b:cheapest")
|
| 9 |
HF_VISION_MODEL = os.getenv("HF_VISION_MODEL", "")
|
| 10 |
MAX_EVIDENCE_CHARS = int(os.getenv("MAX_EVIDENCE_CHARS", "18000"))
|
|
|
|
|
|
|
| 11 |
CACHE_DIR = Path(os.getenv("GAIA_CACHE_DIR", tempfile.gettempdir())) / "gaia_agent_files"
|
| 12 |
REQUEST_HEADERS = {
|
| 13 |
"User-Agent": (
|
|
@@ -20,8 +22,15 @@ REQUEST_HEADERS = {
|
|
| 20 |
|
| 21 |
def get_hf_token() -> str | None:
|
| 22 |
"""读取 Hugging Face Token。Space Secrets 中建议使用 HF_TOKEN。"""
|
| 23 |
-
|
| 24 |
os.getenv("HF_TOKEN")
|
| 25 |
or os.getenv("HUGGING_FACE_HUB_TOKEN")
|
| 26 |
or os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 27 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
HF_TEXT_MODEL = os.getenv("HF_TEXT_MODEL", "openai/gpt-oss-20b:cheapest")
|
| 9 |
HF_VISION_MODEL = os.getenv("HF_VISION_MODEL", "")
|
| 10 |
MAX_EVIDENCE_CHARS = int(os.getenv("MAX_EVIDENCE_CHARS", "18000"))
|
| 11 |
+
MAX_AGENT_STEPS = int(os.getenv("MAX_AGENT_STEPS", "4"))
|
| 12 |
+
MAX_TOOL_OUTPUT_CHARS = int(os.getenv("MAX_TOOL_OUTPUT_CHARS", "8000"))
|
| 13 |
CACHE_DIR = Path(os.getenv("GAIA_CACHE_DIR", tempfile.gettempdir())) / "gaia_agent_files"
|
| 14 |
REQUEST_HEADERS = {
|
| 15 |
"User-Agent": (
|
|
|
|
| 22 |
|
| 23 |
def get_hf_token() -> str | None:
|
| 24 |
"""读取 Hugging Face Token。Space Secrets 中建议使用 HF_TOKEN。"""
|
| 25 |
+
token = (
|
| 26 |
os.getenv("HF_TOKEN")
|
| 27 |
or os.getenv("HUGGING_FACE_HUB_TOKEN")
|
| 28 |
or os.getenv("HUGGINGFACEHUB_API_TOKEN")
|
| 29 |
)
|
| 30 |
+
if token:
|
| 31 |
+
return token
|
| 32 |
+
|
| 33 |
+
local_token_path = Path.home() / ".cache" / "huggingface" / "token"
|
| 34 |
+
if local_token_path.exists():
|
| 35 |
+
return local_token_path.read_text(encoding="utf-8").strip()
|
| 36 |
+
return None
|
tools/executor.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from config import MAX_TOOL_OUTPUT_CHARS
|
| 6 |
+
from tools.attachment_loader import download_task_file
|
| 7 |
+
from tools.code_runner import solve_python_output
|
| 8 |
+
from tools.common import normalize_answer, read_plain_file, truncate_text
|
| 9 |
+
from tools.direct_rules import solve_direct
|
| 10 |
+
from tools.sports_solver import solve_sports
|
| 11 |
+
from tools.spreadsheet_solver import solve_excel_food_sales
|
| 12 |
+
from tools.structured_web_tools import solve_structured_web, wikipedia_parse_html, wikipedia_search_titles
|
| 13 |
+
from tools.tool_specs import ToolSpec
|
| 14 |
+
from tools.web_tools import fetch_url_text, search_web
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def solver_result_to_observation(result, tool_name: str) -> dict[str, Any]:
|
| 18 |
+
return {
|
| 19 |
+
"tool": tool_name,
|
| 20 |
+
"ok": result.solved,
|
| 21 |
+
"answer": result.answer,
|
| 22 |
+
"confidence": result.confidence,
|
| 23 |
+
"source": result.source,
|
| 24 |
+
"evidence": truncate_text(result.evidence, MAX_TOOL_OUTPUT_CHARS),
|
| 25 |
+
"error": result.error,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def disabled_tool_observation(tool_name: str, reason: str) -> dict[str, Any]:
|
| 30 |
+
return {
|
| 31 |
+
"tool": tool_name,
|
| 32 |
+
"ok": False,
|
| 33 |
+
"answer": None,
|
| 34 |
+
"confidence": "low",
|
| 35 |
+
"source": f"{tool_name}.disabled",
|
| 36 |
+
"evidence": "",
|
| 37 |
+
"error": reason,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def direct_answer_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 42 |
+
question = args.get("question") or context["question"]
|
| 43 |
+
return solver_result_to_observation(solve_direct(question), "direct_answer_tool")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def python_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 47 |
+
return solver_result_to_observation(
|
| 48 |
+
solve_python_output(
|
| 49 |
+
context["question"],
|
| 50 |
+
args.get("task_id") or context.get("task_id", ""),
|
| 51 |
+
args.get("file_name") or context.get("file_name", ""),
|
| 52 |
+
),
|
| 53 |
+
"python_tool",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def spreadsheet_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 58 |
+
return solver_result_to_observation(
|
| 59 |
+
solve_excel_food_sales(
|
| 60 |
+
context["question"],
|
| 61 |
+
args.get("task_id") or context.get("task_id", ""),
|
| 62 |
+
args.get("file_name") or context.get("file_name", ""),
|
| 63 |
+
),
|
| 64 |
+
"spreadsheet_tool",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def wikipedia_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 69 |
+
question = args.get("question") or context["question"]
|
| 70 |
+
result = solve_structured_web(question)
|
| 71 |
+
if result.solved:
|
| 72 |
+
return solver_result_to_observation(result, "wikipedia_tool")
|
| 73 |
+
|
| 74 |
+
query = args.get("query") or question
|
| 75 |
+
try:
|
| 76 |
+
titles = wikipedia_search_titles(query, limit=5)
|
| 77 |
+
evidence_parts = [f"search_titles={titles}"]
|
| 78 |
+
if titles:
|
| 79 |
+
html = wikipedia_parse_html(titles[0])
|
| 80 |
+
evidence_parts.append(truncate_text(html, 5000))
|
| 81 |
+
return {
|
| 82 |
+
"tool": "wikipedia_tool",
|
| 83 |
+
"ok": bool(titles),
|
| 84 |
+
"answer": None,
|
| 85 |
+
"confidence": "medium" if titles else "low",
|
| 86 |
+
"source": "wikipedia_tool.search",
|
| 87 |
+
"evidence": truncate_text("\n\n".join(evidence_parts), MAX_TOOL_OUTPUT_CHARS),
|
| 88 |
+
"error": "" if titles else "Wikipedia search returned no titles.",
|
| 89 |
+
}
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
return {
|
| 92 |
+
"tool": "wikipedia_tool",
|
| 93 |
+
"ok": False,
|
| 94 |
+
"answer": None,
|
| 95 |
+
"confidence": "low",
|
| 96 |
+
"source": "wikipedia_tool.error",
|
| 97 |
+
"evidence": "",
|
| 98 |
+
"error": str(exc),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def sports_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 103 |
+
question = args.get("question") or context["question"]
|
| 104 |
+
return solver_result_to_observation(solve_sports(question), "sports_tool")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def web_search_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 108 |
+
query = args.get("query") or context["question"]
|
| 109 |
+
results = search_web(query, max_results=int(args.get("max_results", 5)))
|
| 110 |
+
return {
|
| 111 |
+
"tool": "web_search_tool",
|
| 112 |
+
"ok": bool(results),
|
| 113 |
+
"answer": None,
|
| 114 |
+
"confidence": "medium" if results else "low",
|
| 115 |
+
"source": "web_search_tool",
|
| 116 |
+
"evidence": truncate_text(json.dumps(results, ensure_ascii=False), MAX_TOOL_OUTPUT_CHARS),
|
| 117 |
+
"error": "" if results else "No search results.",
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def web_read_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 122 |
+
url = args.get("url", "")
|
| 123 |
+
if not url:
|
| 124 |
+
return {
|
| 125 |
+
"tool": "web_read_tool",
|
| 126 |
+
"ok": False,
|
| 127 |
+
"answer": None,
|
| 128 |
+
"confidence": "low",
|
| 129 |
+
"source": "web_read_tool",
|
| 130 |
+
"evidence": "",
|
| 131 |
+
"error": "Missing url.",
|
| 132 |
+
}
|
| 133 |
+
text = fetch_url_text(url, limit=MAX_TOOL_OUTPUT_CHARS)
|
| 134 |
+
return {
|
| 135 |
+
"tool": "web_read_tool",
|
| 136 |
+
"ok": bool(text and not text.startswith("无法读取网页")),
|
| 137 |
+
"answer": None,
|
| 138 |
+
"confidence": "medium",
|
| 139 |
+
"source": "web_read_tool",
|
| 140 |
+
"evidence": truncate_text(text, MAX_TOOL_OUTPUT_CHARS),
|
| 141 |
+
"error": "" if not text.startswith("无法读取网页") else text,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def attachment_text_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 146 |
+
task_id = args.get("task_id") or context.get("task_id", "")
|
| 147 |
+
file_name = args.get("file_name") or context.get("file_name", "")
|
| 148 |
+
file_path, note = download_task_file(task_id, file_name)
|
| 149 |
+
if not file_path:
|
| 150 |
+
return {
|
| 151 |
+
"tool": "attachment_text_tool",
|
| 152 |
+
"ok": False,
|
| 153 |
+
"answer": None,
|
| 154 |
+
"confidence": "low",
|
| 155 |
+
"source": "attachment_text_tool",
|
| 156 |
+
"evidence": note,
|
| 157 |
+
"error": note,
|
| 158 |
+
}
|
| 159 |
+
text = read_plain_file(Path(file_path), limit=MAX_TOOL_OUTPUT_CHARS)
|
| 160 |
+
return {
|
| 161 |
+
"tool": "attachment_text_tool",
|
| 162 |
+
"ok": True,
|
| 163 |
+
"answer": None,
|
| 164 |
+
"confidence": "medium",
|
| 165 |
+
"source": "attachment_text_tool",
|
| 166 |
+
"evidence": f"{note}\n{text}",
|
| 167 |
+
"error": "",
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def final_format_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 172 |
+
answer = normalize_answer(str(args.get("answer", "")))
|
| 173 |
+
return {
|
| 174 |
+
"tool": "final_format_tool",
|
| 175 |
+
"ok": bool(answer),
|
| 176 |
+
"answer": answer or None,
|
| 177 |
+
"confidence": args.get("confidence", "medium"),
|
| 178 |
+
"source": "final_format_tool",
|
| 179 |
+
"evidence": "Normalized final answer.",
|
| 180 |
+
"error": "" if answer else "Missing answer.",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def audio_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 185 |
+
return disabled_tool_observation("audio_tool", "当前版本未启用音频转写工具。")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def video_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 189 |
+
return disabled_tool_observation("video_tool", "当前版本未启用视频/YouTube 工具。")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def vision_tool(args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 193 |
+
return disabled_tool_observation("vision_tool", "当前版本未启用图片/棋局视觉工具。")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
TOOL_REGISTRY: dict[str, ToolSpec] = {
|
| 197 |
+
"direct_answer_tool": ToolSpec(
|
| 198 |
+
name="direct_answer_tool",
|
| 199 |
+
description="Low-cost deterministic solver for self-contained logic questions: reversed text, non-commutative table, botanical vegetable list.",
|
| 200 |
+
input_schema={"question": "optional string; omit to use current question"},
|
| 201 |
+
run=direct_answer_tool,
|
| 202 |
+
cost_level="free",
|
| 203 |
+
),
|
| 204 |
+
"python_tool": ToolSpec(
|
| 205 |
+
name="python_tool",
|
| 206 |
+
description="Download and execute/analyze an attached Python file, then return the final numeric/text output if recoverable.",
|
| 207 |
+
input_schema={"task_id": "optional string", "file_name": "optional string"},
|
| 208 |
+
run=python_tool,
|
| 209 |
+
cost_level="free",
|
| 210 |
+
),
|
| 211 |
+
"spreadsheet_tool": ToolSpec(
|
| 212 |
+
name="spreadsheet_tool",
|
| 213 |
+
description="Download and analyze an attached Excel spreadsheet; currently solves food-sales-not-drinks style calculations.",
|
| 214 |
+
input_schema={"task_id": "optional string", "file_name": "optional string"},
|
| 215 |
+
run=spreadsheet_tool,
|
| 216 |
+
cost_level="free",
|
| 217 |
+
),
|
| 218 |
+
"wikipedia_tool": ToolSpec(
|
| 219 |
+
name="wikipedia_tool",
|
| 220 |
+
description="Use MediaWiki/Wikipedia APIs and structured parsers for Wikipedia-style questions.",
|
| 221 |
+
input_schema={"query": "optional search query", "question": "optional question override"},
|
| 222 |
+
run=wikipedia_tool,
|
| 223 |
+
cost_level="low",
|
| 224 |
+
),
|
| 225 |
+
"sports_tool": ToolSpec(
|
| 226 |
+
name="sports_tool",
|
| 227 |
+
description="Use sports data APIs; currently handles the 1977 Yankees walks/at-bats question.",
|
| 228 |
+
input_schema={"question": "optional question override"},
|
| 229 |
+
run=sports_tool,
|
| 230 |
+
cost_level="low",
|
| 231 |
+
),
|
| 232 |
+
"web_search_tool": ToolSpec(
|
| 233 |
+
name="web_search_tool",
|
| 234 |
+
description="Search the web and return result titles and URLs.",
|
| 235 |
+
input_schema={"query": "string", "max_results": "optional integer, default 5"},
|
| 236 |
+
run=web_search_tool,
|
| 237 |
+
cost_level="low",
|
| 238 |
+
),
|
| 239 |
+
"web_read_tool": ToolSpec(
|
| 240 |
+
name="web_read_tool",
|
| 241 |
+
description="Read a specific URL and return extracted page text.",
|
| 242 |
+
input_schema={"url": "string"},
|
| 243 |
+
run=web_read_tool,
|
| 244 |
+
cost_level="low",
|
| 245 |
+
),
|
| 246 |
+
"attachment_text_tool": ToolSpec(
|
| 247 |
+
name="attachment_text_tool",
|
| 248 |
+
description="Download an attachment and read it as text when it is a plain text/CSV/JSON/Markdown-like file.",
|
| 249 |
+
input_schema={"task_id": "optional string", "file_name": "optional string"},
|
| 250 |
+
run=attachment_text_tool,
|
| 251 |
+
cost_level="free",
|
| 252 |
+
),
|
| 253 |
+
"final_format_tool": ToolSpec(
|
| 254 |
+
name="final_format_tool",
|
| 255 |
+
description="Normalize a candidate final answer string to submission-safe format.",
|
| 256 |
+
input_schema={"answer": "string", "confidence": "optional string"},
|
| 257 |
+
run=final_format_tool,
|
| 258 |
+
cost_level="free",
|
| 259 |
+
),
|
| 260 |
+
"audio_tool": ToolSpec(
|
| 261 |
+
name="audio_tool",
|
| 262 |
+
description="Disabled audio transcription tool. Calling it reports unavailable.",
|
| 263 |
+
input_schema={},
|
| 264 |
+
run=audio_tool,
|
| 265 |
+
enabled=False,
|
| 266 |
+
cost_level="disabled",
|
| 267 |
+
),
|
| 268 |
+
"video_tool": ToolSpec(
|
| 269 |
+
name="video_tool",
|
| 270 |
+
description="Disabled video/YouTube analysis tool. Calling it reports unavailable.",
|
| 271 |
+
input_schema={},
|
| 272 |
+
run=video_tool,
|
| 273 |
+
enabled=False,
|
| 274 |
+
cost_level="disabled",
|
| 275 |
+
),
|
| 276 |
+
"vision_tool": ToolSpec(
|
| 277 |
+
name="vision_tool",
|
| 278 |
+
description="Disabled image/chess vision tool. Calling it reports unavailable.",
|
| 279 |
+
input_schema={},
|
| 280 |
+
run=vision_tool,
|
| 281 |
+
enabled=False,
|
| 282 |
+
cost_level="disabled",
|
| 283 |
+
),
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def tool_prompt() -> str:
|
| 288 |
+
return "\n".join(spec.prompt_block() for spec in TOOL_REGISTRY.values())
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def execute_tool(action: str, args: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
| 292 |
+
spec = TOOL_REGISTRY.get(action)
|
| 293 |
+
if not spec:
|
| 294 |
+
return {
|
| 295 |
+
"tool": action,
|
| 296 |
+
"ok": False,
|
| 297 |
+
"answer": None,
|
| 298 |
+
"confidence": "low",
|
| 299 |
+
"source": "tool_executor.unknown_tool",
|
| 300 |
+
"evidence": "",
|
| 301 |
+
"error": f"Unknown tool: {action}",
|
| 302 |
+
}
|
| 303 |
+
if not spec.enabled:
|
| 304 |
+
return disabled_tool_observation(action, f"Tool {action} is disabled.")
|
| 305 |
+
try:
|
| 306 |
+
return spec.run(args or {}, context)
|
| 307 |
+
except Exception as exc:
|
| 308 |
+
return {
|
| 309 |
+
"tool": action,
|
| 310 |
+
"ok": False,
|
| 311 |
+
"answer": None,
|
| 312 |
+
"confidence": "low",
|
| 313 |
+
"source": f"{action}.exception",
|
| 314 |
+
"evidence": "",
|
| 315 |
+
"error": str(exc),
|
| 316 |
+
}
|
tools/llm_client.py
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Any
|
| 2 |
|
| 3 |
import requests
|
|
@@ -6,7 +9,25 @@ from config import HF_ROUTER_URL, HF_TEXT_MODEL, get_hf_token
|
|
| 6 |
from tools.common import normalize_answer
|
| 7 |
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
token = get_hf_token()
|
| 11 |
if not token:
|
| 12 |
raise RuntimeError("未配置 HF_TOKEN。")
|
|
@@ -20,16 +41,64 @@ def call_hf_chat(messages: list[dict[str, Any]], model: str = HF_TEXT_MODEL, max
|
|
| 20 |
json={
|
| 21 |
"model": model,
|
| 22 |
"messages": messages,
|
| 23 |
-
"temperature":
|
| 24 |
"max_tokens": max_tokens,
|
| 25 |
},
|
| 26 |
timeout=180,
|
| 27 |
)
|
| 28 |
-
response.
|
|
|
|
|
|
|
|
|
|
| 29 |
data = response.json()
|
| 30 |
return data["choices"][0]["message"]["content"]
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def answer_with_light_model(question: str, evidence: str) -> str:
|
| 34 |
prompt = f"""
|
| 35 |
你是一个 GAIA 问答 Agent。你会收到问题和工具已经整理好的证据。
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
from typing import Any
|
| 5 |
|
| 6 |
import requests
|
|
|
|
| 9 |
from tools.common import normalize_answer
|
| 10 |
|
| 11 |
|
| 12 |
+
@dataclass
|
| 13 |
+
class AgentAction:
|
| 14 |
+
action: str
|
| 15 |
+
args: dict[str, Any]
|
| 16 |
+
thought: str = ""
|
| 17 |
+
answer: str | None = None
|
| 18 |
+
confidence: str = "medium"
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def is_final(self) -> bool:
|
| 22 |
+
return self.action == "final_answer"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def call_hf_chat(
|
| 26 |
+
messages: list[dict[str, Any]],
|
| 27 |
+
model: str = HF_TEXT_MODEL,
|
| 28 |
+
max_tokens: int = 512,
|
| 29 |
+
temperature: float = 0.1,
|
| 30 |
+
) -> str:
|
| 31 |
token = get_hf_token()
|
| 32 |
if not token:
|
| 33 |
raise RuntimeError("未配置 HF_TOKEN。")
|
|
|
|
| 41 |
json={
|
| 42 |
"model": model,
|
| 43 |
"messages": messages,
|
| 44 |
+
"temperature": temperature,
|
| 45 |
"max_tokens": max_tokens,
|
| 46 |
},
|
| 47 |
timeout=180,
|
| 48 |
)
|
| 49 |
+
if not response.ok:
|
| 50 |
+
raise RuntimeError(
|
| 51 |
+
f"HF chat 请求失败:status={response.status_code}, body={response.text[:500]}"
|
| 52 |
+
)
|
| 53 |
data = response.json()
|
| 54 |
return data["choices"][0]["message"]["content"]
|
| 55 |
|
| 56 |
|
| 57 |
+
def extract_json_object(text: str) -> dict[str, Any]:
|
| 58 |
+
text = text.strip()
|
| 59 |
+
if text.startswith("```"):
|
| 60 |
+
text = re.sub(r"^```(?:json)?", "", text, flags=re.IGNORECASE).strip()
|
| 61 |
+
text = re.sub(r"```$", "", text).strip()
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
return json.loads(text)
|
| 65 |
+
except json.JSONDecodeError:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
| 69 |
+
if not match:
|
| 70 |
+
raise ValueError(f"模型没有输出 JSON 对象:{text[:300]}")
|
| 71 |
+
return json.loads(match.group(0))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def parse_agent_action(text: str) -> AgentAction:
|
| 75 |
+
data = extract_json_object(text)
|
| 76 |
+
action = data.get("action") or data.get("tool")
|
| 77 |
+
if not isinstance(action, str) or not action.strip():
|
| 78 |
+
raise ValueError(f"模型 JSON 缺少 action 字段:{data}")
|
| 79 |
+
|
| 80 |
+
args = data.get("args") or {}
|
| 81 |
+
if not isinstance(args, dict):
|
| 82 |
+
raise ValueError(f"action args 必须是对象:{data}")
|
| 83 |
+
|
| 84 |
+
answer = data.get("answer")
|
| 85 |
+
if answer is not None:
|
| 86 |
+
answer = str(answer)
|
| 87 |
+
|
| 88 |
+
return AgentAction(
|
| 89 |
+
action=action.strip(),
|
| 90 |
+
args=args,
|
| 91 |
+
thought=str(data.get("thought", "")).strip(),
|
| 92 |
+
answer=answer,
|
| 93 |
+
confidence=str(data.get("confidence", "medium")).strip() or "medium",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def plan_next_action(messages: list[dict[str, Any]]) -> AgentAction:
|
| 98 |
+
raw = call_hf_chat(messages, max_tokens=700, temperature=0.0)
|
| 99 |
+
return parse_agent_action(raw)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
def answer_with_light_model(question: str, evidence: str) -> str:
|
| 103 |
prompt = f"""
|
| 104 |
你是一个 GAIA 问答 Agent。你会收到问题和工具已经整理好的证据。
|
tools/tool_specs.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass
|
| 2 |
+
from typing import Any, Callable
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
ToolContext = dict[str, Any]
|
| 6 |
+
ToolArgs = dict[str, Any]
|
| 7 |
+
ToolRunner = Callable[[ToolArgs, ToolContext], dict[str, Any]]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class ToolSpec:
|
| 12 |
+
name: str
|
| 13 |
+
description: str
|
| 14 |
+
input_schema: dict[str, Any]
|
| 15 |
+
run: ToolRunner
|
| 16 |
+
enabled: bool = True
|
| 17 |
+
cost_level: str = "low"
|
| 18 |
+
|
| 19 |
+
def prompt_block(self) -> str:
|
| 20 |
+
enabled_text = "enabled" if self.enabled else "disabled"
|
| 21 |
+
return (
|
| 22 |
+
f"- {self.name} ({enabled_text}, cost={self.cost_level})\n"
|
| 23 |
+
f" description: {self.description}\n"
|
| 24 |
+
f" args_schema: {self.input_schema}"
|
| 25 |
+
)
|