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 +1684 -0
- prompt_copilot_cli-0.1.1.dist-info/METADATA +222 -0
- prompt_copilot_cli-0.1.1.dist-info/RECORD +7 -0
- prompt_copilot_cli-0.1.1.dist-info/WHEEL +5 -0
- prompt_copilot_cli-0.1.1.dist-info/entry_points.txt +2 -0
- prompt_copilot_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- prompt_copilot_cli-0.1.1.dist-info/top_level.txt +1 -0
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)
|