prompt-copilot-cli 0.1.1__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.
main.py ADDED
@@ -0,0 +1,1684 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import base64
6
+ import json
7
+ import logging
8
+ import os
9
+ import platform
10
+ import shutil
11
+ import signal
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import traceback
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from mcp.client.session import ClientSession
21
+ from mcp.client.sse import sse_client
22
+ from mcp.client.stdio import StdioServerParameters, stdio_client
23
+ from mcp.client.streamable_http import streamablehttp_client
24
+ from openai import OpenAI
25
+ from prompt_toolkit import PromptSession
26
+ from prompt_toolkit.completion import Completion, Completer
27
+ from prompt_toolkit.history import FileHistory
28
+ from rich.console import Console
29
+ from rich.panel import Panel
30
+ from types import SimpleNamespace
31
+
32
+
33
+ ROOT = Path.home() / ".prompt-copilot"
34
+ ROOT.mkdir(parents=True, exist_ok=True)
35
+
36
+ LAST_MODEL_CALL_COMPLETED_AT: float | None = None
37
+ TOTAL_TOKEN_USAGE: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
38
+
39
+
40
+ def wait_for_model_call_interval() -> None:
41
+ global LAST_MODEL_CALL_COMPLETED_AT
42
+ if LAST_MODEL_CALL_COMPLETED_AT is None:
43
+ return
44
+
45
+ elapsed = time.monotonic() - LAST_MODEL_CALL_COMPLETED_AT
46
+ if elapsed < RE_ACTION_DELAY:
47
+ time.sleep(RE_ACTION_DELAY - elapsed)
48
+
49
+
50
+ def mark_model_call_completed() -> None:
51
+ global LAST_MODEL_CALL_COMPLETED_AT
52
+ LAST_MODEL_CALL_COMPLETED_AT = time.monotonic()
53
+
54
+
55
+ def update_total_token_usage(usage: Any) -> None:
56
+ if usage is None:
57
+ return
58
+
59
+ for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
60
+ value = getattr(usage, key, None)
61
+ if isinstance(value, (int, float)):
62
+ TOTAL_TOKEN_USAGE[key] += int(value)
63
+
64
+
65
+ def format_usage_summary(usage: Any) -> str:
66
+ if usage is None:
67
+ return t("token_usage_unavailable")
68
+
69
+ prompt_tokens = getattr(usage, "prompt_tokens", None)
70
+ completion_tokens = getattr(usage, "completion_tokens", None)
71
+ total_tokens = getattr(usage, "total_tokens", None)
72
+
73
+ return (
74
+ f"{t('prompt_tokens_label')}={prompt_tokens if prompt_tokens is not None else '-'} | "
75
+ f"{t('completion_tokens_label')}={completion_tokens if completion_tokens is not None else '-'} | "
76
+ f"{t('total_tokens_label')}={total_tokens if total_tokens is not None else '-'}"
77
+ )
78
+
79
+
80
+ def format_cumulative_token_summary() -> str:
81
+ return (
82
+ f"{t('token_usage_label')} "
83
+ f"{t('prompt_tokens_label')}={TOTAL_TOKEN_USAGE['prompt_tokens']} | "
84
+ f"{t('completion_tokens_label')}={TOTAL_TOKEN_USAGE['completion_tokens']} | "
85
+ f"{t('total_tokens_label')}={TOTAL_TOKEN_USAGE['total_tokens']}"
86
+ )
87
+
88
+
89
+ def build_bottom_toolbar_text() -> str:
90
+ base = t("toolbar_help")
91
+ token_summary = format_cumulative_token_summary()
92
+ return f"{token_summary} | {base}"
93
+
94
+
95
+ TRANSLATIONS = {
96
+ "system_prompt": {
97
+ "zh": """你是一个基于 CLI 的编程 Agent,你的名字是 Jason Li,专注于使用文件、命令、Python 脚本工具完成开发任务。\n\n工作原则:\n1、先检查工作目录并理解需求。\n2、优先使用网络搜索类工具搜索网络实时信息,例如:天气、新闻、资讯等等。\n3、所有代码文件生成、编辑、删除等操作均在工作目录中执行。如果用户没有指定具体目录,默认工作目录为工程的根目录。\n4、如果需要,输出简洁的说明与下一步建议。\n5、若涉及删除系统文件或执行危险命令,必须先向用户确认后方可执行。\n6、当用户输入指令:/task-start 的时候;直接回复:请输入你的第一条初始提示。\n7、遇到信息盲区时,严禁主观臆测,请优先使用搜索工具补齐信息缺口,确保回答精准有据。\n8、如果用户想“读取/识别其他二进制文件”,你必须明确告知:我无法直接读取或理解通用二进制文件内容。\n9、如果用户想“查看图片内容”,应优先调用图片读取工具将图片转成 base64 或视觉消息格式,再将其发送给支持多模态的模型。\n10、对于图片类任务,优先使用图片工具而不是尝试直接读取二进制原始内容。""",
98
+ "en": """You are a CLI-based coding agent named Jason Li, focused on completing development tasks by using files, commands, and Python scripts.\n\nWorking principles:\n1. Inspect the working directory and understand the requirement first.\n2. Prefer using network search tools for real-time information such as weather, news, or other current topics.\n3. All code file creation, editing, and deletion operations are performed in the working directory. If the user does not specify a directory, the project root is used by default.\n4. If needed, provide concise explanations and next-step suggestions.\n5. If the task involves deleting system files or executing dangerous commands, ask for confirmation first.\n6. When the user enters /task-start, reply with: please enter your first initial prompt.\n7. When information is missing, do not speculate; prefer using search tools to fill the gap and keep the answer precise and evidence-based.\n8. If the user wants to read or recognize other binary files, you must clearly state that you cannot directly read or interpret generic binary file contents.\n9. If the user wants to inspect image content, you should first use the image-reading tool to convert the image into base64 or a visual-message format and then send it to a multimodal-capable model.\n10. For image tasks, prefer the image tool over attempting to read the raw binary contents directly.""",
99
+ },
100
+ "config_field_model": {"zh": "模型名称,例如 qwen2.5-7b-instruct", "en": "Model name, for example qwen2.5-7b-instruct"},
101
+ "config_field_base_url": {"zh": "OpenAI 兼容接口地址,例如 http://127.0.0.1:11434/v1", "en": "OpenAI-compatible base URL, for example http://127.0.0.1:11434/v1"},
102
+ "config_field_api_key": {"zh": "API Key;若是本地模型可填写任意非空字符串", "en": "API key; for a local model you can enter any non-empty string"},
103
+ "config_field_temperature": {"zh": "采样温度,取值通常在 0.0 到 1.0 之间", "en": "Sampling temperature, usually between 0.0 and 1.0"},
104
+ "config_field_debug": {"zh": "是否开启调试日志,建议保持 true", "en": "Whether to enable debug logging; true is recommended"},
105
+ "config_field_mcp": {"zh": "MCP 工具服务器配置对象,包含 enabled 和 servers", "en": "MCP tool-server configuration object containing enabled and servers"},
106
+ "read_file_desc": {"zh": "读取指定文件内容(文本模式)。", "en": "Read the contents of the specified file in text mode."},
107
+ "write_file_desc": {"zh": "写入或覆盖指定文件内容。", "en": "Write or overwrite the content of the specified file."},
108
+ "delete_file_desc": {"zh": "删除指定文件。", "en": "Delete the specified file."},
109
+ "create_directory_desc": {"zh": "创建目录(支持递归创建)。", "en": "Create a directory (supports recursive creation)."},
110
+ "delete_directory_desc": {"zh": "删除目录及其内容。", "en": "Delete a directory and its contents."},
111
+ "rename_path_desc": {"zh": "重命名或移动文件/目录。", "en": "Rename or move a file or directory."},
112
+ "copy_file_desc": {"zh": "复制文件。", "en": "Copy a file."},
113
+ "read_image_as_base64_desc": {"zh": "读取图片文件并将其编码为 base64。", "en": "Read an image file and encode it as base64."},
114
+ "list_dir_desc": {"zh": "列出目录中的文件和子目录。", "en": "List files and subdirectories in the given directory."},
115
+ "execute_command_desc": {"zh": "在指定目录下执行 shell 命令。", "en": "Execute a shell command in the specified directory."},
116
+ "execute_python_script_desc": {"zh": "执行一个 Python 脚本或一段 Python 代码。", "en": "Execute a Python script or a block of Python code."},
117
+ "path_desc": {"zh": "要读取的文件路径。", "en": "Path of the file to read."},
118
+ "write_path_desc": {"zh": "要写入的目标文件路径。", "en": "Target file path to write to."},
119
+ "content_desc": {"zh": "要写入的文件内容。", "en": "Content to write to the file."},
120
+ "list_dir_path_desc": {"zh": "要列出的目录路径。", "en": "Directory path to list."},
121
+ "list_dir_recursive_desc": {"zh": "是否递归遍历子目录。", "en": "Whether to recursively traverse subdirectories."},
122
+ "command_desc": {"zh": "要执行的命令。", "en": "Command to execute."},
123
+ "cwd_desc": {"zh": "执行命令的工作目录。", "en": "Working directory for the command."},
124
+ "script_desc": {"zh": "要执行的脚本内容。", "en": "Script content to execute."},
125
+ "script_cwd_desc": {"zh": "脚本执行工作目录。", "en": "Working directory for the script execution."},
126
+ "interrupt": {"zh": "用户中断", "en": "Interrupted by user"},
127
+ "not_detected": {"zh": "未检测到", "en": "Not detected"},
128
+ "device_environment": {"zh": "设备工程环境:", "en": "Device environment:"},
129
+ "device_time": {"zh": "当前设备时间:", "en": "Current device time:"},
130
+ "device_os": {"zh": "当前操作系统信息:", "en": "Current operating system info:"},
131
+ "device_software": {"zh": "当前设备软件环境信息:", "en": "Current software environment info:"},
132
+ "device_workdir": {"zh": "当前工作目录:", "en": "Current working directory:"},
133
+ "invalid_tool_args": {"zh": "工具参数不是有效 JSON,原始内容:%s", "en": "Tool arguments are not valid JSON. Raw content: %s"},
134
+ "config_header": {"zh": "配置字段说明:", "en": "Configuration field descriptions:"},
135
+ "config_file_read_error": {"zh": "配置文件读取失败,请检查 {path}: {error}", "en": "Failed to read config file. Please check {path}: {error}"},
136
+ "config_file_format_error": {"zh": "配置文件格式不正确,期望 JSON 对象: {path}", "en": "Config file format is invalid. Expected a JSON object: {path}"},
137
+ "config_incomplete": {"zh": "模型配置不完整,首次使用需正确配置模型才能够使用。\n请在 {path} 中完善以下字段:{fields}", "en": "The model configuration is incomplete. Please complete the required fields in {path} before using the agent.\nMissing fields: {fields}"},
138
+ "config_api_key_error": {"zh": "未配置 API Key,请先在 config/model_config.json 中填写 api_key。", "en": "API key is not configured. Please fill in the api_key field in the config file first."},
139
+ "mcp_discover_failed": {"zh": "发现 MCP 工具失败", "en": "Failed to discover MCP tools"},
140
+ "mcp_tool_not_found": {"zh": "未找到 MCP 工具对应的服务配置: {name}", "en": "No MCP server configuration found for tool: {name}"},
141
+ "mcp_tool_unavailable": {"zh": "MCP 工具不可用:{name},已忽略。", "en": "MCP tool unavailable: {name}; it will be ignored."},
142
+ "mcp_tool_failed": {"zh": "执行 MCP 工具失败", "en": "Failed to execute MCP tool"},
143
+ "tool_missing_arg": {"zh": "{name} 缺少 {arg} 参数。", "en": "{name} is missing the {arg} argument."},
144
+ "unknown_tool": {"zh": "未知工具: {name}", "en": "Unknown tool: {name}"},
145
+ "start_model_call": {"zh": "开始调用模型", "en": "Starting model call"},
146
+ "model_request_params": {"zh": "调试-模型请求参数", "en": "Debug - model request parameters"},
147
+ "model_call_finished": {"zh": "调用模型结束", "en": "Model call finished"},
148
+ "model_raw_response": {"zh": "调试-模型原始返回", "en": "Debug - raw model response"},
149
+ "cancelled": {"zh": "已取消", "en": "Cancelled"},
150
+ "model_cancelled": {"zh": "用户中断了当前模型调用。", "en": "The current model call was interrupted by the user."},
151
+ "model_error": {"zh": "调用模型时出错", "en": "Error while calling the model"},
152
+ "model_quota_error": {"zh": "模型调用失败:检测到配额/限流问题(可能是免费额度已耗尽或未开通付费)。\n请检查配置中的 api_key 与 base_url,或开通/充值服务后重试。", "en": "Model call failed: quota or rate-limit issue detected (the free quota may be exhausted or billing may not be enabled).\nPlease check the api_key and base_url in the config, or enable/top up the service and try again."},
153
+ "model_502_error": {"zh": "模型调用失败:上游模型服务返回了 502(Bad Gateway)或服务端异常。\n这通常是模型服务端暂时不可用、代理转发异常或服务正在重启造成的。\n请稍后重试,并确认 base_url 指向的是可正常提供 /chat/completions 的服务。", "en": "Model call failed: the upstream model service returned 502 (Bad Gateway) or a server-side error.\nThis is usually caused by a temporary outage, proxy forwarding issue, or service restart.\nPlease try again later and confirm that base_url points to a service that serves /chat/completions correctly."},
154
+ "model_call_failed": {"zh": "模型调用失败:{error}", "en": "Model call failed: {error}"},
155
+ "tool_result_title": {"zh": "工具调用结果", "en": "Tool execution result"},
156
+ "config_error_title": {"zh": "配置错误", "en": "Configuration error"},
157
+ "quota_hint": {"zh": "免费额度", "en": "free quota"},
158
+ "tool_execution": {"zh": "开始执行工具:{name},参数:{args}", "en": "Starting tool execution: {name}, args: {args}"},
159
+ "tool_result": {"zh": "{name} 结果:{result}", "en": "{name} result: {result}"},
160
+ "tool_subprocess_failed": {"zh": "子进程启动失败,cwd=%s,回退到 %s: %s", "en": "Failed to start subprocess in cwd=%s, falling back to %s: %s"},
161
+ "welcome_message": {
162
+ "zh": "输入:\n/exit 退出,\n/clear 清空本地会话,\n/task-start 开始任务上下文,\n/task-end 生成最终提示。\nCtrl+C 可中断当前执行。\n\n启动命令示例:\npython main.py -l zh\npython main.py -t \"你的任务\" -d ./workspace -l en\npython main.py --reset-session\n\n参数说明:\n-t / --task:一次性任务内容\n-d / --workdir:指定工作目录\n-l / --lang:选择语言(zh / en)\n-amc / --agent-messages-count:历史消息数量(推荐默认)\n-rd / --request-delay:请求间隔秒数(推荐默认)\n-hc / --history-count:会话轮次数量(推荐默认)\n--reset-session:重置本地会话记录。",
163
+ "en": "Input:\n/exit to exit,\n/clear to clear the local session,\n/task-start to start a task context,\n/task-end to generate the final prompt.\nCtrl+C can interrupt the current execution.\n\nStartup command examples:\npython main.py -l zh\npython main.py -t \"your task\" -d ./workspace -l en\npython main.py --reset-session\n\nOptions:\n-t / --task: one-off task content\n-d / --workdir: specify the working directory\n-l / --lang: choose language (zh / en)\n-amc / --agent-messages-count: number of messages to keep in agent history (default: 6)\n-rd / --request-delay: delay in seconds between model requests (default: 8)\n-hc / --history-count: number of rounds to keep in conversation history (default: 5)\n--reset-session: reset the local session history."
164
+ },
165
+ "startup_title": {"zh": "启动", "en": "Start"},
166
+ "exit_message": {"zh": "已退出。", "en": "Exited."},
167
+ "bye_message": {"zh": "再见。", "en": "Goodbye."},
168
+ "clear_session_message": {"zh": "会话记录与最近对话记录已清空。", "en": "Session history and recent conversation history have been cleared."},
169
+ "workdir_message": {"zh": "工作目录: {path}", "en": "Working directory: {path}"},
170
+ "cli_config_title": {"zh": "Cli 配置", "en": "CLI configuration"},
171
+ "mcp_connected": {"zh": "已接入 MCP 工具:{count} 个", "en": "Connected MCP tools: {count}"},
172
+ "mcp_config_title": {"zh": "MCP 配置", "en": "MCP configuration"},
173
+ "session_reset_message": {"zh": "会话记录已重置。", "en": "Session history has been reset."},
174
+ "debug_enabled_message": {"zh": "调试模式已开启,会输出模型请求参数与原始返回内容。", "en": "Debug mode is enabled; model request parameters and raw responses will be shown."},
175
+ "debug_config_title": {"zh": "调试配置", "en": "Debug configuration"},
176
+ "task_cancelled": {"zh": "已取消当前任务。", "en": "The current task has been cancelled."},
177
+ "interrupted_title": {"zh": "已中断", "en": "Interrupted"},
178
+ "runtime_error_title": {"zh": "运行时错误", "en": "Runtime error"},
179
+ "tool_execution_error": {"zh": "工具执行错误", "en": "Tool execution error"},
180
+ "task_end_error": {"zh": "Task End 错误", "en": "Task End error"},
181
+ "task_end_notice": {"zh": "Task End 提示", "en": "Task End notice"},
182
+ "task_end_file_missing": {"zh": "未找到对话记录文件:{path}", "en": "Conversation history file not found: {path}"},
183
+ "task_end_empty": {"zh": "对话记录为空或格式不正确。", "en": "Conversation history is empty or malformed."},
184
+ "task_end_no_task_start": {"zh": "未在 recent_conversations.md 中找到包含 /task-start 的轮次。", "en": "No round containing /task-start was found in recent_conversations.md."},
185
+ "task_end_generate_failed": {"zh": "生成失败", "en": "Generation failed"},
186
+ "task_end_no_prompt": {"zh": "模型未返回任何最终提示。", "en": "The model did not return a final prompt."},
187
+ "task_end_result": {"zh": "生成结果", "en": "Generation result"},
188
+ "task_end_completed": {"zh": "已生成最终提示并写入: {path}", "en": "Final prompt generated and written to: {path}"},
189
+ "task_end_done": {"zh": "Task End 完成", "en": "Task End complete"},
190
+ "starting_tool_call": {"zh": "开始调用工具", "en": "Starting tool call"},
191
+ "tool_call_finished": {"zh": "调用工具结束", "en": "Tool call finished"},
192
+ "current_tool_cancelled": {"zh": "已取消当前工具执行。", "en": "The current tool execution has been cancelled."},
193
+ "cli_parser_description": {"zh": "Prompt Copilot CLI 编程 Agent", "en": "Prompt Copilot CLI coding agent"},
194
+ "task_argument_help": {"zh": "一次性任务内容,适合单次执行。", "en": "One-off task content, suitable for a single run."},
195
+ "workdir_argument_help": {"zh": "工作目录。", "en": "Working directory."},
196
+ "reset_session_help": {"zh": "重置本地持久化会话记录。", "en": "Reset local persisted session history."},
197
+ "prompt_placeholder": {"zh": "你想让我做什么?> ", "en": "What do you want me to do? > "},
198
+ "toolbar_help": {"zh": "可用命令: /exit /clear /task-start /task-end | Tab 补全 | Ctrl+C 可中断当前执行", "en": "Available commands: /exit /clear /task-start /task-end | Tab completion | Ctrl+C can interrupt the current execution"},
199
+ "token_usage_label": {"zh": "累计 token", "en": "Cumulative tokens"},
200
+ "prompt_tokens_label": {"zh": "prompt", "en": "prompt"},
201
+ "completion_tokens_label": {"zh": "completion", "en": "completion"},
202
+ "total_tokens_label": {"zh": "total", "en": "total"},
203
+ "token_usage_unavailable": {"zh": "无 token 使用信息", "en": "No token usage information"},
204
+ "context_size_label": {"zh": "提交上下文大小", "en": "Submitted context size"},
205
+ "response_length_label": {"zh": "回复内容长度", "en": "Response content length"},
206
+ "recent_conversations_header": {"zh": "# 最近对话记录", "en": "# Recent conversations"},
207
+ "task_end_system_prompt": {"zh": "你是提示词优化专家(精通将对话记录转化为清晰、可执行的任务提示)。\n输入:首先是用户在输入 /task-start 后会给出第一条初始提示,随后是从该标记以来的对话记录(包括模型回复、工具调用与结果)。\n任务:阅读这些记录,理解用户起始提示与后续的澄清或更改,并结合这些信息生成一个改进后的、清晰且可直接用于执行的最终提示词。\n输出要求:只输出最终改进后的提示词文本,不要添加任何解释、元信息或注释。长度控制在 2000 字符以内。", "en": "You are a prompt-optimization expert skilled at turning conversation history into a clear and actionable task prompt.\nInput: first the user provides an initial prompt after /task-start, then the conversation history from that point onward including model replies, tool calls, and results.\nTask: read these records, understand the initial prompt and any follow-up clarifications or changes, and generate an improved final prompt that is clear, executable, and ready to use.\nOutput requirements: return only the final improved prompt text, with no explanation, metadata, or comments. Keep it within 2000 characters."},
208
+ "task_end_user_message": {"zh": "对话记录(从 /task-start 开始,按时间从旧到新):\n\n{compiled_text}", "en": "Conversation history (starting from /task-start, from oldest to newest):\n\n{compiled_text}"},
209
+ "task_end_generation_failed_log": {"zh": "调用模型生成最终提示失败", "en": "Failed to generate the final prompt with the model"},
210
+ "final_prompt_header": {"zh": "# 最终提示词", "en": "# Final prompt"},
211
+ }
212
+
213
+
214
+ def t(key: str, **kwargs: Any) -> str:
215
+ mapping = TRANSLATIONS.get(key, {})
216
+ text = mapping.get(UI_SYSTEM_LANGUAGE, mapping.get("en", key))
217
+ if kwargs:
218
+ return text.format(**kwargs)
219
+ return text
220
+
221
+
222
+ UI_SYSTEM_LANGUAGE = "en"
223
+ DEFAULT_SYSTEM_PROMPT = t("system_prompt")
224
+ WORKSPACE_DIR = ROOT / "workspace"
225
+ LOG_DIR = ROOT / "logs"
226
+ LOG_FILE = LOG_DIR / "agent_runtime.log"
227
+ DEFAULT_MAX_CHAT_COUNT = 5
228
+ CHAT_MESSAGE_MAX_COUNT = 6
229
+ CONFIG_SAVE_FILE_PATH = ROOT / "config.json"
230
+ RE_ACTION_DELAY = 5 # unit: seconds
231
+ TOOL_SUBPROCESS_TIMEOUT = 60 * 60 # 1 hour in seconds
232
+ DEFAULT_MODEL_CONFIG: dict[str, Any] = {
233
+ "model": "",
234
+ "base_url": "",
235
+ "api_key": "",
236
+ "temperature": 0.2,
237
+ "debug": False,
238
+ "mcp": {
239
+ "enabled": True,
240
+ "servers": [
241
+ # {
242
+ # "name": "bing",
243
+ # "command": "npx",
244
+ # "args": ["-y", "bing-cn-mcp"]
245
+ # },
246
+ # {
247
+ # "name": "open-websearch-http",
248
+ # "transport": "http",
249
+ # "url": "http://127.0.0.1:3000/mcp"
250
+ # }
251
+ ]
252
+ }
253
+ }
254
+
255
+
256
+ CONFIG_FIELD_DESCRIPTIONS = {
257
+ "model": t("config_field_model"),
258
+ "base_url": t("config_field_base_url"),
259
+ "api_key": t("config_field_api_key"),
260
+ "temperature": t("config_field_temperature"),
261
+ "debug": t("config_field_debug"),
262
+ "mcp": t("config_field_mcp"),
263
+ }
264
+
265
+ console = Console()
266
+
267
+ logger = logging.getLogger("cli_agent")
268
+ logger.setLevel(logging.INFO)
269
+ logger.propagate = False
270
+ if not logger.handlers:
271
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
272
+ file_handler = logging.FileHandler(LOG_FILE, encoding="utf-8")
273
+ file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
274
+ logger.addHandler(file_handler)
275
+ TOOL_DEFINITIONS = [
276
+ {
277
+ "type": "function",
278
+ "function": {
279
+ "name": "read_file",
280
+ "description": t("read_file_desc"),
281
+ "parameters": {
282
+ "type": "object",
283
+ "properties": {
284
+ "path": {"type": "string", "description": t("path_desc")}
285
+ },
286
+ "required": ["path"],
287
+ },
288
+ },
289
+ },
290
+ {
291
+ "type": "function",
292
+ "function": {
293
+ "name": "write_file",
294
+ "description": t("write_file_desc"),
295
+ "parameters": {
296
+ "type": "object",
297
+ "properties": {
298
+ "path": {"type": "string", "description": t("write_path_desc")},
299
+ "content": {"type": "string", "description": t("content_desc")},
300
+ },
301
+ "required": ["path", "content"],
302
+ },
303
+ },
304
+ },
305
+ {
306
+ "type": "function",
307
+ "function": {
308
+ "name": "delete_file",
309
+ "description": t("delete_file_desc"),
310
+ "parameters": {
311
+ "type": "object",
312
+ "properties": {
313
+ "path": {"type": "string", "description": t("path_desc")}
314
+ },
315
+ "required": ["path"],
316
+ },
317
+ },
318
+ },
319
+ {
320
+ "type": "function",
321
+ "function": {
322
+ "name": "create_directory",
323
+ "description": t("create_directory_desc"),
324
+ "parameters": {
325
+ "type": "object",
326
+ "properties": {
327
+ "path": {"type": "string", "description": t("path_desc")}
328
+ },
329
+ "required": ["path"],
330
+ },
331
+ },
332
+ },
333
+ {
334
+ "type": "function",
335
+ "function": {
336
+ "name": "delete_directory",
337
+ "description": t("delete_directory_desc"),
338
+ "parameters": {
339
+ "type": "object",
340
+ "properties": {
341
+ "path": {"type": "string", "description": t("path_desc")}
342
+ },
343
+ "required": ["path"],
344
+ },
345
+ },
346
+ },
347
+ {
348
+ "type": "function",
349
+ "function": {
350
+ "name": "rename_path",
351
+ "description": t("rename_path_desc"),
352
+ "parameters": {
353
+ "type": "object",
354
+ "properties": {
355
+ "old_path": {"type": "string", "description": t("path_desc")},
356
+ "new_path": {"type": "string", "description": t("write_path_desc")}
357
+ },
358
+ "required": ["old_path", "new_path"],
359
+ },
360
+ },
361
+ },
362
+ {
363
+ "type": "function",
364
+ "function": {
365
+ "name": "copy_file",
366
+ "description": t("copy_file_desc"),
367
+ "parameters": {
368
+ "type": "object",
369
+ "properties": {
370
+ "source_path": {"type": "string", "description": t("path_desc")},
371
+ "destination_path": {"type": "string", "description": t("write_path_desc")}
372
+ },
373
+ "required": ["source_path", "destination_path"],
374
+ },
375
+ },
376
+ },
377
+ {
378
+ "type": "function",
379
+ "function": {
380
+ "name": "read_image_as_base64",
381
+ "description": t("read_image_as_base64_desc"),
382
+ "parameters": {
383
+ "type": "object",
384
+ "properties": {
385
+ "path": {"type": "string", "description": t("path_desc")}
386
+ },
387
+ "required": ["path"],
388
+ },
389
+ },
390
+ },
391
+ {
392
+ "type": "function",
393
+ "function": {
394
+ "name": "list_dir",
395
+ "description": t("list_dir_desc"),
396
+ "parameters": {
397
+ "type": "object",
398
+ "properties": {
399
+ "path": {"type": "string", "description": t("list_dir_path_desc")},
400
+ "recursive": {"type": "boolean", "description": t("list_dir_recursive_desc")}
401
+ },
402
+ "required": ["path"],
403
+ },
404
+ },
405
+ },
406
+ {
407
+ "type": "function",
408
+ "function": {
409
+ "name": "execute_command",
410
+ "description": t("execute_command_desc"),
411
+ "parameters": {
412
+ "type": "object",
413
+ "properties": {
414
+ "command": {"type": "string", "description": t("command_desc")},
415
+ "cwd": {"type": "string", "description": t("cwd_desc")},
416
+ },
417
+ "required": ["command", "cwd"],
418
+ },
419
+ },
420
+ },
421
+ {
422
+ "type": "function",
423
+ "function": {
424
+ "name": "execute_python_script",
425
+ "description": t("execute_python_script_desc"),
426
+ "parameters": {
427
+ "type": "object",
428
+ "properties": {
429
+ "script": {"type": "string", "description": t("script_desc")},
430
+ "cwd": {"type": "string", "description": t("script_cwd_desc")},
431
+ },
432
+ "required": ["script", "cwd"],
433
+ },
434
+ },
435
+ },
436
+ ]
437
+ ACTIVE_MCP_TOOL_DEFINITIONS: list[dict[str, Any]] = []
438
+ ACTIVE_MCP_TOOL_CONFIG: dict[str, Any] = {}
439
+ ACTIVE_MCP_TOOL_CONFIGS: list[dict[str, Any]] = []
440
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME: dict[str, dict[str, Any]] = {}
441
+ INTERRUPTION_REQUESTED = False
442
+
443
+
444
+ def handle_sigint(signum: int, frame: Any) -> None:
445
+ global INTERRUPTION_REQUESTED
446
+ INTERRUPTION_REQUESTED = True
447
+ raise KeyboardInterrupt(t("interrupt"))
448
+
449
+
450
+ def ensure_not_interrupted() -> None:
451
+ if INTERRUPTION_REQUESTED:
452
+ raise KeyboardInterrupt(t("interrupt"))
453
+
454
+
455
+ def reset_interruption_state() -> None:
456
+ global INTERRUPTION_REQUESTED
457
+ INTERRUPTION_REQUESTED = False
458
+
459
+
460
+ class SessionStore:
461
+ def __init__(self, session_file: Path, max_messages: int = DEFAULT_MAX_CHAT_COUNT):
462
+ self.session_file = session_file
463
+ self.max_messages = max_messages
464
+ self.session_file.parent.mkdir(parents=True, exist_ok=True)
465
+ if not self.session_file.exists():
466
+ self.session_file.write_text(json.dumps([], ensure_ascii=False), encoding="utf-8")
467
+
468
+ def load(self) -> list[dict[str, Any]]:
469
+ try:
470
+ data = json.loads(self.session_file.read_text(encoding="utf-8"))
471
+ if isinstance(data, list):
472
+ return data
473
+ except Exception:
474
+ pass
475
+ return []
476
+
477
+ def save(self, history: list[dict[str, Any]]) -> None:
478
+ if len(history) > self.max_messages:
479
+ history = history[-self.max_messages:]
480
+ self.session_file.write_text(json.dumps(history, ensure_ascii=False), encoding="utf-8")
481
+
482
+
483
+ class SlashCommandCompleter(Completer):
484
+ def __init__(self, commands: list[str]):
485
+ self.commands = commands
486
+
487
+ def get_completions(self, document, complete_event):
488
+ text = document.text_before_cursor.lstrip()
489
+ if not text.startswith("/"):
490
+ return
491
+
492
+ prefix = text
493
+ if " " in text:
494
+ return
495
+
496
+ for command in self.commands:
497
+ if command.startswith(prefix):
498
+ yield Completion(command, start_position=-len(prefix))
499
+
500
+
501
+ def get_version_from_command(command: list[str]) -> str:
502
+ try:
503
+ result = subprocess.run(
504
+ command,
505
+ capture_output=True,
506
+ text=True,
507
+ encoding='utf-8',
508
+ errors='replace',
509
+ timeout=10,
510
+ )
511
+ stdout = result.stdout or ''
512
+ output = stdout.strip().splitlines()[0] if stdout.strip() else ''
513
+ return output or t("not_detected")
514
+ except Exception:
515
+ return t("not_detected")
516
+
517
+
518
+ def build_device_environment_context(workdir: str) -> str:
519
+ now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
520
+ os_name = platform.system() or t("unknown") if False else platform.system() or 'Unknown'
521
+ os_release = platform.release() or 'Unknown'
522
+ os_version = platform.version() or 'Unknown'
523
+ os_arch = platform.machine() or 'Unknown'
524
+ python_version = platform.python_version() or sys.version.split()[0]
525
+ node_path = shutil.which('node')
526
+ node_version = get_version_from_command(['node', '--version']) if node_path else t("not_detected")
527
+ npm_version = get_version_from_command(['npm', '--version']) if shutil.which('npm') else t("not_detected")
528
+
529
+ return (
530
+ t("device_environment") + "\n"
531
+ + t("device_time") + now + "\n"
532
+ + t("device_os")
533
+ + f"system={os_name}, release={os_release}, version={os_version}, arch={os_arch}\n"
534
+ + t("device_software")
535
+ + f"python={python_version}, node={node_version}, npm={npm_version}\n"
536
+ + t("device_workdir") + workdir
537
+ )
538
+
539
+
540
+ def safe_parse_tool_args(raw_arguments: Any) -> dict[str, Any]:
541
+ if isinstance(raw_arguments, dict):
542
+ return raw_arguments
543
+ if isinstance(raw_arguments, str):
544
+ try:
545
+ parsed = json.loads(raw_arguments)
546
+ return parsed if isinstance(parsed, dict) else {}
547
+ except Exception:
548
+ logger.warning(t("invalid_tool_args"), raw_arguments)
549
+ return {}
550
+ return {}
551
+
552
+
553
+ def build_multimodal_user_message(text: str, image_path: str | os.PathLike[str]) -> dict[str, Any]:
554
+ path = Path(image_path).expanduser()
555
+ if not path.exists():
556
+ raise FileNotFoundError(f"Image file not found: {path}")
557
+
558
+ image_bytes = path.read_bytes()
559
+ encoded = base64.b64encode(image_bytes).decode("ascii")
560
+ mime_type = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
561
+ return {
562
+ "role": "user",
563
+ "content": [
564
+ {"type": "text", "text": text},
565
+ {
566
+ "type": "image_url",
567
+ "image_url": {
568
+ "url": f"data:{mime_type};base64,{encoded}",
569
+ },
570
+ },
571
+ ],
572
+ }
573
+
574
+
575
+ def build_multimodal_prompt_from_image(user_text: str, image_path: str | os.PathLike[str]) -> list[dict[str, Any]]:
576
+ return [build_multimodal_user_message(user_text, image_path)]
577
+
578
+
579
+ def resolve_execution_cwd(cwd: Any, fallback: str | os.PathLike[str] | None = None) -> str:
580
+ fallback_path = Path(fallback or Path.cwd()).expanduser()
581
+ if cwd in (None, "", "."):
582
+ return str(fallback_path)
583
+
584
+ candidate = Path(str(cwd)).expanduser()
585
+ if not candidate.is_absolute():
586
+ candidate = Path.cwd() / candidate
587
+
588
+ try:
589
+ resolved = candidate.resolve(strict=False)
590
+ except Exception:
591
+ return str(fallback_path)
592
+
593
+ if resolved.exists() and resolved.is_dir():
594
+ return str(resolved)
595
+
596
+ try:
597
+ resolved.mkdir(parents=True, exist_ok=True)
598
+ return str(resolved)
599
+ except Exception:
600
+ return str(fallback_path)
601
+
602
+
603
+ def _format_config_field_help() -> str:
604
+ lines = [t("config_header")]
605
+ for field_name, description in CONFIG_FIELD_DESCRIPTIONS.items():
606
+ lines.append(f"- {field_name}: {description}")
607
+ return "\n".join(lines)
608
+
609
+
610
+ def ensure_config(workdir: Path) -> tuple[dict[str, Any], str]:
611
+ CONFIG_SAVE_FILE_PATH.parent.mkdir(parents=True, exist_ok=True)
612
+
613
+ if not CONFIG_SAVE_FILE_PATH.exists():
614
+ default_payload = {
615
+ key: (value.copy() if isinstance(value, dict) else value)
616
+ for key, value in DEFAULT_MODEL_CONFIG.items()
617
+ }
618
+ CONFIG_SAVE_FILE_PATH.write_text(
619
+ json.dumps(default_payload, ensure_ascii=False, indent=2) + "\n",
620
+ encoding="utf-8",
621
+ )
622
+
623
+ try:
624
+ file_payload = json.loads(CONFIG_SAVE_FILE_PATH.read_text(encoding="utf-8"))
625
+ except Exception as exc:
626
+ raise RuntimeError(
627
+ t("config_file_read_error", path=CONFIG_SAVE_FILE_PATH, error=exc) + "\n\n"
628
+ + f"{_format_config_field_help()}"
629
+ ) from exc
630
+
631
+ if not isinstance(file_payload, dict):
632
+ raise RuntimeError(
633
+ t("config_file_format_error", path=CONFIG_SAVE_FILE_PATH) + "\n\n"
634
+ + f"{_format_config_field_help()}"
635
+ )
636
+
637
+ for key, value in file_payload.items():
638
+ if isinstance(value, dict) and isinstance(DEFAULT_MODEL_CONFIG.get(key), dict):
639
+ DEFAULT_MODEL_CONFIG[key] = value
640
+ else:
641
+ DEFAULT_MODEL_CONFIG[key] = value
642
+
643
+ required_fields = ["model", "base_url", "api_key"]
644
+ missing_fields = [field for field in required_fields if not str(DEFAULT_MODEL_CONFIG.get(field, "")).strip()]
645
+ if missing_fields:
646
+ raise RuntimeError(
647
+ t("config_incomplete", path=CONFIG_SAVE_FILE_PATH, fields=', '.join(missing_fields)) + "\n\n"
648
+ + f"{_format_config_field_help()}"
649
+ )
650
+
651
+ device_prompt = build_device_environment_context(str(workdir))
652
+ system_prompt = device_prompt + "\n\n" + DEFAULT_SYSTEM_PROMPT
653
+ return DEFAULT_MODEL_CONFIG, system_prompt
654
+
655
+
656
+ def build_client(model_cfg: dict[str, Any]) -> OpenAI:
657
+ api_key = model_cfg.get("api_key")
658
+ if not api_key:
659
+ raise RuntimeError(t("config_api_key_error"))
660
+ base_url = model_cfg.get("base_url")
661
+ kwargs: dict[str, Any] = {"api_key": api_key}
662
+ if base_url:
663
+ kwargs["base_url"] = base_url
664
+ return OpenAI(**kwargs)
665
+
666
+
667
+ def normalize_mcp_tool_definition(tool_obj: Any) -> dict[str, Any]:
668
+ schema = getattr(tool_obj, "inputSchema", None) or {}
669
+ parameters = dict(schema)
670
+ if not parameters.get("type"):
671
+ parameters["type"] = "object"
672
+ if parameters.get("type") == "object" and not parameters.get("properties"):
673
+ parameters["properties"] = {}
674
+
675
+ return {
676
+ "type": "function",
677
+ "function": {
678
+ "name": getattr(tool_obj, "name", ""),
679
+ "description": getattr(tool_obj, "description", "") or "",
680
+ "parameters": parameters,
681
+ },
682
+ }
683
+
684
+
685
+ async def _run_mcp_session(server_config: dict[str, Any], handler: Any) -> Any:
686
+ transport = str(server_config.get("transport") or server_config.get("type") or "stdio").lower()
687
+ if transport in {"http", "streamable_http", "streamable-http"}:
688
+ url = server_config.get("url")
689
+ headers = server_config.get("headers") or {}
690
+ if not url:
691
+ raise RuntimeError("MCP HTTP server is missing a URL")
692
+ async with streamablehttp_client(url, headers=headers) as (read, write, _):
693
+ async with ClientSession(read, write) as session:
694
+ await session.initialize()
695
+ return await handler(session)
696
+
697
+ if transport == "sse":
698
+ url = server_config.get("url")
699
+ headers = server_config.get("headers") or {}
700
+ if not url:
701
+ raise RuntimeError("MCP SSE server is missing a URL")
702
+ async with sse_client(url, headers=headers) as (read, write):
703
+ async with ClientSession(read, write) as session:
704
+ await session.initialize()
705
+ return await handler(session)
706
+
707
+ command = server_config.get("command") or "npx"
708
+ args = list(server_config.get("args") or ["-y", "bing-cn-mcp"])
709
+ server_params = StdioServerParameters(command=command, args=args)
710
+ async with stdio_client(server_params) as (read, write):
711
+ async with ClientSession(read, write) as session:
712
+ await session.initialize()
713
+ return await handler(session)
714
+
715
+
716
+ def normalize_mcp_server_config(raw_config: Any, fallback: dict[str, Any] | None = None) -> dict[str, Any]:
717
+ base = dict(fallback or {})
718
+ if isinstance(raw_config, dict):
719
+ merged = dict(base)
720
+ merged.update(raw_config)
721
+ transport = str(merged.get("transport") or merged.get("type") or base.get("transport") or "stdio").lower()
722
+ if transport in {"sse", "http", "streamable_http", "streamable-http"}:
723
+ headers = merged.get("headers") or base.get("headers") or {}
724
+ if isinstance(headers, dict):
725
+ headers = dict(headers)
726
+ else:
727
+ headers = {}
728
+ url = merged.get("url") or base.get("url") or ""
729
+ return {
730
+ "name": merged.get("name") or url or f"mcp-{transport}",
731
+ "transport": transport,
732
+ "url": url,
733
+ "headers": headers,
734
+ }
735
+
736
+ command = merged.get("command") or base.get("command") or "npx"
737
+ args_value = merged.get("args") or merged.get("arguments") or base.get("args") or ["-y", "bing-cn-mcp"]
738
+ if isinstance(args_value, str):
739
+ args = [args_value]
740
+ elif isinstance(args_value, list):
741
+ args = list(args_value)
742
+ else:
743
+ args = [str(args_value)]
744
+ return {
745
+ "name": merged.get("name") or f"{command}:{' '.join(args)}",
746
+ "transport": transport,
747
+ "command": command,
748
+ "args": args,
749
+ }
750
+
751
+ if isinstance(raw_config, str):
752
+ return {"name": raw_config, "transport": "stdio", "command": raw_config, "args": []}
753
+
754
+ return {"name": "mcp", "transport": "stdio", "command": "npx", "args": ["-y", "bing-cn-mcp"]}
755
+
756
+
757
+ def normalize_mcp_server_configs(mcp_cfg: Any) -> list[dict[str, Any]]:
758
+ if isinstance(mcp_cfg, list):
759
+ return [normalize_mcp_server_config(item) for item in mcp_cfg if item is not None]
760
+
761
+ if isinstance(mcp_cfg, dict):
762
+ if isinstance(mcp_cfg.get("servers"), list):
763
+ fallback = {
764
+ "command": mcp_cfg.get("command"),
765
+ "args": mcp_cfg.get("args"),
766
+ "transport": mcp_cfg.get("transport") or mcp_cfg.get("type"),
767
+ "url": mcp_cfg.get("url"),
768
+ "headers": mcp_cfg.get("headers"),
769
+ }
770
+ return [normalize_mcp_server_config(item, fallback=fallback) for item in mcp_cfg["servers"] if item is not None]
771
+ return [normalize_mcp_server_config(mcp_cfg)]
772
+
773
+ return []
774
+
775
+
776
+ def discover_mcp_tools(model_cfg: dict[str, Any]) -> list[dict[str, Any]]:
777
+ global ACTIVE_MCP_TOOL_DEFINITIONS, ACTIVE_MCP_TOOL_CONFIG, ACTIVE_MCP_TOOL_CONFIGS, ACTIVE_MCP_TOOL_SERVER_BY_NAME
778
+
779
+ mcp_cfg = model_cfg.get("mcp", {})
780
+ if isinstance(mcp_cfg, dict):
781
+ enabled = bool(mcp_cfg.get("enabled", True))
782
+ else:
783
+ enabled = True
784
+ if not enabled:
785
+ ACTIVE_MCP_TOOL_DEFINITIONS = []
786
+ ACTIVE_MCP_TOOL_CONFIG = {}
787
+ ACTIVE_MCP_TOOL_CONFIGS = []
788
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME = {}
789
+ return []
790
+
791
+ server_configs = normalize_mcp_server_configs(mcp_cfg)
792
+ if not server_configs:
793
+ ACTIVE_MCP_TOOL_DEFINITIONS = []
794
+ ACTIVE_MCP_TOOL_CONFIG = {}
795
+ ACTIVE_MCP_TOOL_CONFIGS = []
796
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME = {}
797
+ return []
798
+
799
+ ACTIVE_MCP_TOOL_CONFIGS = []
800
+ ACTIVE_MCP_TOOL_CONFIG = {}
801
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME = {}
802
+ ACTIVE_MCP_TOOL_DEFINITIONS = []
803
+
804
+ async def _discover_one(server_config: dict[str, Any]) -> list[dict[str, Any]]:
805
+ async def _handler(session: ClientSession) -> list[dict[str, Any]]:
806
+ tools = await session.list_tools()
807
+ return [normalize_mcp_tool_definition(tool) for tool in tools.tools]
808
+
809
+ return await _run_mcp_session(server_config, _handler)
810
+
811
+ try:
812
+ definitions: list[dict[str, Any]] = []
813
+ active_configs: list[dict[str, Any]] = []
814
+ for server_config in server_configs:
815
+ try:
816
+ discovered = asyncio.run(_discover_one(server_config))
817
+ definitions.extend(discovered)
818
+ active_configs.append(server_config)
819
+ for item in discovered:
820
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME[item["function"]["name"]] = server_config
821
+ except Exception:
822
+ logger.exception(t("mcp_discover_failed") + f", server={server_config.get('name')}")
823
+ console.print(Panel.fit(t("mcp_tool_unavailable", name=server_config.get("name") or str(server_config)), title=t("mcp_discover_failed")))
824
+
825
+ ACTIVE_MCP_TOOL_DEFINITIONS = definitions
826
+ ACTIVE_MCP_TOOL_CONFIGS = active_configs
827
+ ACTIVE_MCP_TOOL_CONFIG = active_configs[0] if active_configs else {}
828
+ logger.info("Discovered MCP tool definitions: %s", [item["function"]["name"] for item in definitions])
829
+ return definitions
830
+ except Exception:
831
+ logger.exception(t("mcp_discover_failed"))
832
+ ACTIVE_MCP_TOOL_DEFINITIONS = []
833
+ ACTIVE_MCP_TOOL_CONFIG = {}
834
+ ACTIVE_MCP_TOOL_CONFIGS = []
835
+ ACTIVE_MCP_TOOL_SERVER_BY_NAME = {}
836
+ return []
837
+
838
+
839
+ def run_mcp_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
840
+ server_config = ACTIVE_MCP_TOOL_SERVER_BY_NAME.get(name) or ACTIVE_MCP_TOOL_CONFIGS[0] or ACTIVE_MCP_TOOL_CONFIG
841
+ if not server_config:
842
+ return {"status": "error", "content": t("mcp_tool_not_found", name=name)}
843
+
844
+ async def _invoke() -> dict[str, Any]:
845
+ async def _handler(session: ClientSession) -> dict[str, Any]:
846
+ result = await session.call_tool(name, arguments or {})
847
+ serialized = result.model_dump(mode="json")
848
+ content = serialized.get("content")
849
+ if isinstance(content, list):
850
+ items = []
851
+ for item in content:
852
+ if isinstance(item, dict):
853
+ text = item.get("text")
854
+ if text is not None:
855
+ items.append(text)
856
+ else:
857
+ items.append(item)
858
+ else:
859
+ items.append(str(item))
860
+ normalized = items[0] if len(items) == 1 else items
861
+ else:
862
+ normalized = content
863
+ return {
864
+ "status": "error" if bool(serialized.get("isError")) else "ok",
865
+ "content": normalized,
866
+ }
867
+
868
+ return await _run_mcp_session(server_config, _handler)
869
+
870
+ try:
871
+ return asyncio.run(_invoke())
872
+ except Exception:
873
+ logger.exception(t("mcp_tool_failed") + f", tool={name}")
874
+ return {"status": "error", "content": traceback.format_exc()}
875
+
876
+
877
+ def run_subprocess_command(command: Any, cwd: str, shell: bool = False, timeout: int | None = None) -> tuple[int, str, str]:
878
+ safe_cwd = resolve_execution_cwd(cwd, Path.cwd())
879
+ try:
880
+ proc = subprocess.Popen(
881
+ command,
882
+ cwd=safe_cwd,
883
+ shell=shell,
884
+ stdout=subprocess.PIPE,
885
+ stderr=subprocess.PIPE,
886
+ text=True,
887
+ encoding="utf-8",
888
+ errors="replace",
889
+ )
890
+ except (FileNotFoundError, NotADirectoryError, OSError) as exc:
891
+ logger.warning(t("tool_subprocess_failed"), cwd, safe_cwd, exc)
892
+ proc = subprocess.Popen(
893
+ command,
894
+ cwd=str(Path.cwd()),
895
+ shell=shell,
896
+ stdout=subprocess.PIPE,
897
+ stderr=subprocess.PIPE,
898
+ text=True,
899
+ encoding="utf-8",
900
+ errors="replace",
901
+ )
902
+
903
+ try:
904
+ stdout, stderr = proc.communicate(timeout=timeout)
905
+ except subprocess.TimeoutExpired:
906
+ proc.terminate()
907
+ try:
908
+ proc.wait(timeout=5)
909
+ except subprocess.TimeoutExpired:
910
+ proc.kill()
911
+ proc.wait(timeout=5)
912
+ return -1, "", f"Command timed out after {timeout} seconds"
913
+ except KeyboardInterrupt:
914
+ proc.terminate()
915
+ try:
916
+ proc.wait(timeout=5)
917
+ except subprocess.TimeoutExpired:
918
+ proc.kill()
919
+ proc.wait(timeout=5)
920
+ raise
921
+ return proc.returncode, stdout or "", stderr or ""
922
+
923
+
924
+ def execute_tool_call(tool_call: Any) -> dict[str, Any]:
925
+ name = getattr(tool_call.function, "name", "")
926
+ args = safe_parse_tool_args(getattr(tool_call.function, "arguments", {}))
927
+ ensure_not_interrupted()
928
+ logger.info(t("tool_execution", name=name, args=args))
929
+
930
+ if name == "read_file":
931
+ file_path = args.get("path") or args.get("file_path")
932
+ if not file_path:
933
+ result = {"status": "error", "content": t("tool_missing_arg", name="read_file", arg="path")}
934
+ logger.error("read_file missing path argument, raw args: %s", args)
935
+ return result
936
+ path = Path(file_path).expanduser()
937
+ if not path.is_absolute():
938
+ path = Path.cwd() / path
939
+ result = {"status": "ok", "content": path.read_text(encoding="utf-8") if path.exists() else f"File does not exist: {path}"}
940
+ logger.info("read_file result: %s", result)
941
+ return result
942
+
943
+ if name == "write_file":
944
+ file_path = args.get("path") or args.get("file_path")
945
+ if not file_path:
946
+ result = {"status": "error", "content": t("tool_missing_arg", name="write_file", arg="path")}
947
+ logger.error("write_file missing path argument, raw args: %s", args)
948
+ return result
949
+ content = args.get("content", "")
950
+ path = Path(file_path).expanduser()
951
+ if not path.is_absolute():
952
+ path = Path.cwd() / path
953
+ path.parent.mkdir(parents=True, exist_ok=True)
954
+ path.write_text(content, encoding="utf-8")
955
+ result = {"status": "ok", "content": f"Written: {path}"}
956
+ logger.info("write_file result: %s", result)
957
+ return result
958
+
959
+ if name == "delete_file":
960
+ file_path = args.get("path") or args.get("file_path")
961
+ if not file_path:
962
+ result = {"status": "error", "content": t("tool_missing_arg", name="delete_file", arg="path")}
963
+ logger.error("delete_file missing path argument, raw args: %s", args)
964
+ return result
965
+ path = Path(file_path).expanduser()
966
+ if not path.is_absolute():
967
+ path = Path.cwd() / path
968
+ if not path.exists():
969
+ result = {"status": "error", "content": f"File does not exist: {path}"}
970
+ logger.info("delete_file result: %s", result)
971
+ return result
972
+ path.unlink()
973
+ result = {"status": "ok", "content": f"Deleted: {path}"}
974
+ logger.info("delete_file result: %s", result)
975
+ return result
976
+
977
+ if name == "create_directory":
978
+ file_path = args.get("path") or args.get("file_path")
979
+ if not file_path:
980
+ result = {"status": "error", "content": t("tool_missing_arg", name="create_directory", arg="path")}
981
+ logger.error("create_directory missing path argument, raw args: %s", args)
982
+ return result
983
+ path = Path(file_path).expanduser()
984
+ if not path.is_absolute():
985
+ path = Path.cwd() / path
986
+ path.mkdir(parents=True, exist_ok=True)
987
+ result = {"status": "ok", "content": f"Created directory: {path}"}
988
+ logger.info("create_directory result: %s", result)
989
+ return result
990
+
991
+ if name == "delete_directory":
992
+ file_path = args.get("path") or args.get("file_path")
993
+ if not file_path:
994
+ result = {"status": "error", "content": t("tool_missing_arg", name="delete_directory", arg="path")}
995
+ logger.error("delete_directory missing path argument, raw args: %s", args)
996
+ return result
997
+ path = Path(file_path).expanduser()
998
+ if not path.is_absolute():
999
+ path = Path.cwd() / path
1000
+ if not path.exists():
1001
+ result = {"status": "error", "content": f"Directory does not exist: {path}"}
1002
+ logger.info("delete_directory result: %s", result)
1003
+ return result
1004
+ shutil.rmtree(path)
1005
+ result = {"status": "ok", "content": f"Deleted directory: {path}"}
1006
+ logger.info("delete_directory result: %s", result)
1007
+ return result
1008
+
1009
+ if name == "rename_path":
1010
+ old_path = args.get("old_path")
1011
+ new_path = args.get("new_path")
1012
+ if not old_path or not new_path:
1013
+ result = {"status": "error", "content": t("tool_missing_arg", name="rename_path", arg="old_path/new_path")}
1014
+ logger.error("rename_path missing arguments, raw args: %s", args)
1015
+ return result
1016
+ old = Path(old_path).expanduser()
1017
+ new = Path(new_path).expanduser()
1018
+ if not old.is_absolute():
1019
+ old = Path.cwd() / old
1020
+ if not new.is_absolute():
1021
+ new = Path.cwd() / new
1022
+ if not old.exists():
1023
+ result = {"status": "error", "content": f"Path does not exist: {old}"}
1024
+ logger.info("rename_path result: %s", result)
1025
+ return result
1026
+ new.parent.mkdir(parents=True, exist_ok=True)
1027
+ old.rename(new)
1028
+ result = {"status": "ok", "content": f"Renamed: {old} -> {new}"}
1029
+ logger.info("rename_path result: %s", result)
1030
+ return result
1031
+
1032
+ if name == "copy_file":
1033
+ source_path = args.get("source_path")
1034
+ destination_path = args.get("destination_path")
1035
+ if not source_path or not destination_path:
1036
+ result = {"status": "error", "content": t("tool_missing_arg", name="copy_file", arg="source_path/destination_path")}
1037
+ logger.error("copy_file missing arguments, raw args: %s", args)
1038
+ return result
1039
+ source = Path(source_path).expanduser()
1040
+ destination = Path(destination_path).expanduser()
1041
+ if not source.is_absolute():
1042
+ source = Path.cwd() / source
1043
+ if not destination.is_absolute():
1044
+ destination = Path.cwd() / destination
1045
+ if not source.exists():
1046
+ result = {"status": "error", "content": f"Source file does not exist: {source}"}
1047
+ logger.info("copy_file result: %s", result)
1048
+ return result
1049
+ destination.parent.mkdir(parents=True, exist_ok=True)
1050
+ shutil.copy2(source, destination)
1051
+ result = {"status": "ok", "content": f"Copied: {source} -> {destination}"}
1052
+ logger.info("copy_file result: %s", result)
1053
+ return result
1054
+
1055
+ if name == "read_image_as_base64":
1056
+ file_path = args.get("path") or args.get("file_path")
1057
+ if not file_path:
1058
+ result = {"status": "error", "content": t("tool_missing_arg", name="read_image_as_base64", arg="path")}
1059
+ logger.error("read_image_as_base64 missing path argument, raw args: %s", args)
1060
+ return result
1061
+ path = Path(file_path).expanduser()
1062
+ if not path.is_absolute():
1063
+ path = Path.cwd() / path
1064
+ if not path.exists():
1065
+ result = {"status": "error", "content": f"Image file does not exist: {path}"}
1066
+ logger.info("read_image_as_base64 result: %s", result)
1067
+ return result
1068
+
1069
+ image_bytes = path.read_bytes()
1070
+ encoded = base64.b64encode(image_bytes).decode("ascii")
1071
+ result = {"status": "ok", "content": json.dumps({"path": str(path), "base64": encoded}, ensure_ascii=False)}
1072
+ logger.info("read_image_as_base64 result: %s", result)
1073
+ return result
1074
+
1075
+ if name == "list_dir":
1076
+ file_path = args.get("path") or args.get("file_path")
1077
+ if not file_path:
1078
+ result = {"status": "error", "content": t("tool_missing_arg", name="list_dir", arg="path")}
1079
+ logger.error("list_dir missing path argument, raw args: %s", args)
1080
+ return result
1081
+ path = Path(file_path).expanduser()
1082
+ if not path.is_absolute():
1083
+ path = Path.cwd() / path
1084
+ if not path.exists():
1085
+ result = {"status": "ok", "content": json.dumps([], ensure_ascii=False)}
1086
+ logger.info("list_dir result: %s", result)
1087
+ return result
1088
+
1089
+ recursive = bool(args.get("recursive", False))
1090
+ if recursive:
1091
+ items = []
1092
+ for item in sorted(path.rglob("*")):
1093
+ try:
1094
+ items.append(str(item.resolve()))
1095
+ except OSError:
1096
+ items.append(str(item))
1097
+ else:
1098
+ items = sorted([str(p) for p in path.iterdir()])
1099
+ result = {"status": "ok", "content": json.dumps(items, ensure_ascii=False)}
1100
+ logger.info("list_dir result: %s", result)
1101
+ return result
1102
+
1103
+ if name == "execute_command":
1104
+ command = args.get("command")
1105
+ if not command:
1106
+ result = {"status": "error", "content": t("tool_missing_arg", name="execute_command", arg="command")}
1107
+ logger.error("execute_command missing command argument, raw args: %s", args)
1108
+ return result
1109
+ cwd = resolve_execution_cwd(args.get("cwd"), Path.cwd())
1110
+ returncode, stdout, stderr = run_subprocess_command(command, cwd, shell=True, timeout=TOOL_SUBPROCESS_TIMEOUT)
1111
+ response = {
1112
+ "status": "ok" if returncode == 0 else "error",
1113
+ "content": stdout + stderr,
1114
+ "returncode": returncode,
1115
+ }
1116
+ logger.info("execute_command result: %s", response)
1117
+ return response
1118
+
1119
+ if name == "execute_python_script":
1120
+ script = args.get("script")
1121
+ if not script:
1122
+ result = {"status": "error", "content": t("tool_missing_arg", name="execute_python_script", arg="script")}
1123
+ logger.error("execute_python_script missing script argument, raw args: %s", args)
1124
+ return result
1125
+ cwd = resolve_execution_cwd(args.get("cwd"), Path.cwd())
1126
+ script_path = Path(cwd) / "__cli_temp_script__.py"
1127
+ script_path.parent.mkdir(parents=True, exist_ok=True)
1128
+ script_path.write_text(script, encoding="utf-8")
1129
+ returncode, stdout, stderr = run_subprocess_command([sys.executable, str(script_path)], cwd, timeout=TOOL_SUBPROCESS_TIMEOUT)
1130
+ response = {
1131
+ "status": "ok" if returncode == 0 else "error",
1132
+ "content": stdout + stderr,
1133
+ "returncode": returncode,
1134
+ }
1135
+ logger.info("execute_python_script result: %s", response)
1136
+ return response
1137
+
1138
+ if name in {item["function"]["name"] for item in ACTIVE_MCP_TOOL_DEFINITIONS}:
1139
+ logger.info("Executing MCP tool: %s, args: %s", name, args)
1140
+ return run_mcp_tool(name, args)
1141
+
1142
+ result = {"status": "error", "content": t("unknown_tool", name=name)}
1143
+ logger.error("Unknown tool call: %s", name)
1144
+ return result
1145
+
1146
+
1147
+ def sanitize_tool_result_for_display(result: dict[str, Any], max_len: int = 80) -> dict[str, Any]:
1148
+ """Return a shallow copy of result suitable for CLI display.
1149
+
1150
+ If the 'content' field is a string longer than max_len, replace it with
1151
+ a placeholder that indicates it's omitted. For lists, replace long string
1152
+ items similarly. This avoids dumping huge text blobs to the terminal.
1153
+ """
1154
+ try:
1155
+ display = dict(result)
1156
+ except Exception:
1157
+ return {"status": "error", "content": "<unserializable result>"}
1158
+
1159
+ content = display.get("content")
1160
+ if isinstance(content, str):
1161
+ if len(content) > max_len:
1162
+ display["content"] = f"<content omitted: length={len(content)} chars>"
1163
+ elif isinstance(content, list):
1164
+ new_list: list[Any] = []
1165
+ for item in content:
1166
+ if isinstance(item, str) and len(item) > max_len:
1167
+ new_list.append(f"<item omitted: length={len(item)} chars>")
1168
+ else:
1169
+ new_list.append(item)
1170
+ display["content"] = new_list
1171
+ return display
1172
+
1173
+
1174
+ def show_stage(title: str, content: str) -> None:
1175
+ # If the content includes a JSON-formatted tool result (passed as "...\nresult={json}")
1176
+ # attempt to sanitize its 'content' field before printing to avoid very long output.
1177
+ if "\nresult=" in content:
1178
+ prefix, json_part = content.split("\nresult=", 1)
1179
+ try:
1180
+ parsed = json.loads(json_part)
1181
+ sanitized = sanitize_tool_result_for_display(parsed)
1182
+ content = f"{prefix}\nresult={json.dumps(sanitized, ensure_ascii=False)}"
1183
+ except Exception:
1184
+ # If it's not valid JSON, leave it as-is
1185
+ pass
1186
+ console.print(Panel.fit(content, title=title))
1187
+
1188
+
1189
+ def show_tool_result(tool_call: Any, result: dict[str, Any]) -> None:
1190
+ display_result = sanitize_tool_result_for_display(result)
1191
+ console.print(Panel.fit(f"[bold cyan]{tool_call.function.name}[/bold cyan]\n{json.dumps(display_result, ensure_ascii=False, indent=2)}", title=t("tool_result_title")))
1192
+
1193
+
1194
+ def chat_once(client: OpenAI, model: str, messages: list[dict[str, Any]], temperature: float, debug_enabled: bool = False) -> Any:
1195
+ ensure_not_interrupted()
1196
+ wait_for_model_call_interval()
1197
+ tool_definitions = TOOL_DEFINITIONS + ACTIVE_MCP_TOOL_DEFINITIONS
1198
+ request_payload = {
1199
+ "model": model,
1200
+ "messages": messages,
1201
+ "temperature": temperature,
1202
+ "tools": tool_definitions,
1203
+ "tool_choice": "auto",
1204
+ }
1205
+
1206
+ context_payload = json.dumps(request_payload, ensure_ascii=False)
1207
+ context_size_chars = len(context_payload)
1208
+ context_size_bytes = len(context_payload.encode("utf-8"))
1209
+ show_stage(t("start_model_call"), f"model={model}\nmessages={len(messages)}\n{t('context_size_label')}={context_size_chars} chars / {context_size_bytes} bytes")
1210
+ if debug_enabled:
1211
+ show_stage(t("model_request_params"), json.dumps(request_payload, ensure_ascii=False, indent=2))
1212
+
1213
+ try:
1214
+ response = client.chat.completions.create(**request_payload)
1215
+ assistant_message = response.choices[0].message
1216
+ usage = getattr(response, "usage", None)
1217
+ if usage is not None:
1218
+ update_total_token_usage(usage)
1219
+
1220
+ response_content = assistant_message.content or ""
1221
+ response_length = len(str(response_content))
1222
+ usage_summary = format_usage_summary(usage)
1223
+ show_stage(
1224
+ t("model_call_finished"),
1225
+ f"model={model}\nresponse_type={type(assistant_message).__name__}\n{t('response_length_label')}={response_length}\n{t('token_usage_label')}={usage_summary}",
1226
+ )
1227
+ if debug_enabled:
1228
+ raw_response = response.model_dump_json(indent=2)
1229
+ show_stage(t("model_raw_response"), raw_response)
1230
+
1231
+ return assistant_message
1232
+ except KeyboardInterrupt:
1233
+ logger.info("Model call interrupted by user")
1234
+ show_stage(t("cancelled"), t("model_cancelled"))
1235
+ raise
1236
+ except Exception as exc:
1237
+ logger.exception(t("model_error"))
1238
+ err_text = str(exc)
1239
+ exc_name = type(exc).__name__
1240
+ status_code = getattr(exc, "status_code", None)
1241
+
1242
+ if "429" in err_text or "RateLimit" in exc_name or t("quota_hint") in err_text or "RESOURCES_TIPS" in err_text:
1243
+ user_msg = t("model_quota_error")
1244
+ elif status_code == 502 or "502" in err_text or "InternalServerError" in exc_name or "Bad Gateway" in err_text:
1245
+ user_msg = t("model_502_error")
1246
+ else:
1247
+ user_msg = t("model_call_failed", error=f"{exc_name}: {err_text}")
1248
+
1249
+ assistant_message = SimpleNamespace()
1250
+ assistant_message.tool_calls = []
1251
+ assistant_message.content = user_msg
1252
+ if debug_enabled:
1253
+ show_stage("Debug - model call error", traceback.format_exc())
1254
+ else:
1255
+ show_stage(t("model_call_failed", error=user_msg), user_msg)
1256
+ return assistant_message
1257
+ finally:
1258
+ mark_model_call_completed()
1259
+
1260
+
1261
+ class ConversationRecorder:
1262
+ def __init__(self, md_path: Path, max_rounds: int = 50):
1263
+ self.md_path = md_path
1264
+ self.max_rounds = max_rounds
1265
+ # ensure file exists
1266
+ self.md_path.parent.mkdir(parents=True, exist_ok=True)
1267
+ if not self.md_path.exists():
1268
+ self.md_path.write_text(t("recent_conversations_header") + "\n\n", encoding="utf-8")
1269
+
1270
+ def _append(self, text: str) -> None:
1271
+ # Append text to the file
1272
+ with self.md_path.open("a", encoding="utf-8") as f:
1273
+ f.write(text)
1274
+ self._trim_rounds()
1275
+
1276
+ def _trim_rounds(self) -> None:
1277
+ # Keep only the last self.max_rounds rounds (based on '## Round' headings)
1278
+ content = self.md_path.read_text(encoding="utf-8")
1279
+ parts = content.split("\n## Round ")
1280
+ if len(parts) <= self.max_rounds + 1:
1281
+ return
1282
+ # parts[0] is header before first round
1283
+ header = parts[0]
1284
+ rounds = parts[1:]
1285
+ keep = rounds[-self.max_rounds :]
1286
+ new_content = header + "\n## Round " + "\n## Round ".join(keep)
1287
+ self.md_path.write_text(new_content, encoding="utf-8")
1288
+
1289
+ def start_round(self, user_text: str) -> None:
1290
+ ts = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
1291
+ header = f"\n## Round {ts}\n\n"
1292
+ user_block = f"**User:**\n\n{user_text}\n\n"
1293
+ self._append(header + user_block)
1294
+
1295
+ def record_assistant(self, assistant_text: str) -> None:
1296
+ block = f"**Assistant:**\n\n{assistant_text}\n\n"
1297
+ self._append(block)
1298
+
1299
+ def record_tool_start(self, tool_name: str, args: Any) -> None:
1300
+ try:
1301
+ args_text = json.dumps(args, ensure_ascii=False)
1302
+ except Exception:
1303
+ args_text = str(args)
1304
+ block = f"**Tool Start:** {tool_name}\n\nArguments: {args_text}\n\n"
1305
+ self._append(block)
1306
+
1307
+ def record_tool_result(self, tool_name: str, result: dict[str, Any]) -> None:
1308
+ status = result.get("status")
1309
+ content = result.get("content")
1310
+ try:
1311
+ content_text = json.dumps(content, ensure_ascii=False)
1312
+ except Exception:
1313
+ content_text = str(content)
1314
+ block = f"**Tool Result:** {tool_name} (status={status})\n\n{content_text}\n\n"
1315
+ self._append(block)
1316
+
1317
+ def record_error(self, error_text: str) -> None:
1318
+ block = f"**Error:**\n\n{error_text}\n\n"
1319
+ self._append(block)
1320
+
1321
+
1322
+ def to_tool_call_objects(tool_calls: list[Any]) -> list[Any]:
1323
+ converted: list[Any] = []
1324
+ for idx, tool_call in enumerate(tool_calls):
1325
+ if isinstance(tool_call, SimpleNamespace):
1326
+ converted.append(tool_call)
1327
+ continue
1328
+
1329
+ function = getattr(tool_call, "function", None)
1330
+ if function is None:
1331
+ continue
1332
+
1333
+ converted.append(
1334
+ SimpleNamespace(
1335
+ id=getattr(tool_call, "id", f"tool_call_{idx}"),
1336
+ type=getattr(tool_call, "type", "function"),
1337
+ function=SimpleNamespace(
1338
+ name=getattr(function, "name", ""),
1339
+ arguments=getattr(function, "arguments", {}),
1340
+ ),
1341
+ )
1342
+ )
1343
+ return converted
1344
+
1345
+
1346
+ def parse_tool_calls_from_content(content: str | None) -> list[Any]:
1347
+ if not content:
1348
+ return []
1349
+
1350
+ text = content.strip()
1351
+ if not text:
1352
+ return []
1353
+
1354
+ decoded_calls: list[Any] = []
1355
+ start = 0
1356
+ while True:
1357
+ start = text.find("{", start)
1358
+ if start == -1:
1359
+ break
1360
+
1361
+ try:
1362
+ parsed, end = json.JSONDecoder().raw_decode(text[start:])
1363
+ except Exception:
1364
+ start += 1
1365
+ continue
1366
+
1367
+ if isinstance(parsed, dict) and {"name", "arguments"}.issubset(parsed.keys()):
1368
+ name = str(parsed.get("name") or "").strip()
1369
+ arguments = parsed.get("arguments") or {}
1370
+ if name:
1371
+ decoded_calls.append(
1372
+ SimpleNamespace(
1373
+ id=f"content_tool_{len(decoded_calls)}",
1374
+ type="function",
1375
+ function=SimpleNamespace(name=name, arguments=arguments),
1376
+ )
1377
+ )
1378
+ start += end
1379
+ continue
1380
+
1381
+ start += 1
1382
+
1383
+ return decoded_calls
1384
+
1385
+
1386
+ def run_agent(client: OpenAI, model: str, system_prompt: str, session_store: SessionStore, user_text: str, debug_enabled: bool = False, recorder: ConversationRecorder | None = None) -> None:
1387
+ reset_interruption_state()
1388
+ history = session_store.load()
1389
+ first_task_prompt = {"role": "user", "content": user_text}
1390
+ history.append(first_task_prompt)
1391
+ session_store.save(history)
1392
+ if recorder:
1393
+ recorder.start_round(user_text)
1394
+
1395
+ system_prompt_message = {"role": "system", "content": system_prompt}
1396
+ messages = []
1397
+ messages.extend(history)
1398
+
1399
+ while True:
1400
+ try:
1401
+ finalize_prompt = []
1402
+ messages = messages[-CHAT_MESSAGE_MAX_COUNT:] # Keep only the last N messages for context
1403
+ finalize_prompt.extend(messages)
1404
+ existing_first_prompt = first_task_prompt in messages
1405
+ if not existing_first_prompt:
1406
+ finalize_prompt = [first_task_prompt] + finalize_prompt
1407
+ finalize_prompt = [system_prompt_message] + finalize_prompt
1408
+ assistant_message = chat_once(client, model, finalize_prompt, temperature=0.2, debug_enabled=debug_enabled)
1409
+ except KeyboardInterrupt:
1410
+ logger.info("Current task interrupted by user")
1411
+ console.print(Panel.fit(t("task_cancelled"), title=t("interrupted_title")))
1412
+ if recorder:
1413
+ recorder.record_error("User interrupted the current task")
1414
+ return
1415
+ except Exception:
1416
+ logger.exception("Chat request failed")
1417
+ console.print(Panel.fit(traceback.format_exc(), title=t("runtime_error_title")))
1418
+ if recorder:
1419
+ recorder.record_error(traceback.format_exc())
1420
+ return
1421
+
1422
+ tool_calls = to_tool_call_objects(list(getattr(assistant_message, "tool_calls", []) or []))
1423
+ if not tool_calls:
1424
+ tool_calls = parse_tool_calls_from_content(getattr(assistant_message, "content", ""))
1425
+
1426
+ if not tool_calls:
1427
+ answer = assistant_message.content or "I did not receive a valid reply."
1428
+ history.append({"role": "assistant", "content": answer})
1429
+ session_store.save(history)
1430
+ console.print(Panel.fit(answer, title="Agent reply"))
1431
+ if recorder:
1432
+ recorder.record_assistant(answer)
1433
+ return
1434
+
1435
+ assistant_call_message = {
1436
+ "role": "assistant",
1437
+ "content": assistant_message.content or "",
1438
+ "tool_calls": [
1439
+ {
1440
+ "id": tc.id,
1441
+ "type": tc.type,
1442
+ "function": {
1443
+ "name": tc.function.name,
1444
+ "arguments": tc.function.arguments,
1445
+ },
1446
+ }
1447
+ for tc in tool_calls
1448
+ ],
1449
+ }
1450
+ messages.append(assistant_call_message)
1451
+ # if recorder:
1452
+ # recorder.record_assistant(assistant_message.content or "开始调用方法function calling...")
1453
+
1454
+ for tc in tool_calls:
1455
+ try:
1456
+ start_calling_tips = f"tool={tc.function.name}\narguments={tc.function.arguments}"
1457
+ show_stage(t("starting_tool_call"), start_calling_tips[:80])
1458
+ # if recorder:
1459
+ # recorder.record_tool_start(tc.function.name, tc.function.arguments)
1460
+ result = execute_tool_call(tc)
1461
+ end_calling_tips = f"tool={tc.function.name}\nresult={json.dumps(result, ensure_ascii=False)}"
1462
+ show_stage(t("tool_call_finished"), end_calling_tips[:80])
1463
+ show_tool_result(tc, result)
1464
+ messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False)})
1465
+ # if recorder:
1466
+ # recorder.record_tool_result(tc.function.name, result)
1467
+ except KeyboardInterrupt:
1468
+ logger.info("Tool execution interrupted by user, tool=%s", tc.function.name)
1469
+ console.print(Panel.fit(t("current_tool_cancelled"), title=t("interrupted_title")))
1470
+ # if recorder:
1471
+ # recorder.record_error(f"用户中断工具执行: {tc.function.name}")
1472
+ return
1473
+ except Exception:
1474
+ logger.exception("Tool execution error, tool=%s", tc.function.name)
1475
+ error_payload = {"status": "error", "content": traceback.format_exc()}
1476
+ messages.append({"role": "tool", "tool_call_id": tc.id, "content": json.dumps(error_payload, ensure_ascii=False)})
1477
+ console.print(Panel.fit(traceback.format_exc(), title=t("tool_execution_error")))
1478
+ # if recorder:
1479
+ # recorder.record_error(traceback.format_exc())
1480
+
1481
+
1482
+ def handle_task_end_command(md_path: Path, client: OpenAI, model: str, system_prompt: str, workdir: Path, debug_enabled: bool = False) -> None:
1483
+ """Process recent_conversations.md to find the most recent /task-start and use the
1484
+ subsequent rounds to ask the model to produce an improved prompt, then write last-prompt.md.
1485
+ """
1486
+ if not md_path.exists():
1487
+ console.print(Panel.fit(t("task_end_file_missing", path=md_path), title=t("task_end_error")))
1488
+ return
1489
+
1490
+ raw = md_path.read_text(encoding="utf-8")
1491
+ parts = raw.split("\n## Round ")
1492
+ if len(parts) <= 1:
1493
+ console.print(Panel.fit(t("task_end_empty"), title=t("task_end_error")))
1494
+ return
1495
+
1496
+ rounds = parts[1:]
1497
+
1498
+ # Parse user text for each round (simple extraction)
1499
+ def extract_user_text(part: str) -> str:
1500
+ marker = "**User:**"
1501
+ idx = part.find(marker)
1502
+ if idx == -1:
1503
+ return ""
1504
+ start = idx + len(marker)
1505
+ # skip leading whitespace/newlines
1506
+ sub = part[start:]
1507
+ # end at next heading like '\n\n**' or end of part
1508
+ end_idx = sub.find("\n\n**")
1509
+ if end_idx == -1:
1510
+ end_idx = len(sub)
1511
+ return sub[:end_idx].strip()
1512
+
1513
+ # Find most recent round index where user contains /task-start
1514
+ start_index = None
1515
+ for i in range(len(rounds) - 1, -1, -1):
1516
+ user_text = extract_user_text(rounds[i])
1517
+ if "/task-start" in user_text:
1518
+ start_index = i
1519
+ break
1520
+
1521
+ if start_index is None:
1522
+ console.print(Panel.fit(t("task_end_no_task_start"), title=t("task_end_notice")))
1523
+ return
1524
+
1525
+ selected = rounds[start_index:]
1526
+ compiled_text = "\n\n".join(["## Round " + r for r in selected])
1527
+
1528
+ # Build messages for the model
1529
+ sys_prompt = (
1530
+ t("task_end_system_prompt")
1531
+ )
1532
+
1533
+ user_msg = (
1534
+ t("task_end_user_message", compiled_text=compiled_text)
1535
+ )
1536
+
1537
+ try:
1538
+ assistant_message = chat_once(client, model, [{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}], temperature=0.2, debug_enabled=debug_enabled)
1539
+ except Exception:
1540
+ logger.exception(t("task_end_generation_failed_log"))
1541
+ console.print(Panel.fit(traceback.format_exc(), title=t("task_end_generate_failed")))
1542
+ return
1543
+
1544
+ final_prompt = (assistant_message.content or "").strip()
1545
+ if not final_prompt:
1546
+ console.print(Panel.fit(t("task_end_no_prompt"), title=t("task_end_result")))
1547
+ return
1548
+
1549
+ out_path = workdir / "last-prompt.md"
1550
+ out_text = t("final_prompt_header") + "\n\n" + final_prompt + "\n"
1551
+ out_path.write_text(out_text, encoding="utf-8")
1552
+ console.print(Panel.fit(t("task_end_completed", path=out_path), title=t("task_end_done")))
1553
+
1554
+
1555
+ def interactive_loop(client: OpenAI, model: str, system_prompt: str, session_store: SessionStore, history_file: Path, debug_enabled: bool = False, recorder: ConversationRecorder | None = None) -> None:
1556
+ console.print(Panel.fit(
1557
+ "[bold green]Prompt Pilot CLI Coding Agent[/bold green]\n"
1558
+ + t("welcome_message") + "\n",
1559
+ title=t("startup_title"),
1560
+ ))
1561
+
1562
+ slash_commands = ["/exit", "/clear", "/task-start", "/task-end"]
1563
+ session = PromptSession(
1564
+ history=FileHistory(str(history_file)),
1565
+ completer=SlashCommandCompleter(slash_commands),
1566
+ complete_while_typing=True,
1567
+ bottom_toolbar=build_bottom_toolbar_text,
1568
+ )
1569
+ while True:
1570
+ try:
1571
+ user_text = session.prompt(t("prompt_placeholder"))
1572
+ except KeyboardInterrupt:
1573
+ console.print("\n" + t("exit_message"))
1574
+ return
1575
+
1576
+ if user_text.strip() == "/exit":
1577
+ console.print(t("bye_message"))
1578
+ return
1579
+ if user_text.strip() == "/clear":
1580
+ session_store.save([])
1581
+ recent_conversations_path = ROOT / "recent_conversations.md"
1582
+ if recent_conversations_path.exists():
1583
+ recent_conversations_path.write_text(t("recent_conversations_header") + "\n\n", encoding="utf-8")
1584
+ console.print(t("clear_session_message"))
1585
+ continue
1586
+ if user_text.strip() == "/task-end":
1587
+ # Process recent_conversations.md and generate last-prompt.md
1588
+ md_path = recorder.md_path if recorder else (ROOT / "recent_conversations.md")
1589
+ handle_task_end_command(md_path, client, model, system_prompt, WORKSPACE_DIR, debug_enabled=debug_enabled)
1590
+ continue
1591
+
1592
+ run_agent(client, model, system_prompt, session_store, user_text, debug_enabled=debug_enabled, recorder=recorder)
1593
+
1594
+
1595
+ def build_cli_parser() -> argparse.ArgumentParser:
1596
+ parser = argparse.ArgumentParser(description=t("cli_parser_description"))
1597
+ parser.add_argument("-t", "--task", help=t("task_argument_help"))
1598
+ parser.add_argument("-d", "--workdir", default=WORKSPACE_DIR, help=t("workdir_argument_help"))
1599
+ parser.add_argument("-l", "--lang", default="en", help="language code for localization (default: en)")
1600
+ parser.add_argument("-amc", "--agent-messages-count", default=CHAT_MESSAGE_MAX_COUNT, help="number of messages to keep in agent history (default: 6)")
1601
+ parser.add_argument("-rd", "--request-delay", default=RE_ACTION_DELAY, help="delay in seconds between model requests (default: 8)")
1602
+ parser.add_argument("-hc", "--history-count", default=DEFAULT_MAX_CHAT_COUNT, help="number of rounds to keep in conversation history (default: 5)")
1603
+ parser.add_argument("--reset-session", action="store_true", help=t("reset_session_help"))
1604
+ return parser
1605
+
1606
+
1607
+ def main() -> None:
1608
+ global UI_SYSTEM_LANGUAGE, DEFAULT_SYSTEM_PROMPT, WORKSPACE_DIR, RE_ACTION_DELAY, DEFAULT_MAX_CHAT_COUNT, CHAT_MESSAGE_MAX_COUNT
1609
+
1610
+ parser = build_cli_parser()
1611
+ args = parser.parse_args()
1612
+
1613
+ RE_ACTION_DELAY = int(args.request_delay)
1614
+ DEFAULT_MAX_CHAT_COUNT = int(args.history_count)
1615
+ CHAT_MESSAGE_MAX_COUNT = int(args.agent_messages_count)
1616
+
1617
+ # Set localization language
1618
+ UI_SYSTEM_LANGUAGE = args.lang
1619
+ DEFAULT_SYSTEM_PROMPT = t("system_prompt")
1620
+
1621
+ # set up workspace directory
1622
+ WORKSPACE_DIR = Path(args.workdir)
1623
+ console.print(Panel.fit(t("workdir_message", path=WORKSPACE_DIR), title=t("cli_config_title")))
1624
+
1625
+ try:
1626
+ model_cfg, system_prompt = ensure_config(workdir=WORKSPACE_DIR)
1627
+ except RuntimeError as exc:
1628
+ console.print(Panel.fit(str(exc), title=t("config_error_title")))
1629
+ return
1630
+
1631
+ WORKSPACE_DIR.mkdir(parents=True, exist_ok=True)
1632
+ os.chdir(WORKSPACE_DIR)
1633
+
1634
+ signal.signal(signal.SIGINT, handle_sigint)
1635
+ if hasattr(signal, "SIGTERM"):
1636
+ signal.signal(signal.SIGTERM, handle_sigint)
1637
+
1638
+ mcp_tools = discover_mcp_tools(model_cfg)
1639
+ if mcp_tools:
1640
+ console.print(Panel.fit(t("mcp_connected", count=len(mcp_tools)), title=t("mcp_config_title")))
1641
+
1642
+ try:
1643
+ client = build_client(model_cfg)
1644
+ except RuntimeError as exc:
1645
+ console.print(Panel.fit(str(exc), title=t("config_error_title")))
1646
+ return
1647
+
1648
+ session_file = ROOT / ".session_history.json"
1649
+ history_file = ROOT / ".history"
1650
+
1651
+ session_store = SessionStore(session_file, max_messages=DEFAULT_MAX_CHAT_COUNT)
1652
+ if args.reset_session:
1653
+ session_store.save([])
1654
+ recent_conversations_path = ROOT / "recent_conversations.md"
1655
+ if recent_conversations_path.exists():
1656
+ recent_conversations_path.write_text(t("recent_conversations_header") + "\n\n", encoding="utf-8")
1657
+ console.print(t("session_reset_message"))
1658
+
1659
+ debug_enabled = bool(model_cfg.get("debug", False))
1660
+ if debug_enabled:
1661
+ console.print(Panel.fit(t("debug_enabled_message"), title=t("debug_config_title")))
1662
+
1663
+ # Create conversation recorder to persist recent rounds to markdown
1664
+ recorder = ConversationRecorder(ROOT / "recent_conversations.md", max_rounds=50)
1665
+
1666
+ if args.task:
1667
+ try:
1668
+ run_agent(client, model_cfg.get("model", "gpt-4o-mini"), system_prompt, session_store, args.task, debug_enabled=debug_enabled, recorder=recorder)
1669
+ except KeyboardInterrupt:
1670
+ console.print(Panel.fit(t("task_cancelled"), title=t("interrupted_title")))
1671
+ return
1672
+
1673
+ try:
1674
+ interactive_loop(client, model_cfg.get("model", "gpt-4o-mini"), system_prompt, session_store, history_file, debug_enabled=debug_enabled, recorder=recorder)
1675
+ except KeyboardInterrupt:
1676
+ console.print(Panel.fit(t("task_cancelled"), title=t("interrupted_title")))
1677
+
1678
+
1679
+ if __name__ == "__main__":
1680
+ try:
1681
+ main()
1682
+ except Exception:
1683
+ console.print(Panel.fit(traceback.format_exc(), title=t("runtime_error_title")))
1684
+ sys.exit(1)