OhBrian commited on
Commit
e7e1e90
·
1 Parent(s): 6c511d6

更新为llm判断工具函数调用

Browse files
Files changed (7) hide show
  1. README.md +24 -5
  2. agent.py +154 -125
  3. app.py +8 -8
  4. config.py +10 -1
  5. tools/executor.py +316 -0
  6. tools/llm_client.py +72 -3
  7. 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
- 当前版本已经改成工具路由 Agent:
18
 
19
  - `app.py` 会通过课程评测接口拉取问题并提交答案。
20
- - `agent.py` 中的 `GaiaAgent` 会按题型路由到专用 solver
21
- - `tools/` 目录包含规则题、附件下载、Python 执行、Excel 计算结构化网页和体育数据工具。
 
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
- ── sports_solver.py
 
 
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
- 当前代码采用 40% 优先策略为了先冲过最低分,不处理音频和视频题,只集中处理文本、网页、Python、Excel、体育统计和少量确性规则题
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 loopLLM 每一步只能输出一个 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
- from pathlib import Path
2
-
3
- from config import HF_TEXT_MODEL, HF_VISION_MODEL
4
- from tools.attachment_loader import download_task_file
5
- from tools.code_runner import solve_python_output
6
- from tools.common import (
7
- AUDIO_VIDEO_EXTENSIONS,
8
- IMAGE_EXTENSIONS,
9
- TEXT_EXTENSIONS,
10
- extract_urls,
11
- is_youtube_url,
12
- normalize_answer,
13
- read_plain_file,
14
- )
15
- from tools.direct_rules import solve_direct
16
- from tools.llm_client import answer_with_light_model
17
- from tools.sports_solver import solve_sports
18
- from tools.spreadsheet_solver import solve_excel_food_sales
19
- from tools.structured_web_tools import solve_structured_web
20
- from tools.types import SolverResult, unresolved
21
- from tools.web_tools import collect_web_evidence, fetch_url_text
 
 
 
 
 
22
 
23
 
24
  class GaiaAgent:
25
- """面向当前 GAIA 20 题的工具优先 Agent。"""
26
 
27
  def __init__(self):
28
- print("GAIA 工具路由 Agent 已初始化。")
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
- media_result = self._skip_media_if_needed(question, file_name)
36
- if media_result.solved:
37
- return media_result
38
-
39
- # 1. 纯规则题:不联网、不调用模型。
40
- result = solve_direct(question)
41
- if result.solved:
42
- return result
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
- # 3. 结构化网页/体育数据:优先走专用解析器。
54
- result = solve_sports(question)
55
- if result.solved:
56
- return result
 
 
 
 
57
 
58
- result = solve_structured_web(question)
59
- if result.solved:
60
- return result
 
 
 
 
 
 
 
 
61
 
62
- # 4. 图片题暂时不投入,除非后续补视觉/棋局 solver。
63
- if file_name and Path(file_name).suffix.lower() in IMAGE_EXTENSIONS:
64
- return SolverResult(
65
- "无法确定",
66
- source="agent.skip_image",
67
- confidence="low",
68
- evidence="当前 40% 策略暂不处理图片/棋局题。",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  )
70
 
71
- # 5. 最后才构造证据交给轻量模型兜底。
72
- evidence = self._build_fallback_evidence(question, task_id, file_name)
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="llm_fallback.error",
86
- confidence="low",
87
- evidence=evidence,
88
- error=f"{exc}",
89
  )
90
 
91
- return unresolved("agent", "所有 solver 均未给出答案。", evidence)
 
 
 
 
 
 
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 _skip_media_if_needed(self, question: str, file_name: str) -> SolverResult:
102
- extension = Path(file_name).suffix.lower() if file_name else ""
103
- if extension in AUDIO_VIDEO_EXTENSIONS:
104
- return SolverResult(
105
- "无法确定",
106
- source="agent.skip_audio_video_attachment",
107
- confidence="low",
108
- evidence=f"当前 40% 策略跳过音视频附件:{file_name}",
109
- )
110
-
111
- urls = extract_urls(question)
112
- if any(is_youtube_url(url) for url in urls):
113
- return SolverResult(
114
- "无法确定",
115
- source="agent.skip_youtube",
116
- confidence="low",
117
- evidence="当前 40% 策略跳过 YouTube 视频题。",
118
- )
119
- return unresolved("agent.media_skip")
120
-
121
- def _build_fallback_evidence(self, question: str, task_id: str, file_name: str) -> str:
122
- evidence_parts = []
123
-
124
- if file_name:
125
- file_path, note = download_task_file(task_id, file_name)
126
- evidence_parts.append(note)
127
- if file_path:
128
- extension = Path(file_name).suffix.lower()
129
- if extension in TEXT_EXTENSIONS:
130
- evidence_parts.append(f"文本附件内容:\n{read_plain_file(file_path)}")
131
- else:
132
- evidence_parts.append(f"附件类型 {extension} 没有专用兜底处理。")
133
-
134
- urls = extract_urls(question)
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("# 工具路由 GAIA Agent 评测运行器")
154
  gr.Markdown(
155
  f"""
156
  **当前策略:**
157
 
158
- 1. 目标是先稳定覆盖 40% 左右的低成本题型而不是全多模态
159
- 2. 规则、Python、Excel、Wikipedia/表格棒球统计会优先走专用 solver
160
- 3. 音频、视频和棋局图暂时跳过返回“法确定”
161
- 4. 只有专solver 失败时,才调用轻量文本模型 `{HF_TEXT_MODEL}` 兜底
162
- 5. 图片题默认不启用视觉模型;当前视觉模型:`{HF_VISION_MODEL or "未启用"}`。
163
 
164
  **提交前检查:**
165
- Space Secrets 至少需要 `HF_TOKEN`,否则 LLM 兜底不可用;确定性 solver 不依赖它
166
  """
167
  )
168
 
@@ -192,5 +192,5 @@ if __name__ == "__main__":
192
  print("未找到 SPACE_ID 环境变量(可能是在本地运行)。无法确定仓库地址。")
193
 
194
  print("-" * (60 + len(" 应用启动中 ")) + "\n")
195
- print("正在启动工具路由 GAIA Agent 评测 Gradio 界面...")
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
- return (
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
- def call_hf_chat(messages: list[dict[str, Any]], model: str = HF_TEXT_MODEL, max_tokens: int = 512) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": 0.1,
24
  "max_tokens": max_tokens,
25
  },
26
  timeout=180,
27
  )
28
- response.raise_for_status()
 
 
 
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
+ )