mini-agent-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mini_agent/__init__.py +11 -0
- mini_agent/__main__.py +172 -0
- mini_agent/agent.py +275 -0
- mini_agent/config.py +78 -0
- mini_agent/images.py +143 -0
- mini_agent/llm.py +339 -0
- mini_agent/mcp.py +83 -0
- mini_agent/mini-agent.example.json +81 -0
- mini_agent/skills/mini-agent-config/SKILL.md +46 -0
- mini_agent/skills.py +138 -0
- mini_agent/tools.py +137 -0
- mini_agent_cli-0.2.0.dist-info/METADATA +161 -0
- mini_agent_cli-0.2.0.dist-info/RECORD +15 -0
- mini_agent_cli-0.2.0.dist-info/WHEEL +4 -0
- mini_agent_cli-0.2.0.dist-info/entry_points.txt +3 -0
mini_agent/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""mini-agent:终端里的编码 Agent。
|
|
2
|
+
|
|
3
|
+
config.py 读 mini-agent.json(全局 + 项目,深度合并)
|
|
4
|
+
llm.py 三种协议:openai-chat / openai-responses / anthropic
|
|
5
|
+
tools.py 内置工具:bash read write edit grep glob
|
|
6
|
+
mcp.py mcp 段 -> 连 MCP server
|
|
7
|
+
skills.py Agent Skills(SKILL.md)发现与加载
|
|
8
|
+
images.py 剪贴板 / 文件里的图 -> 多模态 content
|
|
9
|
+
agent.py 装配、权限、循环、上下文预算
|
|
10
|
+
__main__.py 命令行入口
|
|
11
|
+
"""
|
mini_agent/__main__.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""命令行入口。
|
|
2
|
+
|
|
3
|
+
mini-agent 交互模式
|
|
4
|
+
mini-agent -p "帮我看下这个仓库" 问一句就退出,适合脚本 / 管道
|
|
5
|
+
mini-agent -m bailian/qwen3.8-plus 换模型(<provider>/<model>,provider 来自配置)
|
|
6
|
+
mini-agent --yes ask 类操作全部放行(deny 仍然禁止)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import readline # noqa: F401 让 input() 支持方向键和历史
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from contextlib import AsyncExitStack
|
|
15
|
+
from importlib.metadata import version
|
|
16
|
+
|
|
17
|
+
from .agent import Agent
|
|
18
|
+
from .config import load
|
|
19
|
+
from .images import grab_clipboard, looks_like_image
|
|
20
|
+
from .llm import make_llm
|
|
21
|
+
|
|
22
|
+
HELP = """/img [路径] 挂一张图(不带路径从剪贴板取;也可以直接把图片文件拖进来)
|
|
23
|
+
/model [p/m] 看或切换模型(对话保留,可以跨厂商、跨协议)
|
|
24
|
+
/reload 重新读配置、skills、MCP(对话保留)
|
|
25
|
+
Ctrl+C 回答中:打断这一轮;输入时:清空当前行
|
|
26
|
+
Ctrl+D 退出
|
|
27
|
+
/clear 清空对话
|
|
28
|
+
/tools 看工具清单
|
|
29
|
+
exit 退出"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def switch_model(agent, ref):
|
|
33
|
+
try:
|
|
34
|
+
llm = make_llm(agent.config, ref)
|
|
35
|
+
except SystemExit as error:
|
|
36
|
+
print(f" {error}")
|
|
37
|
+
return
|
|
38
|
+
# 历史是中立格式:别家模型的回复会被重建成新协议能接受的样子(见 llm.py 开头)
|
|
39
|
+
agent.llm = llm
|
|
40
|
+
print(f" 已切换到 {llm.ref}({llm.api})")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Turn:
|
|
44
|
+
"""Ctrl+C 的去向:回答进行中就取消这一轮,否则按默认行为抛 KeyboardInterrupt。"""
|
|
45
|
+
task = agent = None
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def on_sigint(cls, signum, frame):
|
|
49
|
+
if cls.task and not cls.task.done() and not (cls.agent and cls.agent.prompting):
|
|
50
|
+
cls.task.get_loop().call_soon_threadsafe(cls.task.cancel)
|
|
51
|
+
return
|
|
52
|
+
raise KeyboardInterrupt
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
async def run(cls, coroutine):
|
|
56
|
+
cls.task = asyncio.ensure_future(coroutine)
|
|
57
|
+
try:
|
|
58
|
+
return await cls.task
|
|
59
|
+
finally:
|
|
60
|
+
cls.task = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def repl(agent):
|
|
64
|
+
"""返回 "reload" 表示要重新装配,其余情况就是退出。"""
|
|
65
|
+
pending = []
|
|
66
|
+
while True:
|
|
67
|
+
# 输入在主线程里同步读:Ctrl+C 直接打断 input(),不会有卡在 stdin 上的后台线程
|
|
68
|
+
try:
|
|
69
|
+
line = input(f"\n❯ {f'[{len(pending)}图] ' if pending else ''}").strip()
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
continue
|
|
72
|
+
except EOFError:
|
|
73
|
+
print()
|
|
74
|
+
return None
|
|
75
|
+
if not line:
|
|
76
|
+
continue
|
|
77
|
+
command, _, argument = line.partition(" ")
|
|
78
|
+
argument = argument.strip()
|
|
79
|
+
if line in ("exit", "quit", "/exit"):
|
|
80
|
+
return None
|
|
81
|
+
if command == "/help":
|
|
82
|
+
print(HELP)
|
|
83
|
+
continue
|
|
84
|
+
if command == "/reload":
|
|
85
|
+
return "reload"
|
|
86
|
+
if command == "/clear":
|
|
87
|
+
agent.reset()
|
|
88
|
+
pending = []
|
|
89
|
+
print(" 已清空。")
|
|
90
|
+
continue
|
|
91
|
+
if command == "/tools":
|
|
92
|
+
print(agent.report())
|
|
93
|
+
continue
|
|
94
|
+
if command == "/model":
|
|
95
|
+
if argument:
|
|
96
|
+
switch_model(agent, argument)
|
|
97
|
+
else:
|
|
98
|
+
print(f" 当前 {agent.llm.ref};provider:{'、'.join(agent.config.get('provider', {}))}")
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# 绝对路径也是 / 开头,先认图片路径再认命令
|
|
102
|
+
image = looks_like_image(line)
|
|
103
|
+
if not image and command == "/img":
|
|
104
|
+
image = looks_like_image(argument) if argument else grab_clipboard()
|
|
105
|
+
if not image:
|
|
106
|
+
print(" 没取到图片。")
|
|
107
|
+
continue
|
|
108
|
+
if image:
|
|
109
|
+
pending.append(image)
|
|
110
|
+
print(f" 已挂上 {image.name},接着把问题打出来。")
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await Turn.run(agent.ask(line, pending))
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
print(" 已中断。", file=sys.stderr)
|
|
117
|
+
except Exception as error:
|
|
118
|
+
print(f"\n 请求失败:{type(error).__name__}: {error}", file=sys.stderr)
|
|
119
|
+
finally:
|
|
120
|
+
pending = []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def run(args):
|
|
124
|
+
signal.signal(signal.SIGINT, Turn.on_sigint)
|
|
125
|
+
config, sources = load()
|
|
126
|
+
agent = Turn.agent = Agent(config, make_llm(config, args.model), auto_yes=args.yes)
|
|
127
|
+
failure = None
|
|
128
|
+
while True:
|
|
129
|
+
async with AsyncExitStack() as stack:
|
|
130
|
+
await agent.build(stack)
|
|
131
|
+
if args.prompt:
|
|
132
|
+
try:
|
|
133
|
+
await Turn.run(agent.ask(args.prompt)) # 正文已经流式打到 stdout
|
|
134
|
+
except asyncio.CancelledError:
|
|
135
|
+
failure = "已中断"
|
|
136
|
+
except Exception as error:
|
|
137
|
+
# 不能在这里 raise:MCP 的 TaskGroup 会把它包成一大坨异常组
|
|
138
|
+
failure = f"请求失败:{type(error).__name__}: {error}"
|
|
139
|
+
break
|
|
140
|
+
print("配置 " + "、".join(map(str, sources)))
|
|
141
|
+
print(agent.report())
|
|
142
|
+
print("\n/help 看命令,exit 退出。")
|
|
143
|
+
action = await repl(agent)
|
|
144
|
+
if action != "reload":
|
|
145
|
+
break
|
|
146
|
+
try:
|
|
147
|
+
config, sources = load()
|
|
148
|
+
except SystemExit as error:
|
|
149
|
+
print(f" {error}\n 沿用旧配置。")
|
|
150
|
+
continue
|
|
151
|
+
agent.config, agent.permission = config, config.get("permission") or {}
|
|
152
|
+
# 当前模型在新配置里还在就沿用,否则回到配置里的默认模型
|
|
153
|
+
name = agent.llm.ref.partition("/")[0]
|
|
154
|
+
switch_model(agent, agent.llm.ref if name in config.get("provider", {}) else None)
|
|
155
|
+
if failure:
|
|
156
|
+
raise SystemExit(failure)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
parser = argparse.ArgumentParser(prog="mini-agent", description="终端里的编码 Agent")
|
|
161
|
+
parser.add_argument("-p", "--prompt", help="问一句就退出")
|
|
162
|
+
parser.add_argument("-m", "--model", help="<provider>/<model>,默认用配置里的 model")
|
|
163
|
+
parser.add_argument("-y", "--yes", action="store_true", help="ask 类操作全部放行")
|
|
164
|
+
parser.add_argument("-V", "--version", action="version", version=f"mini-agent {version('mini-agent-cli')}")
|
|
165
|
+
try:
|
|
166
|
+
asyncio.run(run(parser.parse_args()))
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
mini_agent/agent.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Agent = LLM + 工具 + 循环。另外管三件事:工具装配、权限、上下文预算。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import date
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .images import user_message
|
|
13
|
+
from .mcp import load_servers, open_session
|
|
14
|
+
from .skills import SKILL_SCHEMA, catalog, load_skills, skill_dirs
|
|
15
|
+
from .tools import TOOL_SCHEMAS, TOOLS, preview
|
|
16
|
+
|
|
17
|
+
MAX_TURNS = 50 # 一次提问里最多调几轮模型
|
|
18
|
+
MAX_TOOL_CHARS = 30_000 # 单个工具结果最多喂回这么多字符
|
|
19
|
+
MAX_HISTORY_CHARS = 400_000 # 整段历史超过这个数就从最旧的一轮开始丢
|
|
20
|
+
|
|
21
|
+
# stdout 只放回答正文,思考 / 工具进度 / 审批都走 stderr:-p 重定向到文件时只剩答案
|
|
22
|
+
DIM, RESET = ("\033[2m", "\033[0m") if sys.stderr.isatty() else ("", "")
|
|
23
|
+
|
|
24
|
+
# 外部 MCP 工具事先不知道有哪些,按动词判断:带副作用的归到 mcp 权限
|
|
25
|
+
RISKY = re.compile(
|
|
26
|
+
r"^(create|delete|remove|update|edit|write|set|put|post|patch|publish|send|upload|"
|
|
27
|
+
r"insert|drop|exec|execute|run|move|rename|kill|push|merge|deploy)", re.I)
|
|
28
|
+
|
|
29
|
+
SYSTEM_PROMPT = """你是运行在用户终端里的编码助手,用户是开发者。
|
|
30
|
+
工作目录:{cwd}
|
|
31
|
+
系统:{system} 今天:{today}
|
|
32
|
+
|
|
33
|
+
用工具完成任务,最后用中文简洁说明做了什么。
|
|
34
|
+
- 找文件用 glob,搜内容用 grep,看文件用 read,改文件优先 edit,其余用 bash
|
|
35
|
+
- 改代码前先读懂上下文,改动保持最小
|
|
36
|
+
- 操作被用户拒绝就换办法或直接说明,不要反复尝试同一件事"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Agent:
|
|
40
|
+
def __init__(self, config, llm, auto_yes=False):
|
|
41
|
+
self.config, self.llm, self.auto_yes = config, llm, auto_yes
|
|
42
|
+
self.permission = config.get("permission") or {}
|
|
43
|
+
self.schemas, self.routes, self.skills, self.notes = [], {}, {}, []
|
|
44
|
+
self.messages = []
|
|
45
|
+
self._stream = None # 正在输出的是 text 还是 thinking
|
|
46
|
+
self._interrupted = False
|
|
47
|
+
self.prompting = False # 正卡在审批的 input() 上:Ctrl+C 要直接打断它
|
|
48
|
+
|
|
49
|
+
# ---------------- 装配 ----------------
|
|
50
|
+
|
|
51
|
+
async def build(self, stack):
|
|
52
|
+
"""内置 > skill > MCP,重名时先注册的赢。routes[名字] = (类型, 执行者, 来源标签)。"""
|
|
53
|
+
self.schemas, self.routes, self.notes = [], {}, []
|
|
54
|
+
|
|
55
|
+
def claim(name, route, schema):
|
|
56
|
+
if name in self.routes:
|
|
57
|
+
self.notes.append(f"工具 {name} 重名,保留先注册的那个")
|
|
58
|
+
return
|
|
59
|
+
self.routes[name] = route
|
|
60
|
+
self.schemas.append(schema)
|
|
61
|
+
|
|
62
|
+
for schema in TOOL_SCHEMAS:
|
|
63
|
+
claim(schema["name"], ("local", TOOLS[schema["name"]], "内置"), schema)
|
|
64
|
+
|
|
65
|
+
self.skills, notes = load_skills()
|
|
66
|
+
self.notes += notes
|
|
67
|
+
if self.skills:
|
|
68
|
+
claim("skill", ("skill", self.skills, "skill"), SKILL_SCHEMA)
|
|
69
|
+
|
|
70
|
+
servers, notes = load_servers(self.config)
|
|
71
|
+
self.notes += notes
|
|
72
|
+
for server in servers:
|
|
73
|
+
try:
|
|
74
|
+
session = await open_session(stack, server)
|
|
75
|
+
tools = (await session.list_tools()).tools
|
|
76
|
+
except Exception as error:
|
|
77
|
+
# 一个 server 连不上只是少一批工具,不能拖垮整个 agent
|
|
78
|
+
self.notes.append(f"{server['name']}:连接失败({type(error).__name__}: {error})")
|
|
79
|
+
continue
|
|
80
|
+
for t in tools:
|
|
81
|
+
claim(t.name, ("mcp", session, server["name"]), {
|
|
82
|
+
"name": t.name, "description": t.description or "", "parameters": t.input_schema})
|
|
83
|
+
|
|
84
|
+
system = SYSTEM_PROMPT.format(cwd=os.getcwd(), system=platform.system(), today=date.today())
|
|
85
|
+
self.system = system + catalog(self.skills)
|
|
86
|
+
|
|
87
|
+
def reset(self):
|
|
88
|
+
self.messages = []
|
|
89
|
+
|
|
90
|
+
def report(self):
|
|
91
|
+
groups = {}
|
|
92
|
+
for name, (kind, _, label) in self.routes.items():
|
|
93
|
+
if kind != "skill": # skill 工具单独一行报数量
|
|
94
|
+
groups.setdefault(label, []).append(name)
|
|
95
|
+
lines = [f"模型 {self.llm.ref}({self.llm.api})"]
|
|
96
|
+
for label, names in groups.items():
|
|
97
|
+
shown = "、".join(names) if len(names) <= 8 else "、".join(names[:8]) + f" 等 {len(names)} 个"
|
|
98
|
+
lines.append(f"{label:<5} {shown}")
|
|
99
|
+
if self.skills:
|
|
100
|
+
lines.append(f"skills {len(self.skills)} 个,模型按需读取")
|
|
101
|
+
if self.notes:
|
|
102
|
+
lines += ["跳过:"] + [f" - {n}" for n in self.notes]
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
# ---------------- 权限 ----------------
|
|
106
|
+
|
|
107
|
+
def _safe_path(self, path):
|
|
108
|
+
"""工作目录和 skill 目录里的随便读,别处归 external。"""
|
|
109
|
+
target = Path(path).expanduser().resolve()
|
|
110
|
+
return any(target.is_relative_to(r.resolve()) for r in [Path.cwd(), *skill_dirs()])
|
|
111
|
+
|
|
112
|
+
def _category(self, kind, name, args):
|
|
113
|
+
if kind == "local":
|
|
114
|
+
if name == "bash":
|
|
115
|
+
return "bash"
|
|
116
|
+
if name in ("write", "edit"):
|
|
117
|
+
return "edit"
|
|
118
|
+
return None if self._safe_path(args.get("path", ".")) else "external"
|
|
119
|
+
if kind == "mcp" and RISKY.match(name):
|
|
120
|
+
return "mcp"
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def _question(self, category, name, args):
|
|
124
|
+
if category == "bash":
|
|
125
|
+
return f"执行:{args.get('command')}"
|
|
126
|
+
if category == "edit":
|
|
127
|
+
return f"{name} {args.get('path')}\n{preview(name, args)}"
|
|
128
|
+
if category == "external":
|
|
129
|
+
return f"{name} 工作目录外的 {args.get('path')}"
|
|
130
|
+
return f"调用 {name}({_clip(json.dumps(args, ensure_ascii=False), 300)})"
|
|
131
|
+
|
|
132
|
+
def _allowed(self, category, name, args):
|
|
133
|
+
"""返回 None 表示放行,否则是喂回模型的拒绝理由。"""
|
|
134
|
+
level = self.permission.get(category, "ask")
|
|
135
|
+
if level == "deny":
|
|
136
|
+
return f"配置里禁止了 {category} 类操作(permission.{category} = deny)。"
|
|
137
|
+
if level == "allow" or self.auto_yes:
|
|
138
|
+
return None
|
|
139
|
+
if not sys.stdin.isatty():
|
|
140
|
+
_log(" (非交互模式,自动拒绝;加 --yes 放行)")
|
|
141
|
+
return "非交互模式下自动拒绝了这次操作。"
|
|
142
|
+
# 在主线程里同步问:Ctrl+C 能直接打断 input(),不会留下一个还在读 stdin 的线程
|
|
143
|
+
_log(f" ⚠ {self._question(category, name, args)}")
|
|
144
|
+
self.prompting = True
|
|
145
|
+
try:
|
|
146
|
+
answer = input(" 允许?[y/N] ").strip().lower()
|
|
147
|
+
except (KeyboardInterrupt, EOFError):
|
|
148
|
+
_log()
|
|
149
|
+
self._interrupted = True
|
|
150
|
+
return "用户中断了这一轮。"
|
|
151
|
+
finally:
|
|
152
|
+
self.prompting = False
|
|
153
|
+
return None if answer == "y" else "用户拒绝了这次操作。"
|
|
154
|
+
|
|
155
|
+
# ---------------- 执行 ----------------
|
|
156
|
+
|
|
157
|
+
async def _run_tool(self, call):
|
|
158
|
+
name = call["name"]
|
|
159
|
+
try:
|
|
160
|
+
args = json.loads(call["arguments"] or "{}")
|
|
161
|
+
except json.JSONDecodeError as error:
|
|
162
|
+
return f"错误:参数不是合法 JSON({error})"
|
|
163
|
+
if name not in self.routes:
|
|
164
|
+
return f"错误:没有叫 {name} 的工具"
|
|
165
|
+
|
|
166
|
+
kind, target, label = self.routes[name]
|
|
167
|
+
_log(f" · [{label}] {name}({_clip(json.dumps(args, ensure_ascii=False), 160)})")
|
|
168
|
+
category = self._category(kind, name, args)
|
|
169
|
+
if category:
|
|
170
|
+
refusal = self._allowed(category, name, args)
|
|
171
|
+
if refusal:
|
|
172
|
+
return refusal
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
if kind == "local":
|
|
176
|
+
# 同步函数丢线程里,别卡住 MCP 的事件循环
|
|
177
|
+
return str(await asyncio.to_thread(target, **args))
|
|
178
|
+
if kind == "skill":
|
|
179
|
+
skill = target.get(args.get("name"))
|
|
180
|
+
return skill.render() if skill else f"没有叫 {args.get('name')} 的 skill"
|
|
181
|
+
result = await target.call_tool(name, args)
|
|
182
|
+
text = "\n".join(b.text for b in result.content if getattr(b, "text", None)).strip()
|
|
183
|
+
return ("错误:" if result.is_error else "") + (text or "(工具没有返回内容)")
|
|
184
|
+
except Exception as error:
|
|
185
|
+
# 异常当普通结果喂回去,让模型自己看到并调整
|
|
186
|
+
return f"错误:{type(error).__name__}: {error}"
|
|
187
|
+
|
|
188
|
+
async def ask(self, text, images=()):
|
|
189
|
+
"""流式输出到终端,返回最终回答。被取消(Ctrl+C)时历史保持可继续的状态。"""
|
|
190
|
+
self.messages.append(user_message(text, list(images)))
|
|
191
|
+
self._compact()
|
|
192
|
+
self._interrupted = False
|
|
193
|
+
self.prompting = False # 正卡在审批的 input() 上:Ctrl+C 要直接打断它
|
|
194
|
+
try:
|
|
195
|
+
for _ in range(MAX_TURNS):
|
|
196
|
+
reply = await self.llm.complete(self.system, self.messages, self.schemas, self._emit)
|
|
197
|
+
self._end_stream()
|
|
198
|
+
self.messages.append(reply)
|
|
199
|
+
if not reply["tool_calls"]:
|
|
200
|
+
return reply["text"]
|
|
201
|
+
|
|
202
|
+
for call in reply["tool_calls"]:
|
|
203
|
+
result = "用户中断了这一轮,没有执行。" if self._interrupted else await self._run_tool(call)
|
|
204
|
+
if len(result) > MAX_TOOL_CHARS:
|
|
205
|
+
result = result[:MAX_TOOL_CHARS] + f"\n…(结果共 {len(result)} 字符,已截断)"
|
|
206
|
+
self.messages.append({"role": "tool", "id": call["id"], "content": result})
|
|
207
|
+
if self._interrupted:
|
|
208
|
+
_log(" 已中断。")
|
|
209
|
+
return "(已中断)"
|
|
210
|
+
return f"(已达到 {MAX_TURNS} 轮工具调用上限,提前停止)"
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
self.llm.abort()
|
|
213
|
+
self._end_stream()
|
|
214
|
+
self._close_orphans()
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
def _close_orphans(self):
|
|
218
|
+
"""中断时可能留下「有调用没结果」的工具调用,各家接口都会拒绝这种历史。补上占位结果。"""
|
|
219
|
+
last = next((m for m in reversed(self.messages) if m["role"] == "assistant"), None)
|
|
220
|
+
if not last:
|
|
221
|
+
return
|
|
222
|
+
done = {m["id"] for m in self.messages if m["role"] == "tool"}
|
|
223
|
+
for call in last["tool_calls"]:
|
|
224
|
+
if call["id"] not in done:
|
|
225
|
+
self.messages.append({"role": "tool", "id": call["id"], "content": "用户中断了这一轮,没有执行。"})
|
|
226
|
+
|
|
227
|
+
def _emit(self, kind, delta):
|
|
228
|
+
"""流式增量:正文进 stdout,思考过程灰色进 stderr。"""
|
|
229
|
+
if kind != self._stream:
|
|
230
|
+
self._end_stream()
|
|
231
|
+
self._stream = kind
|
|
232
|
+
if kind == "thinking":
|
|
233
|
+
sys.stderr.write(DIM)
|
|
234
|
+
out = sys.stderr if kind == "thinking" else sys.stdout
|
|
235
|
+
out.write(delta)
|
|
236
|
+
out.flush()
|
|
237
|
+
|
|
238
|
+
def _end_stream(self):
|
|
239
|
+
if self._stream == "thinking":
|
|
240
|
+
sys.stderr.write(RESET + "\n")
|
|
241
|
+
sys.stderr.flush()
|
|
242
|
+
elif self._stream == "text":
|
|
243
|
+
sys.stdout.write("\n")
|
|
244
|
+
sys.stdout.flush()
|
|
245
|
+
self._stream = None
|
|
246
|
+
|
|
247
|
+
# ---------------- 上下文预算 ----------------
|
|
248
|
+
|
|
249
|
+
def _compact(self):
|
|
250
|
+
"""旧消息里的图片换成占位符;总量超预算就丢最旧的整轮。
|
|
251
|
+
|
|
252
|
+
按「用户消息」切,保证 tool_calls 和对应的 tool 结果一起走,
|
|
253
|
+
不然接口会报调用 id 对不上。
|
|
254
|
+
"""
|
|
255
|
+
for m in self.messages[:-1]:
|
|
256
|
+
if m["role"] == "user" and isinstance(m["content"], list):
|
|
257
|
+
text = next((p["text"] for p in m["content"] if p["type"] == "text"), "")
|
|
258
|
+
m["content"] = f"{text}\n[当时附了图片,已从历史中移除]"
|
|
259
|
+
|
|
260
|
+
def total():
|
|
261
|
+
return sum(len(json.dumps(m, ensure_ascii=False, default=str)) for m in self.messages)
|
|
262
|
+
|
|
263
|
+
while total() > MAX_HISTORY_CHARS:
|
|
264
|
+
users = [i for i, m in enumerate(self.messages) if m["role"] == "user" and i > 0]
|
|
265
|
+
if not users:
|
|
266
|
+
break
|
|
267
|
+
del self.messages[:users[0]]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _log(text=""):
|
|
271
|
+
print(text, file=sys.stderr, flush=True)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _clip(text, limit):
|
|
275
|
+
return text if len(text) <= limit else text[:limit] + "…"
|
mini_agent/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""读 mini-agent.json:全局一份,项目里可以再放一份覆盖。
|
|
2
|
+
|
|
3
|
+
~/.config/mini-agent/mini-agent.json 全局($MINI_AGENT_CONFIG 可改路径)
|
|
4
|
+
<从 git 根目录到当前目录>/mini-agent.json 项目,越靠近当前目录优先级越高
|
|
5
|
+
|
|
6
|
+
全局配置不存在时,从包里的 mini-agent.example.json 复制一份过去。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
GLOBAL = Path(os.getenv("MINI_AGENT_CONFIG", "~/.config/mini-agent/mini-agent.json")).expanduser()
|
|
16
|
+
EXAMPLE = Path(__file__).resolve().parent / "mini-agent.example.json" # 在包里,wheel 安装后也找得到
|
|
17
|
+
NAME = "mini-agent.json"
|
|
18
|
+
|
|
19
|
+
_ENV = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
20
|
+
_COMMENT = re.compile(r"^\s*//.*$", re.M) # 只认整行注释,URL 里的 // 不受影响
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingEnv(Exception):
|
|
24
|
+
"""配置引用了一个没设置的环境变量。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def project_dirs(start=None):
|
|
28
|
+
"""从 git 根目录到当前目录的每一层(根在前)。不在 git 里就只有当前目录。"""
|
|
29
|
+
here = Path(start or os.getcwd()).resolve()
|
|
30
|
+
chain = [here, *here.parents]
|
|
31
|
+
root = next((i for i, d in enumerate(chain) if (d / ".git").exists()), 0)
|
|
32
|
+
return list(reversed(chain[:root + 1]))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read(path):
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(_COMMENT.sub("", path.read_text(encoding="utf-8")))
|
|
38
|
+
except json.JSONDecodeError as error:
|
|
39
|
+
raise SystemExit(f"{path} 不是合法 JSON:{error}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def merge(base, override):
|
|
43
|
+
out = dict(base)
|
|
44
|
+
for key, value in override.items():
|
|
45
|
+
out[key] = merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else value
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load():
|
|
50
|
+
"""返回 (合并后的配置, 读到的文件列表)。"""
|
|
51
|
+
if not GLOBAL.exists():
|
|
52
|
+
GLOBAL.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
shutil.copy(EXAMPLE, GLOBAL)
|
|
54
|
+
print(f"已生成默认配置:{GLOBAL}")
|
|
55
|
+
config, sources = _read(GLOBAL), [GLOBAL]
|
|
56
|
+
for directory in project_dirs():
|
|
57
|
+
path = directory / NAME
|
|
58
|
+
if path.exists() and path != GLOBAL:
|
|
59
|
+
config = merge(config, _read(path))
|
|
60
|
+
sources.append(path)
|
|
61
|
+
return config, sources
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def expand(value):
|
|
65
|
+
"""把 {env:X} 换成环境变量的值,递归处理 list / dict。"""
|
|
66
|
+
def replace(match):
|
|
67
|
+
got = os.getenv(match.group(1))
|
|
68
|
+
if not got:
|
|
69
|
+
raise MissingEnv(match.group(1))
|
|
70
|
+
return got
|
|
71
|
+
|
|
72
|
+
if isinstance(value, str):
|
|
73
|
+
return _ENV.sub(replace, value)
|
|
74
|
+
if isinstance(value, list):
|
|
75
|
+
return [expand(v) for v in value]
|
|
76
|
+
if isinstance(value, dict):
|
|
77
|
+
return {k: expand(v) for k, v in value.items()}
|
|
78
|
+
return value
|
mini_agent/images.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""把一张图变成模型能吃的一段 content。
|
|
2
|
+
|
|
3
|
+
终端里的 input() 只能收字符串 —— 你按 Cmd+V 贴一张图,它什么都收不到,
|
|
4
|
+
因为图片在系统剪贴板里是二进制,根本不经过 stdin。
|
|
5
|
+
|
|
6
|
+
所以要绕一下:让 agent 自己去问系统剪贴板要那张图,落成文件,
|
|
7
|
+
再编码成 data URL 塞进 messages。这个文件干的就是这件事。
|
|
8
|
+
|
|
9
|
+
内部的中立格式(llm.py 再翻译成各协议自己的写法):
|
|
10
|
+
|
|
11
|
+
{"role": "user", "content": [
|
|
12
|
+
{"type": "text", "text": "这张图里是什么?"},
|
|
13
|
+
{"type": "image", "url": "data:image/png;base64,..."},
|
|
14
|
+
]}
|
|
15
|
+
|
|
16
|
+
单独跑它可以测剪贴板通不通:
|
|
17
|
+
|
|
18
|
+
uv run python -m mini_agent.images
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import mimetypes
|
|
23
|
+
import platform
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
|
|
30
|
+
|
|
31
|
+
# 超过这个大小就先缩一下再发。视网膜屏截图动不动几 MB,
|
|
32
|
+
# base64 之后还要再涨三分之一。
|
|
33
|
+
MAX_BYTES = 4 * 1024 * 1024
|
|
34
|
+
SHRINK_TO = 1600 # 长边像素
|
|
35
|
+
|
|
36
|
+
# macOS:AppleScript 把剪贴板里的图强制转成 PNG 写到文件
|
|
37
|
+
_OSASCRIPT = """
|
|
38
|
+
set outFile to POSIX file "{path}"
|
|
39
|
+
try
|
|
40
|
+
set imgData to the clipboard as «class PNGf»
|
|
41
|
+
on error
|
|
42
|
+
return "no-image"
|
|
43
|
+
end try
|
|
44
|
+
set fh to open for access outFile with write permission
|
|
45
|
+
set eof fh to 0
|
|
46
|
+
write imgData to fh
|
|
47
|
+
close access fh
|
|
48
|
+
return "ok"
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_TEMP = Path(tempfile.mkdtemp(prefix="mini_agent_img_"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def looks_like_image(token):
|
|
55
|
+
"""一段文本是不是一个图片文件路径。
|
|
56
|
+
|
|
57
|
+
终端里把文件拖进来,就会得到这样一个路径,空格是 \\ 转义的。
|
|
58
|
+
"""
|
|
59
|
+
path = Path(token.strip().strip("'\"").replace("\\ ", " ")).expanduser()
|
|
60
|
+
return path if path.suffix.lower() in IMAGE_EXT and path.is_file() else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def grab_clipboard():
|
|
64
|
+
"""从系统剪贴板取一张图,落成临时文件。取不到就返回 None。"""
|
|
65
|
+
target = _TEMP / f"clip_{len(list(_TEMP.iterdir()))}.png"
|
|
66
|
+
system = platform.system()
|
|
67
|
+
|
|
68
|
+
if system == "Darwin":
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["osascript", "-e", _OSASCRIPT.format(path=target)],
|
|
71
|
+
capture_output=True, text=True,
|
|
72
|
+
)
|
|
73
|
+
ok = result.stdout.strip() == "ok"
|
|
74
|
+
elif system == "Linux" and shutil.which("xclip"):
|
|
75
|
+
with open(target, "wb") as f:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
|
|
78
|
+
stdout=f, stderr=subprocess.DEVNULL,
|
|
79
|
+
)
|
|
80
|
+
ok = result.returncode == 0
|
|
81
|
+
else:
|
|
82
|
+
print(f" ({system} 上还没接剪贴板,用 /img <路径> 或者把文件拖进来)")
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
if not ok or not target.exists() or target.stat().st_size == 0:
|
|
86
|
+
target.unlink(missing_ok=True)
|
|
87
|
+
return None
|
|
88
|
+
return target
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def shrink(path):
|
|
92
|
+
"""太大就用 sips(macOS 自带)缩一下长边,缩不了就原样返回。"""
|
|
93
|
+
if path.stat().st_size <= MAX_BYTES or not shutil.which("sips"):
|
|
94
|
+
return path
|
|
95
|
+
smaller = _TEMP / f"small_{path.name}"
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["sips", "-Z", str(SHRINK_TO), str(path), "--out", str(smaller)],
|
|
98
|
+
capture_output=True,
|
|
99
|
+
)
|
|
100
|
+
if result.returncode != 0 or not smaller.exists():
|
|
101
|
+
return path
|
|
102
|
+
print(f" ({path.stat().st_size // 1024}KB 太大,长边缩到 {SHRINK_TO}px"
|
|
103
|
+
f" → {smaller.stat().st_size // 1024}KB)")
|
|
104
|
+
return smaller
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def to_data_url(path):
|
|
108
|
+
"""文件 -> data:image/png;base64,....
|
|
109
|
+
|
|
110
|
+
用 data URL 而不是上传拿链接,是因为它不需要任何外部存储 ——
|
|
111
|
+
图片跟着请求一起走,代价是 base64 会让体积涨 1/3。
|
|
112
|
+
"""
|
|
113
|
+
path = shrink(Path(path))
|
|
114
|
+
mime = mimetypes.guess_type(path.name)[0] or "image/png"
|
|
115
|
+
encoded = base64.b64encode(path.read_bytes()).decode()
|
|
116
|
+
return f"data:{mime};base64,{encoded}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def user_message(text, images):
|
|
120
|
+
"""拼一条用户消息。没有图就还是原来那个字符串,一点没变。"""
|
|
121
|
+
if not images:
|
|
122
|
+
return {"role": "user", "content": text}
|
|
123
|
+
content = [{"type": "text", "text": text or "看看这张图"}]
|
|
124
|
+
content += [{"type": "image", "url": to_data_url(p)} for p in images]
|
|
125
|
+
return {"role": "user", "content": content}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main():
|
|
129
|
+
print(f"平台:{platform.system()} 临时目录:{_TEMP}\n")
|
|
130
|
+
print("先随便截个图(Cmd+Shift+Ctrl+4 截到剪贴板),再跑这个脚本。\n")
|
|
131
|
+
|
|
132
|
+
path = grab_clipboard()
|
|
133
|
+
if not path:
|
|
134
|
+
print("剪贴板里没有图片。")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
print(f"拿到了:{path} {path.stat().st_size // 1024}KB")
|
|
138
|
+
url = to_data_url(path)
|
|
139
|
+
print(f"编码成 data URL:{url[:60]}... 共 {len(url) // 1024}KB")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|