MenuPilot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- menupilot/__init__.py +3 -0
- menupilot/__main__.py +4 -0
- menupilot/agent/__init__.py +0 -0
- menupilot/agent/agent_loop.py +414 -0
- menupilot/agent/matching_engine.py +974 -0
- menupilot/agent/option_expander.py +490 -0
- menupilot/agent/orchestration.py +570 -0
- menupilot/agent/rule_engine.py +509 -0
- menupilot/agent/sandbox.py +216 -0
- menupilot/agent/schema_analyzer.py +1026 -0
- menupilot/agent/template_preprocessor.py +293 -0
- menupilot/agent/token_classifier.py +816 -0
- menupilot/agent/tools.py +365 -0
- menupilot/agent/workflow.py +1072 -0
- menupilot/cli/human_review.py +191 -0
- menupilot/cli/repl.py +821 -0
- menupilot/config.py +113 -0
- menupilot/data/__init__.py +0 -0
- menupilot/data/canonical_schema.py +135 -0
- menupilot/data/mapping_rules.yaml +387 -0
- menupilot/data/memory.py +674 -0
- menupilot/data/token_dict.py +275 -0
- menupilot/excel_io/__init__.py +0 -0
- menupilot/excel_io/excel_reader.py +552 -0
- menupilot/excel_io/excel_writer.py +413 -0
- menupilot/main.py +322 -0
- menupilot/wizard.py +86 -0
- menupilot-0.1.0.dist-info/METADATA +397 -0
- menupilot-0.1.0.dist-info/RECORD +33 -0
- menupilot-0.1.0.dist-info/WHEEL +5 -0
- menupilot-0.1.0.dist-info/entry_points.txt +2 -0
- menupilot-0.1.0.dist-info/licenses/LICENSE +21 -0
- menupilot-0.1.0.dist-info/top_level.txt +1 -0
menupilot/__init__.py
ADDED
menupilot/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Loop — 自然语言驱动的工具调用循环,含会话记忆管理。
|
|
3
|
+
|
|
4
|
+
while turn < max_turns:
|
|
5
|
+
response = LLM.chat(memory.to_llm_input(), tools)
|
|
6
|
+
if no tool_calls: return response
|
|
7
|
+
for each tool_call: execute → append result
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from collections import deque
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
# ── 常量 ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
MAX_TURNS = 15
|
|
20
|
+
DUPLICATE_NOTICE_THRESHOLD = 3
|
|
21
|
+
MAX_MEMORY_TURNS = 20
|
|
22
|
+
MAX_MEMORY_TOKENS = 8000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
26
|
+
# SessionMemory — 滑动窗口记忆管理
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
28
|
+
|
|
29
|
+
class SessionMemory:
|
|
30
|
+
"""滑动窗口消息队列。
|
|
31
|
+
|
|
32
|
+
规则:
|
|
33
|
+
- 最多保留 max_turns 轮对话
|
|
34
|
+
- 关键消息(文件路径、确认、列映射)不被驱逐
|
|
35
|
+
- system prompt 永远保留,不参与驱逐
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, max_turns: int = MAX_MEMORY_TURNS, max_tokens: int = MAX_MEMORY_TOKENS):
|
|
39
|
+
self.messages: deque = deque()
|
|
40
|
+
self.max_turns = max_turns
|
|
41
|
+
self.max_tokens = max_tokens
|
|
42
|
+
self.system_prompt: str = ""
|
|
43
|
+
self.task_context: Dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
def add(self, message: dict):
|
|
46
|
+
self.messages.append(message)
|
|
47
|
+
self._evict()
|
|
48
|
+
|
|
49
|
+
def _evict(self):
|
|
50
|
+
while len(self.messages) > self.max_turns * 2: # *2 因每轮=user+assistant
|
|
51
|
+
oldest = self.messages[0]
|
|
52
|
+
if self._is_critical(oldest):
|
|
53
|
+
break
|
|
54
|
+
self.messages.popleft()
|
|
55
|
+
|
|
56
|
+
def _is_critical(self, message: dict) -> bool:
|
|
57
|
+
content = str(message.get("content", ""))
|
|
58
|
+
# 文件路径、确认、列映射 — 这些不能丢
|
|
59
|
+
keywords = [".xlsx", "column_mapping", "yes", "是", "确认", "执行", "output_path",
|
|
60
|
+
"master_path", "template_path", "run_sop_matching", "run_option_expansion"]
|
|
61
|
+
return any(k in content.lower() for k in keywords)
|
|
62
|
+
|
|
63
|
+
def to_llm_input(self) -> list:
|
|
64
|
+
return [
|
|
65
|
+
{"role": "system", "content": self.system_prompt},
|
|
66
|
+
*list(self.messages),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
def reset_task(self):
|
|
70
|
+
self.messages.clear()
|
|
71
|
+
self.task_context = {}
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def turn_count(self) -> int:
|
|
75
|
+
return len(self.messages) // 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_system_prompt(cwd: str = "") -> str:
|
|
79
|
+
from menupilot.agent.tools import TOOLS
|
|
80
|
+
|
|
81
|
+
tool_descriptions = []
|
|
82
|
+
for t in TOOLS:
|
|
83
|
+
params = t.get("parameters", {}).get("properties", {})
|
|
84
|
+
param_str = ", ".join(f"{k}: {v.get('type','str')}" for k, v in params.items())
|
|
85
|
+
tool_descriptions.append(f"- **{t['name']}**({param_str}): {t['description']}")
|
|
86
|
+
|
|
87
|
+
return f"""你是 PosAgent,一个奶茶/餐饮行业的 POS 模板自动化助手。
|
|
88
|
+
|
|
89
|
+
## 工具
|
|
90
|
+
{chr(10).join(tool_descriptions)}
|
|
91
|
+
|
|
92
|
+
## 判断标准
|
|
93
|
+
- Schema Analyzer 会自动识别列映射,直接展示结果请用户整体确认即可
|
|
94
|
+
- 用户确认的映射列数量决定管线选择:多列映射→run_sop_matching,单选项展开→run_option_expansion
|
|
95
|
+
- 奶底/茶底为空是正常的通配行为,不是错误
|
|
96
|
+
- execute_python 只能用于数据分析,禁止尝试写入文件
|
|
97
|
+
|
|
98
|
+
## 错误处理
|
|
99
|
+
- 工具返回 fatal:true → 立即停止,把 error 和 hint 直接展示给用户,不要重试
|
|
100
|
+
- 工具返回 retryable:false → 换一种方式,不要用相同参数重试
|
|
101
|
+
- 工具返回 retryable:true → 可以纠正参数后重试,最多 2 次
|
|
102
|
+
- 所有错误必须告知用户具体原因和建议,禁止静默吞掉
|
|
103
|
+
|
|
104
|
+
## 交互效率
|
|
105
|
+
- 展示信息和确认操作合并为一次 ask_user 调用
|
|
106
|
+
- 禁止对同一任务的不同字段分多次 ask_user 确认
|
|
107
|
+
- 格式:先展示完整方案,再问"是否执行"
|
|
108
|
+
- 用户说 --sheet N → 传 template_sheet=N
|
|
109
|
+
- 管线完成后用 execute_python 读取 report_path 指向的 txt 报告
|
|
110
|
+
- 以表格展示:商品名 | 不匹配原因 | 行数,表格后给出检查建议
|
|
111
|
+
|
|
112
|
+
## 领域知识
|
|
113
|
+
- 奶茶规格维度: 糖度/温度/规格/奶底/茶底
|
|
114
|
+
- SOP 格式: "T240、B30/80、S4" (时间/配方/糖量)
|
|
115
|
+
- 主数据常见列名: 品名/杯型/奶底/做法/糖/SOP/主编码/商品名称/代码
|
|
116
|
+
- 模板常见列名: 菜品名称/规格/口味做法组合/配料/商品编码/选项名称
|
|
117
|
+
- testdata/pos1test.xlsx 的 Sheet 0 是说明,Sheet 1 是数据模板
|
|
118
|
+
|
|
119
|
+
## 当前会话
|
|
120
|
+
工作目录: {cwd or os.getcwd()}
|
|
121
|
+
当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
126
|
+
# AgentLoop
|
|
127
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
class AgentLoop:
|
|
130
|
+
"""Agent 主循环,含持久化会话记忆。
|
|
131
|
+
|
|
132
|
+
用法:
|
|
133
|
+
agent = AgentLoop(llm_client)
|
|
134
|
+
result = agent.run("匹配 SOPcodemaindata.xlsx 到 pos1test.xlsx")
|
|
135
|
+
result = agent.continue_conversation("yes") # 继续上一轮会话
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, llm_client, cwd: str = ""):
|
|
139
|
+
self.llm = llm_client
|
|
140
|
+
self.cwd = cwd or os.getcwd()
|
|
141
|
+
self.recent_calls: deque = deque(maxlen=DUPLICATE_NOTICE_THRESHOLD + 1)
|
|
142
|
+
|
|
143
|
+
from menupilot.agent.tools import TOOLS
|
|
144
|
+
self.tools: Dict[str, dict] = {t["name"]: t for t in TOOLS}
|
|
145
|
+
self.memory = SessionMemory()
|
|
146
|
+
self.memory.system_prompt = _build_system_prompt(self.cwd)
|
|
147
|
+
|
|
148
|
+
def run(self, user_input: str) -> str:
|
|
149
|
+
"""新会话:清空记忆,开始 Agent loop。"""
|
|
150
|
+
self.memory.reset_task()
|
|
151
|
+
self.memory.add({"role": "user", "content": user_input})
|
|
152
|
+
return self._loop()
|
|
153
|
+
|
|
154
|
+
def continue_conversation(self, user_input: str) -> str:
|
|
155
|
+
"""继续已有会话:追加用户消息,继续 loop。"""
|
|
156
|
+
self.memory.add({"role": "user", "content": user_input})
|
|
157
|
+
return self._loop()
|
|
158
|
+
|
|
159
|
+
def _loop(self) -> str:
|
|
160
|
+
"""内部循环:LLM ↔ 工具执行。"""
|
|
161
|
+
last_error = None
|
|
162
|
+
|
|
163
|
+
for turn in range(1, MAX_TURNS + 1):
|
|
164
|
+
response = self._call_llm(self.memory.to_llm_input())
|
|
165
|
+
|
|
166
|
+
if not response.get("tool_calls"):
|
|
167
|
+
self.memory.add({"role": "assistant", "content": response.get("content", "")})
|
|
168
|
+
return response.get("content", "")
|
|
169
|
+
|
|
170
|
+
for tc in response["tool_calls"]:
|
|
171
|
+
result = self._execute_tool(tc)
|
|
172
|
+
|
|
173
|
+
# 不可恢复错误 → 立即终止,告知用户
|
|
174
|
+
if isinstance(result, dict) and result.get("fatal"):
|
|
175
|
+
self.memory.add({"role": "assistant", "content": None,
|
|
176
|
+
"tool_calls": [self._make_tool_msg(tc)]})
|
|
177
|
+
self.memory.add({"role": "tool", "tool_call_id": tc["id"],
|
|
178
|
+
"content": json.dumps(self._sanitize(result), ensure_ascii=False, default=str)})
|
|
179
|
+
return (
|
|
180
|
+
f"操作无法继续:{result.get('error', '')}\n\n"
|
|
181
|
+
f"💡 {result.get('hint', '')}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
last_error = result if isinstance(result, dict) and "error" in result else None
|
|
185
|
+
|
|
186
|
+
self.memory.add({"role": "assistant", "content": None,
|
|
187
|
+
"tool_calls": [self._make_tool_msg(tc)]})
|
|
188
|
+
self.memory.add({
|
|
189
|
+
"role": "tool",
|
|
190
|
+
"tool_call_id": tc["id"],
|
|
191
|
+
"content": json.dumps(self._sanitize(result), ensure_ascii=False, default=str),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# 超时 → 带上最后失败原因
|
|
195
|
+
if last_error:
|
|
196
|
+
return (
|
|
197
|
+
f"已执行 {MAX_TURNS} 轮,仍未能完成任务。\n\n"
|
|
198
|
+
f"最后一次失败:{last_error.get('error', '未知')}\n"
|
|
199
|
+
f"💡 {last_error.get('hint', '请简化需求后重试')}"
|
|
200
|
+
)
|
|
201
|
+
return f"已执行 {MAX_TURNS} 轮工具调用,仍未完成任务。请简化需求后重试。"
|
|
202
|
+
|
|
203
|
+
def _make_tool_msg(self, tc: dict) -> dict:
|
|
204
|
+
return {
|
|
205
|
+
"id": tc["id"], "type": "function",
|
|
206
|
+
"function": {
|
|
207
|
+
"name": tc["_name"],
|
|
208
|
+
"arguments": json.dumps(tc["_parsed_args"], ensure_ascii=False),
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def _call_llm(self, messages: list) -> dict:
|
|
213
|
+
tool_schemas = [
|
|
214
|
+
{"type": "function", "function": {
|
|
215
|
+
"name": t["name"], "description": t["description"],
|
|
216
|
+
"parameters": t["parameters"],
|
|
217
|
+
}}
|
|
218
|
+
for t in self.tools.values()
|
|
219
|
+
]
|
|
220
|
+
try:
|
|
221
|
+
completion = self.llm.chat.completions.create(
|
|
222
|
+
model=self.llm.model, messages=messages,
|
|
223
|
+
tools=tool_schemas, temperature=0.1,
|
|
224
|
+
)
|
|
225
|
+
msg = completion.choices[0].message
|
|
226
|
+
tool_calls = []
|
|
227
|
+
if msg.tool_calls:
|
|
228
|
+
for tc in msg.tool_calls:
|
|
229
|
+
try:
|
|
230
|
+
args = json.loads(tc.function.arguments)
|
|
231
|
+
except json.JSONDecodeError:
|
|
232
|
+
args = {}
|
|
233
|
+
tool_calls.append({
|
|
234
|
+
"id": tc.id, "type": "function",
|
|
235
|
+
"function": {"name": tc.function.name,
|
|
236
|
+
"arguments": json.dumps(args, ensure_ascii=False)},
|
|
237
|
+
"_parsed_args": args, "_name": tc.function.name,
|
|
238
|
+
})
|
|
239
|
+
return {"content": msg.content, "tool_calls": tool_calls}
|
|
240
|
+
except Exception as e:
|
|
241
|
+
return {"content": f"LLM 调用失败: {e}", "tool_calls": []}
|
|
242
|
+
|
|
243
|
+
def _execute_tool(self, tc: dict) -> dict:
|
|
244
|
+
name = tc.get("_name", tc.get("name", ""))
|
|
245
|
+
args = tc.get("_parsed_args", tc.get("arguments", {}))
|
|
246
|
+
tool = self.tools.get(name)
|
|
247
|
+
if not tool:
|
|
248
|
+
return {
|
|
249
|
+
"error_type": "unknown_tool",
|
|
250
|
+
"error": f"未知工具 '{name}',可用: {list(self.tools)}",
|
|
251
|
+
"hint": "请使用可用工具列表中的工具",
|
|
252
|
+
"retryable": False,
|
|
253
|
+
"fatal": False, # 让 LLM 有机会修正
|
|
254
|
+
}
|
|
255
|
+
call_hash = self._hash_call(name, args)
|
|
256
|
+
self.recent_calls.append(call_hash)
|
|
257
|
+
if self._count_recent(call_hash) >= DUPLICATE_NOTICE_THRESHOLD:
|
|
258
|
+
return {
|
|
259
|
+
"error_type": "duplicate_call",
|
|
260
|
+
"notice": f"'{name}' 已连续调用 {DUPLICATE_NOTICE_THRESHOLD} 次且参数相同。"
|
|
261
|
+
f"如果这是预期行为请忽略,否则请换一种策略。",
|
|
262
|
+
"retryable": True,
|
|
263
|
+
}
|
|
264
|
+
try:
|
|
265
|
+
return tool["handler"](**args)
|
|
266
|
+
except PermissionError as e:
|
|
267
|
+
return {
|
|
268
|
+
"error_type": "file_locked",
|
|
269
|
+
"error": f"文件被占用,无法写入: {e}",
|
|
270
|
+
"hint": "请关闭 Excel 中打开的输出文件后重试,或换一个输出文件名",
|
|
271
|
+
"retryable": False,
|
|
272
|
+
"fatal": True,
|
|
273
|
+
}
|
|
274
|
+
except FileNotFoundError as e:
|
|
275
|
+
return {
|
|
276
|
+
"error_type": "file_not_found",
|
|
277
|
+
"error": f"文件不存在: {e}",
|
|
278
|
+
"hint": "请检查文件路径是否正确,文件是否已被移动或删除",
|
|
279
|
+
"retryable": False,
|
|
280
|
+
"fatal": True,
|
|
281
|
+
}
|
|
282
|
+
except Exception as e:
|
|
283
|
+
return {
|
|
284
|
+
"error_type": type(e).__name__,
|
|
285
|
+
"error": f"{type(e).__name__}: {e}",
|
|
286
|
+
"hint": "请将此错误展示给用户,询问是否需要帮助排查",
|
|
287
|
+
"retryable": True,
|
|
288
|
+
"fatal": False,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _sanitize(obj):
|
|
293
|
+
"""递归转换非字符串 key 为字符串,确保 json.dumps 可用。"""
|
|
294
|
+
if isinstance(obj, dict):
|
|
295
|
+
return {
|
|
296
|
+
k if isinstance(k, (str, int, float, bool, type(None))) else str(k):
|
|
297
|
+
AgentLoop._sanitize(v) for k, v in obj.items()
|
|
298
|
+
}
|
|
299
|
+
if isinstance(obj, (list, tuple, set)):
|
|
300
|
+
return [AgentLoop._sanitize(v) for v in obj]
|
|
301
|
+
return obj
|
|
302
|
+
|
|
303
|
+
def _hash_call(self, name: str, args: dict) -> str:
|
|
304
|
+
raw = json.dumps({"n": name, "a": args}, sort_keys=True, ensure_ascii=False)
|
|
305
|
+
return hashlib.md5(raw.encode()).hexdigest()
|
|
306
|
+
|
|
307
|
+
def _count_recent(self, hash_val: str) -> int:
|
|
308
|
+
return sum(1 for h in self.recent_calls if h == hash_val)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
312
|
+
# 自测
|
|
313
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
passed = 0
|
|
317
|
+
failed = 0
|
|
318
|
+
|
|
319
|
+
def check(condition, msg):
|
|
320
|
+
global passed, failed
|
|
321
|
+
if condition:
|
|
322
|
+
passed += 1
|
|
323
|
+
print(f" PASS {msg}")
|
|
324
|
+
else:
|
|
325
|
+
failed += 1
|
|
326
|
+
print(f" FAIL {msg}")
|
|
327
|
+
|
|
328
|
+
print("=== Agent Loop 自测 ===\n")
|
|
329
|
+
|
|
330
|
+
from unittest.mock import MagicMock
|
|
331
|
+
|
|
332
|
+
# ── 1. 新会话 → 返回文本 ──
|
|
333
|
+
print("1. 新会话 run() → 返回文本")
|
|
334
|
+
mock = MagicMock()
|
|
335
|
+
mock.model = "mock"
|
|
336
|
+
m = MagicMock()
|
|
337
|
+
m.content = "匹配完成,870行,783 HIGH。"
|
|
338
|
+
m.tool_calls = None
|
|
339
|
+
mock.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=m)])
|
|
340
|
+
agent = AgentLoop(mock, cwd="/tmp")
|
|
341
|
+
r = agent.run("匹配")
|
|
342
|
+
check("783 HIGH" in r, f"新会话(实际 {r[:50]})")
|
|
343
|
+
print()
|
|
344
|
+
|
|
345
|
+
# ── 2. 继续会话 → 上下文保持 ──
|
|
346
|
+
print("2. continue_conversation → 上下文保持")
|
|
347
|
+
# Agent 的 memory 里已有上一轮的系统提示和用户输入
|
|
348
|
+
# 模拟 LLM 记得上下文
|
|
349
|
+
m2 = MagicMock()
|
|
350
|
+
m2.content = "好的,执行完成。上次匹配了870行。"
|
|
351
|
+
m2.tool_calls = None
|
|
352
|
+
mock.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=m2)])
|
|
353
|
+
r2 = agent.continue_conversation("yes")
|
|
354
|
+
check("870" in r2, f"记忆保持(实际 {r2[:50]})")
|
|
355
|
+
check(len(agent.memory.messages) >= 4, f"消息历史 ≥4 条(实际 {len(agent.memory.messages)})")
|
|
356
|
+
print()
|
|
357
|
+
|
|
358
|
+
# ── 3. 关键消息不被驱逐 ──
|
|
359
|
+
print("3. 关键消息保护 — .xlsx/yes/确认 不丢")
|
|
360
|
+
sm = SessionMemory(max_turns=2)
|
|
361
|
+
sm.add({"role": "user", "content": "用 testdata/SOPcodemaindata.xlsx"})
|
|
362
|
+
sm.add({"role": "assistant", "content": "确认执行?"})
|
|
363
|
+
sm.add({"role": "user", "content": "yes"})
|
|
364
|
+
# 大量填充非关键消息
|
|
365
|
+
for i in range(10):
|
|
366
|
+
sm.add({"role": "tool", "content": f"result {i}"})
|
|
367
|
+
msgs = sm.to_llm_input()
|
|
368
|
+
check(any(".xlsx" in str(m) for m in msgs), "文件路径保留")
|
|
369
|
+
check(any("yes" in str(m) for m in msgs), "确认消息保留")
|
|
370
|
+
print()
|
|
371
|
+
|
|
372
|
+
# ── 4. 工具调用 → 多轮循环 → 最终文本 ──
|
|
373
|
+
print("4. 工具调用 → 多轮循环")
|
|
374
|
+
import tempfile, pandas as pd
|
|
375
|
+
tmp = tempfile.mkdtemp()
|
|
376
|
+
test_xlsx = os.path.join(tmp, "test.xlsx")
|
|
377
|
+
pd.DataFrame({"A": [1,2], "B": [3,4]}).to_excel(test_xlsx, index=False)
|
|
378
|
+
|
|
379
|
+
mock4 = MagicMock()
|
|
380
|
+
mock4.model = "mock"
|
|
381
|
+
tc = MagicMock()
|
|
382
|
+
tc.id = "c1"; tc.function.name = "read_excel_info"
|
|
383
|
+
tc.function.arguments = json.dumps({"filepath": test_xlsx})
|
|
384
|
+
msg4a = MagicMock(); msg4a.content = None; msg4a.tool_calls = [tc]
|
|
385
|
+
msg4b = MagicMock(); msg4b.content = "文件有2列: A, B"; msg4b.tool_calls = None
|
|
386
|
+
mock4.chat.completions.create.side_effect = [
|
|
387
|
+
MagicMock(choices=[MagicMock(message=msg4a)]),
|
|
388
|
+
MagicMock(choices=[MagicMock(message=msg4b)]),
|
|
389
|
+
]
|
|
390
|
+
a4 = AgentLoop(mock4, cwd=tmp)
|
|
391
|
+
r4 = a4.run("查看 test.xlsx")
|
|
392
|
+
check("有2列" in r4 or "2 列" in r4 or "A, B" in r4, f"工具调用后返回(实际 {r4[:60]})")
|
|
393
|
+
import shutil; shutil.rmtree(tmp, ignore_errors=True)
|
|
394
|
+
print()
|
|
395
|
+
|
|
396
|
+
# ── 5. reset_task 清空记忆 ──
|
|
397
|
+
print("5. reset_task → 清空记忆")
|
|
398
|
+
agent.memory.reset_task()
|
|
399
|
+
check(len(agent.memory.messages) == 0, f"记忆已清空(实际 {len(agent.memory.messages)})")
|
|
400
|
+
print()
|
|
401
|
+
|
|
402
|
+
# ── 6. 超轮次终止 ──
|
|
403
|
+
print("6. 超轮次终止")
|
|
404
|
+
mock6 = MagicMock(); mock6.model = "mock"
|
|
405
|
+
tc6 = MagicMock(); tc6.id = "loop"; tc6.function.name = "read_excel_info"
|
|
406
|
+
tc6.function.arguments = json.dumps({"filepath": "x.xlsx"})
|
|
407
|
+
lm = MagicMock(); lm.content = None; lm.tool_calls = [tc6]
|
|
408
|
+
mock6.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=lm)])
|
|
409
|
+
a6 = AgentLoop(mock6, cwd="/tmp")
|
|
410
|
+
r6 = a6.run("test")
|
|
411
|
+
check("已执行" in r6, f"超轮次终止(实际 {r6[:60]})")
|
|
412
|
+
print()
|
|
413
|
+
|
|
414
|
+
print(f"=== 结果: {passed} passed, {failed} failed ===")
|