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/llm.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""三种协议:openai-chat / openai-responses / anthropic。流式,只用标准库发 HTTP。
|
|
2
|
+
|
|
3
|
+
内部历史用一种中立格式,每种协议各自负责「翻译过去、解析回来」:
|
|
4
|
+
|
|
5
|
+
{"role": "user", "content": "文字" 或 [{"type": "text", "text"}, {"type": "image", "url": data_url}]}
|
|
6
|
+
{"role": "assistant", "text", "thinking", "tool_calls": [{"id", "name", "arguments": JSON 字符串}],
|
|
7
|
+
"raw": 这一轮的协议原文, "api", "ref": 哪个协议、哪个模型产生的}
|
|
8
|
+
{"role": "tool", "id": 调用 id, "content": "结果"}
|
|
9
|
+
|
|
10
|
+
中途换模型(参考 pi-ai 的 cross-provider handoff):
|
|
11
|
+
同一个模型产生的 assistant 消息 → 回放 raw,thinking 签名、reasoning 项都不丢
|
|
12
|
+
别的模型产生的 → 只用中立字段重建:thinking 变成普通文本,签名丢掉,
|
|
13
|
+
工具调用 id 规范成各家都接受的字符集
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
|
|
23
|
+
from .config import MissingEnv, expand
|
|
24
|
+
|
|
25
|
+
TIMEOUT = 600
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LLMError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LLM:
|
|
33
|
+
def __init__(self, ref, api, base_url, api_key, model, options):
|
|
34
|
+
self.ref, self.api, self.base_url = ref, api, base_url.rstrip("/")
|
|
35
|
+
self.api_key, self.model, self.options = api_key, model, options
|
|
36
|
+
self._stop, self._response = threading.Event(), None
|
|
37
|
+
|
|
38
|
+
async def complete(self, system, messages, tools, emit):
|
|
39
|
+
"""emit(kind, 增量文本),kind 是 text / thinking。返回中立格式的 assistant 消息。"""
|
|
40
|
+
build, path, stream = PROTOCOLS[self.api]
|
|
41
|
+
body = {**build(self, system, messages, tools), "stream": True}
|
|
42
|
+
self._stop.clear()
|
|
43
|
+
reply = await asyncio.to_thread(self._run, path, body, stream(emit))
|
|
44
|
+
return {**reply, "api": self.api, "ref": self.ref}
|
|
45
|
+
|
|
46
|
+
def abort(self):
|
|
47
|
+
"""Ctrl+C 时从主线程调:让读流的线程尽快停下。"""
|
|
48
|
+
self._stop.set()
|
|
49
|
+
if self._response:
|
|
50
|
+
try:
|
|
51
|
+
self._response.close()
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def _run(self, path, body, acc):
|
|
56
|
+
headers = {"content-type": "application/json", "accept": "text/event-stream",
|
|
57
|
+
"authorization": f"Bearer {self.api_key}"}
|
|
58
|
+
if self.api == "anthropic":
|
|
59
|
+
headers |= {"x-api-key": self.api_key, "anthropic-version": "2023-06-01"}
|
|
60
|
+
request = urllib.request.Request(
|
|
61
|
+
self.base_url + path, data=json.dumps(body).encode(), headers=headers, method="POST")
|
|
62
|
+
try:
|
|
63
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
64
|
+
self._response = response
|
|
65
|
+
for raw in response:
|
|
66
|
+
if self._stop.is_set():
|
|
67
|
+
raise LLMError("已中断")
|
|
68
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
69
|
+
if not line.startswith("data:"):
|
|
70
|
+
continue
|
|
71
|
+
data = line[5:].strip()
|
|
72
|
+
if data == "[DONE]":
|
|
73
|
+
break
|
|
74
|
+
event = json.loads(data)
|
|
75
|
+
if isinstance(event.get("error"), dict) or event.get("type") == "error":
|
|
76
|
+
raise LLMError(json.dumps(event.get("error") or event, ensure_ascii=False)[:800])
|
|
77
|
+
acc.add(event)
|
|
78
|
+
except urllib.error.HTTPError as error:
|
|
79
|
+
raise LLMError(f"{error.code} {error.read().decode(errors='replace')[:800]}") from None
|
|
80
|
+
except urllib.error.URLError as error:
|
|
81
|
+
raise LLMError(f"连不上 {self.base_url}:{error.reason}") from None
|
|
82
|
+
except (OSError, ValueError) as error:
|
|
83
|
+
if self._stop.is_set():
|
|
84
|
+
raise LLMError("已中断") from None
|
|
85
|
+
raise
|
|
86
|
+
finally:
|
|
87
|
+
self._response = None
|
|
88
|
+
return acc.result()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _native(llm, m):
|
|
92
|
+
"""这条 assistant 消息是不是当前这个模型产生的 —— 是的话可以原样回放。"""
|
|
93
|
+
return m.get("api") == llm.api and m.get("ref") == llm.ref and m.get("raw") is not None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _plain(m):
|
|
97
|
+
"""别家模型的回复:thinking 变普通文本(签名只对原模型有效,丢掉)。"""
|
|
98
|
+
thinking = m.get("thinking") or ""
|
|
99
|
+
return (f"<thinking>\n{thinking}\n</thinking>\n\n" if thinking.strip() else "") + (m["text"] or "")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _parts(content, text_type, image):
|
|
103
|
+
if isinstance(content, str):
|
|
104
|
+
return [{"type": text_type, "text": content}]
|
|
105
|
+
return [{"type": text_type, "text": p["text"]} if p["type"] == "text" else image(p["url"]) for p in content]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------- openai-chat ----------------
|
|
109
|
+
|
|
110
|
+
def _chat_build(llm, system, messages, tools):
|
|
111
|
+
out = [{"role": "system", "content": system}]
|
|
112
|
+
for m in messages:
|
|
113
|
+
if m["role"] == "user":
|
|
114
|
+
content = m["content"] if isinstance(m["content"], str) else _parts(
|
|
115
|
+
m["content"], "text", lambda url: {"type": "image_url", "image_url": {"url": url}})
|
|
116
|
+
out.append({"role": "user", "content": content})
|
|
117
|
+
elif m["role"] == "assistant":
|
|
118
|
+
native = _native(llm, m)
|
|
119
|
+
msg = {"role": "assistant", "content": (m["text"] if native else _plain(m)) or None}
|
|
120
|
+
if native:
|
|
121
|
+
msg |= m["raw"] # DeepSeek / qwen 的 reasoning_content,工具调用中间轮要带回去
|
|
122
|
+
if m["tool_calls"]:
|
|
123
|
+
msg["tool_calls"] = [{"id": t["id"], "type": "function", "function": {
|
|
124
|
+
"name": t["name"], "arguments": t["arguments"]}} for t in m["tool_calls"]]
|
|
125
|
+
out.append(msg)
|
|
126
|
+
else:
|
|
127
|
+
out.append({"role": "tool", "tool_call_id": m["id"], "content": m["content"]})
|
|
128
|
+
body = {"model": llm.model, "messages": out, **llm.options}
|
|
129
|
+
if tools:
|
|
130
|
+
body["tools"] = [{"type": "function", "function": t} for t in tools]
|
|
131
|
+
return body
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _ChatStream:
|
|
135
|
+
def __init__(self, emit):
|
|
136
|
+
self.emit, self.text, self.thinking, self.calls = emit, [], [], {}
|
|
137
|
+
|
|
138
|
+
def add(self, event):
|
|
139
|
+
for choice in event.get("choices") or []:
|
|
140
|
+
delta = choice.get("delta") or {}
|
|
141
|
+
if delta.get("reasoning_content"):
|
|
142
|
+
self.thinking.append(delta["reasoning_content"])
|
|
143
|
+
self.emit("thinking", delta["reasoning_content"])
|
|
144
|
+
if delta.get("content"):
|
|
145
|
+
self.text.append(delta["content"])
|
|
146
|
+
self.emit("text", delta["content"])
|
|
147
|
+
for t in delta.get("tool_calls") or []:
|
|
148
|
+
call = self.calls.setdefault(t.get("index", 0), {"id": "", "name": "", "arguments": ""})
|
|
149
|
+
function = t.get("function") or {}
|
|
150
|
+
call["id"] = t.get("id") or call["id"]
|
|
151
|
+
call["name"] += function.get("name") or ""
|
|
152
|
+
call["arguments"] += function.get("arguments") or ""
|
|
153
|
+
|
|
154
|
+
def result(self):
|
|
155
|
+
thinking = "".join(self.thinking)
|
|
156
|
+
return {"role": "assistant", "text": "".join(self.text), "thinking": thinking,
|
|
157
|
+
"tool_calls": [{**c, "arguments": c["arguments"] or "{}"} for _, c in sorted(self.calls.items())],
|
|
158
|
+
"raw": {"reasoning_content": thinking} if thinking else {}}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------- openai-responses ----------------
|
|
162
|
+
|
|
163
|
+
def _responses_build(llm, system, messages, tools):
|
|
164
|
+
items = []
|
|
165
|
+
for m in messages:
|
|
166
|
+
if m["role"] == "user":
|
|
167
|
+
items.append({"role": "user", "content": _parts(
|
|
168
|
+
m["content"], "input_text", lambda url: {"type": "input_image", "image_url": url})})
|
|
169
|
+
elif m["role"] == "assistant":
|
|
170
|
+
if _native(llm, m):
|
|
171
|
+
items += m["raw"]
|
|
172
|
+
continue
|
|
173
|
+
if _plain(m):
|
|
174
|
+
items.append({"role": "assistant", "content": _plain(m)})
|
|
175
|
+
items += [{"type": "function_call", "call_id": t["id"], "name": t["name"], "arguments": t["arguments"]}
|
|
176
|
+
for t in m["tool_calls"]]
|
|
177
|
+
else:
|
|
178
|
+
items.append({"type": "function_call_output", "call_id": m["id"], "output": m["content"]})
|
|
179
|
+
body = {"model": llm.model, "instructions": system, "input": items, "store": False, **llm.options}
|
|
180
|
+
if tools:
|
|
181
|
+
body["tools"] = [{"type": "function", **t} for t in tools]
|
|
182
|
+
return body
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _ResponsesStream:
|
|
186
|
+
"""增量只用来显示;最终结果取 response.completed 里的完整 response。"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, emit):
|
|
189
|
+
self.emit, self.final = emit, None
|
|
190
|
+
|
|
191
|
+
def add(self, event):
|
|
192
|
+
kind = event.get("type", "")
|
|
193
|
+
if kind == "response.output_text.delta":
|
|
194
|
+
self.emit("text", event["delta"])
|
|
195
|
+
elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
|
|
196
|
+
self.emit("thinking", event["delta"])
|
|
197
|
+
elif kind in ("response.completed", "response.incomplete"):
|
|
198
|
+
self.final = event["response"]
|
|
199
|
+
elif kind == "response.failed":
|
|
200
|
+
raise LLMError(json.dumps(event["response"].get("error"), ensure_ascii=False)[:800])
|
|
201
|
+
|
|
202
|
+
def result(self):
|
|
203
|
+
if not self.final:
|
|
204
|
+
raise LLMError("流提前结束,没收到 response.completed")
|
|
205
|
+
output = self.final.get("output") or []
|
|
206
|
+
text = "".join(c.get("text", "") for i in output if i["type"] == "message"
|
|
207
|
+
for c in i.get("content") or [] if c["type"] == "output_text")
|
|
208
|
+
thinking = "".join(s.get("text", "") for i in output if i["type"] == "reasoning"
|
|
209
|
+
for s in (i.get("summary") or []) + (i.get("content") or []))
|
|
210
|
+
calls = [{"id": i["call_id"], "name": i["name"], "arguments": i.get("arguments") or "{}"}
|
|
211
|
+
for i in output if i["type"] == "function_call"]
|
|
212
|
+
# store=false 时服务端不记 reasoning,没有加密内容的 reasoning 项回放会报错,丢掉
|
|
213
|
+
raw = [i for i in output if i["type"] != "reasoning" or i.get("encrypted_content")]
|
|
214
|
+
return {"role": "assistant", "text": text, "thinking": thinking, "tool_calls": calls, "raw": raw}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------- anthropic ----------------
|
|
218
|
+
|
|
219
|
+
def _safe_id(value):
|
|
220
|
+
"""Anthropic 要求工具调用 id 匹配 ^[a-zA-Z0-9_-]{1,64}$,别家的 id 不一定满足。"""
|
|
221
|
+
return re.sub(r"[^A-Za-z0-9_-]", "_", value)[:64] or "call"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _image_block(url):
|
|
225
|
+
head, _, data = url.partition(",")
|
|
226
|
+
return {"type": "image", "source": {
|
|
227
|
+
"type": "base64", "media_type": head.removeprefix("data:").split(";")[0], "data": data}}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _anthropic_build(llm, system, messages, tools):
|
|
231
|
+
out = []
|
|
232
|
+
|
|
233
|
+
def push(role, blocks):
|
|
234
|
+
# 要求 user / assistant 交替:连续的 tool 结果并进同一条 user
|
|
235
|
+
if not blocks:
|
|
236
|
+
return
|
|
237
|
+
if out and out[-1]["role"] == role:
|
|
238
|
+
out[-1]["content"] += blocks
|
|
239
|
+
else:
|
|
240
|
+
out.append({"role": role, "content": list(blocks)})
|
|
241
|
+
|
|
242
|
+
for m in messages:
|
|
243
|
+
if m["role"] == "user":
|
|
244
|
+
push("user", [b for b in _parts(m["content"], "text", _image_block)
|
|
245
|
+
if b["type"] != "text" or b["text"].strip()] or [{"type": "text", "text": "…"}])
|
|
246
|
+
elif m["role"] == "assistant":
|
|
247
|
+
if _native(llm, m):
|
|
248
|
+
push("assistant", m["raw"])
|
|
249
|
+
continue
|
|
250
|
+
blocks = [{"type": "text", "text": _plain(m)}] if _plain(m).strip() else []
|
|
251
|
+
blocks += [{"type": "tool_use", "id": _safe_id(t["id"]), "name": t["name"],
|
|
252
|
+
"input": _json_or_empty(t["arguments"])} for t in m["tool_calls"]]
|
|
253
|
+
push("assistant", blocks)
|
|
254
|
+
else:
|
|
255
|
+
push("user", [{"type": "tool_result", "tool_use_id": _safe_id(m["id"]), "content": m["content"]}])
|
|
256
|
+
|
|
257
|
+
body = {"model": llm.model, "system": system, "messages": out, "max_tokens": 8192, **llm.options}
|
|
258
|
+
if tools:
|
|
259
|
+
body["tools"] = [{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
|
|
260
|
+
for t in tools]
|
|
261
|
+
return body
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _json_or_empty(text):
|
|
265
|
+
try:
|
|
266
|
+
value = json.loads(text or "{}")
|
|
267
|
+
return value if isinstance(value, dict) else {}
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
return {}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _AnthropicStream:
|
|
273
|
+
def __init__(self, emit):
|
|
274
|
+
self.emit, self.blocks = emit, {}
|
|
275
|
+
|
|
276
|
+
def add(self, event):
|
|
277
|
+
kind = event.get("type")
|
|
278
|
+
if kind == "content_block_start":
|
|
279
|
+
block = dict(event["content_block"])
|
|
280
|
+
if block["type"] == "tool_use":
|
|
281
|
+
block["input"], block["_json"] = {}, ""
|
|
282
|
+
self.blocks[event["index"]] = block
|
|
283
|
+
elif kind == "content_block_delta":
|
|
284
|
+
block, delta = self.blocks[event["index"]], event["delta"]
|
|
285
|
+
if delta["type"] == "text_delta":
|
|
286
|
+
block["text"] = block.get("text", "") + delta["text"]
|
|
287
|
+
self.emit("text", delta["text"])
|
|
288
|
+
elif delta["type"] == "thinking_delta":
|
|
289
|
+
block["thinking"] = block.get("thinking", "") + delta["thinking"]
|
|
290
|
+
self.emit("thinking", delta["thinking"])
|
|
291
|
+
elif delta["type"] == "signature_delta":
|
|
292
|
+
block["signature"] = block.get("signature", "") + delta["signature"]
|
|
293
|
+
elif delta["type"] == "input_json_delta":
|
|
294
|
+
block["_json"] += delta["partial_json"]
|
|
295
|
+
|
|
296
|
+
def result(self):
|
|
297
|
+
content = []
|
|
298
|
+
for _, block in sorted(self.blocks.items()):
|
|
299
|
+
if "_json" in block:
|
|
300
|
+
block["input"] = _json_or_empty(block.pop("_json"))
|
|
301
|
+
content.append(block)
|
|
302
|
+
return {
|
|
303
|
+
"role": "assistant",
|
|
304
|
+
"text": "".join(b["text"] for b in content if b["type"] == "text"),
|
|
305
|
+
"thinking": "".join(b.get("thinking", "") for b in content if b["type"] == "thinking"),
|
|
306
|
+
"tool_calls": [{"id": b["id"], "name": b["name"], "arguments": json.dumps(b["input"], ensure_ascii=False)}
|
|
307
|
+
for b in content if b["type"] == "tool_use"],
|
|
308
|
+
"raw": content,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
PROTOCOLS = {
|
|
313
|
+
"openai-chat": (_chat_build, "/chat/completions", _ChatStream),
|
|
314
|
+
"openai-responses": (_responses_build, "/responses", _ResponsesStream),
|
|
315
|
+
"anthropic": (_anthropic_build, "/v1/messages", _AnthropicStream),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def make_llm(config, ref=None):
|
|
320
|
+
"""ref 形如 deepseek/deepseek-flash。配置有问题直接抛 SystemExit,说清楚哪儿不对。"""
|
|
321
|
+
ref = ref or config.get("model")
|
|
322
|
+
if not ref or "/" not in ref:
|
|
323
|
+
raise SystemExit(f"模型要写成 <provider>/<model>,现在是:{ref!r}")
|
|
324
|
+
name, _, model = ref.partition("/")
|
|
325
|
+
provider = config.get("provider", {}).get(name)
|
|
326
|
+
if not provider:
|
|
327
|
+
raise SystemExit(f"配置里没有 provider {name!r},已有:{'、'.join(config.get('provider', {}))}")
|
|
328
|
+
api = provider.get("api", "openai-chat")
|
|
329
|
+
if api not in PROTOCOLS:
|
|
330
|
+
raise SystemExit(f"{name}.api 只能是 {'、'.join(PROTOCOLS)},现在是 {api!r}")
|
|
331
|
+
try:
|
|
332
|
+
base_url, api_key = expand(provider["baseURL"]), expand(provider.get("apiKey", ""))
|
|
333
|
+
options = expand({**provider.get("options", {}),
|
|
334
|
+
**provider.get("models", {}).get(model, {}).get("options", {})})
|
|
335
|
+
except MissingEnv as error:
|
|
336
|
+
raise SystemExit(f"{name} 需要环境变量 {error}")
|
|
337
|
+
except KeyError:
|
|
338
|
+
raise SystemExit(f"{name} 没写 baseURL")
|
|
339
|
+
return LLM(ref, api, base_url, api_key, model, options)
|
mini_agent/mcp.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""mini-agent.json 里的 mcp 段 -> 该连哪些 server,以及怎么连。
|
|
2
|
+
|
|
3
|
+
"mcp": {
|
|
4
|
+
"名字": {"type": "local", "command": ["npx", "-y", "..."], "environment": {...}, "enabled": true},
|
|
5
|
+
"名字": {"type": "remote", "url": "https://...", "headers": {...}}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
单独跑可以先看配置:uv run python -m mini_agent.mcp
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .config import MissingEnv, expand
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_servers(config):
|
|
17
|
+
"""返回 (生效的 server 列表, 跳过原因)。一个 server 有问题只是少一批工具。"""
|
|
18
|
+
servers, notes = [], []
|
|
19
|
+
for name, raw in (config.get("mcp") or {}).items():
|
|
20
|
+
if raw.get("enabled") is False:
|
|
21
|
+
notes.append(f"{name}:enabled 是 false")
|
|
22
|
+
continue
|
|
23
|
+
try:
|
|
24
|
+
server = {"name": name, **expand(raw)}
|
|
25
|
+
except MissingEnv as error:
|
|
26
|
+
notes.append(f"{name}:环境变量 {error} 没设置")
|
|
27
|
+
continue
|
|
28
|
+
kind = server.get("type") or ("remote" if server.get("url") else "local")
|
|
29
|
+
if kind == "remote" and server.get("url"):
|
|
30
|
+
servers.append({**server, "type": "remote"})
|
|
31
|
+
elif kind == "local" and server.get("command"):
|
|
32
|
+
servers.append({**server, "type": "local"})
|
|
33
|
+
else:
|
|
34
|
+
notes.append(f"{name}:local 要写 command(数组),remote 要写 url")
|
|
35
|
+
return servers, notes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def open_session(stack, server):
|
|
39
|
+
"""连上一个 server,生命周期挂到 AsyncExitStack 上,退出时统一关掉。"""
|
|
40
|
+
from mcp import ClientSession, StdioServerParameters
|
|
41
|
+
from mcp.client.stdio import stdio_client
|
|
42
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
43
|
+
|
|
44
|
+
if server["type"] == "remote":
|
|
45
|
+
client = None
|
|
46
|
+
if server.get("headers"):
|
|
47
|
+
import httpx2 # mcp 自己的依赖
|
|
48
|
+
|
|
49
|
+
client = await stack.enter_async_context(httpx2.AsyncClient(headers=server["headers"], timeout=30))
|
|
50
|
+
transport = streamable_http_client(server["url"], http_client=client)
|
|
51
|
+
else:
|
|
52
|
+
command, *args = server["command"]
|
|
53
|
+
transport = stdio_client(StdioServerParameters(command=command, args=args, env=server.get("environment")))
|
|
54
|
+
read, write = await stack.enter_async_context(transport)
|
|
55
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
56
|
+
await session.initialize()
|
|
57
|
+
return session
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def describe(server):
|
|
61
|
+
"""打印用。url 里常带 key,别原样打出来。"""
|
|
62
|
+
if server["type"] == "remote":
|
|
63
|
+
return "remote " + re.sub(r"((?:key|token)=)[^&]+", r"\1***", server["url"], flags=re.I)
|
|
64
|
+
return "local " + " ".join(server["command"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
from .config import load
|
|
69
|
+
|
|
70
|
+
config, sources = load()
|
|
71
|
+
servers, notes = load_servers(config)
|
|
72
|
+
print("配置:" + "、".join(map(str, sources)) + "\n")
|
|
73
|
+
print(f"生效的 server({len(servers)} 个):")
|
|
74
|
+
for s in servers:
|
|
75
|
+
print(f" {s['name']:<20} {describe(s)}")
|
|
76
|
+
if notes:
|
|
77
|
+
print(f"\n跳过的({len(notes)} 个):")
|
|
78
|
+
for note in notes:
|
|
79
|
+
print(f" {note}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// mini-agent 配置示例。首次运行会复制到 ~/.config/mini-agent/mini-agent.json。
|
|
2
|
+
// 项目里放一个 mini-agent.json,会深度合并覆盖全局配置(从当前目录往上找到 git 根目录)。
|
|
3
|
+
// 支持整行 // 注释。密钥写成 {env:变量名},不要明文写进来。
|
|
4
|
+
{
|
|
5
|
+
// 默认模型:<provider>/<model>。运行时可以用 --model 或 /model 切换
|
|
6
|
+
"model": "deepseek/deepseek-flash",
|
|
7
|
+
|
|
8
|
+
"provider": {
|
|
9
|
+
// api 三选一:
|
|
10
|
+
// openai-chat POST {baseURL}/chat/completions 绝大多数厂商都兼容
|
|
11
|
+
// openai-responses POST {baseURL}/responses
|
|
12
|
+
// anthropic POST {baseURL}/v1/messages
|
|
13
|
+
// options 原样并进请求体;models.<id>.options 只对那个模型生效,优先级更高
|
|
14
|
+
"deepseek": {
|
|
15
|
+
"api": "openai-chat",
|
|
16
|
+
"baseURL": "https://api.deepseek.com",
|
|
17
|
+
"apiKey": "{env:DEEPSEEK_API_KEY}"
|
|
18
|
+
},
|
|
19
|
+
"bailian": {
|
|
20
|
+
"api": "openai-chat",
|
|
21
|
+
"baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
22
|
+
"apiKey": "{env:DASHSCOPE_API_KEY}",
|
|
23
|
+
// qwen3.8-flash 开思考时偶尔会忽略用户这一轮的问题(实测约 1/3),默认关掉;
|
|
24
|
+
// 换 plus / max 或想看思考过程(灰色显示)再打开
|
|
25
|
+
"options": { "enable_thinking": false }
|
|
26
|
+
},
|
|
27
|
+
"bailian-responses": {
|
|
28
|
+
"api": "openai-responses",
|
|
29
|
+
"baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
30
|
+
"apiKey": "{env:DASHSCOPE_API_KEY}"
|
|
31
|
+
},
|
|
32
|
+
"bailian-anthropic": {
|
|
33
|
+
"api": "anthropic",
|
|
34
|
+
"baseURL": "https://dashscope.aliyuncs.com/apps/anthropic",
|
|
35
|
+
"apiKey": "{env:DASHSCOPE_API_KEY}",
|
|
36
|
+
"options": { "max_tokens": 8192 }
|
|
37
|
+
},
|
|
38
|
+
"openai": {
|
|
39
|
+
"api": "openai-responses",
|
|
40
|
+
"baseURL": "https://api.openai.com/v1",
|
|
41
|
+
"apiKey": "{env:OPENAI_API_KEY}"
|
|
42
|
+
},
|
|
43
|
+
"anthropic": {
|
|
44
|
+
"api": "anthropic",
|
|
45
|
+
"baseURL": "https://api.anthropic.com",
|
|
46
|
+
"apiKey": "{env:ANTHROPIC_API_KEY}",
|
|
47
|
+
"options": { "max_tokens": 16000 }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
// type: local(启动子进程,stdio)或 remote(streamable HTTP)
|
|
52
|
+
// 缺 {env:} 变量或 enabled 为 false 的会被跳过,不影响启动
|
|
53
|
+
"mcp": {
|
|
54
|
+
"sequential-thinking": {
|
|
55
|
+
"type": "local",
|
|
56
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]
|
|
57
|
+
},
|
|
58
|
+
"brave-search": {
|
|
59
|
+
"type": "local",
|
|
60
|
+
"command": ["npx", "-y", "@modelcontextprotocol/server-brave-search"],
|
|
61
|
+
"environment": { "BRAVE_API_KEY": "{env:BRAVE_API_KEY}" }
|
|
62
|
+
},
|
|
63
|
+
"amap": {
|
|
64
|
+
"type": "remote",
|
|
65
|
+
"url": "https://mcp.amap.com/mcp?key={env:AMAP_MAPS_API_KEY}",
|
|
66
|
+
"enabled": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// 每类操作:ask(先问)/ allow(直接放行)/ deny(禁止)。--yes 会把 ask 全部当 allow
|
|
71
|
+
// bash 执行命令
|
|
72
|
+
// edit write / edit 改文件
|
|
73
|
+
// external 读工作目录和 skill 目录以外的路径
|
|
74
|
+
// mcp 名字以 create / delete / send / run 等动词开头的 MCP 工具
|
|
75
|
+
"permission": {
|
|
76
|
+
"bash": "ask",
|
|
77
|
+
"edit": "ask",
|
|
78
|
+
"external": "ask",
|
|
79
|
+
"mcp": "ask"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mini-agent-config
|
|
3
|
+
description: 当用户要添加、修改、删除、开关 MCP server,或者切换 / 新增模型厂商、调整权限时使用。负责改 mini-agent.json
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 修改 mini-agent 配置
|
|
7
|
+
|
|
8
|
+
## 改哪个文件
|
|
9
|
+
|
|
10
|
+
- 全局:`~/.config/mini-agent/mini-agent.json`(环境变量 `MINI_AGENT_CONFIG` 可能改了路径,先 `echo $MINI_AGENT_CONFIG` 确认)
|
|
11
|
+
- 项目:git 根目录或当前目录下的 `mini-agent.json`,深度合并覆盖全局
|
|
12
|
+
|
|
13
|
+
用户没说改哪个,就问一句:只在这个项目用,还是所有地方都用。完整格式见 `../../mini-agent.example.json`(相对本 skill 目录)。
|
|
14
|
+
|
|
15
|
+
## 步骤
|
|
16
|
+
|
|
17
|
+
1. 先 read 目标文件。文件是 JSON,允许整行 `//` 注释
|
|
18
|
+
2. 用 edit 做最小改动,不要整体重写,保留用户原有的注释和顺序
|
|
19
|
+
3. 密钥一律写成 `{env:变量名}`。用户把 key 直接贴给你时,不要写进文件,告诉用户用 `export 变量名=...` 加到 shell 配置里
|
|
20
|
+
4. 改完用 bash 跑 `python3 -c "import re,json,sys; json.loads(re.sub(r'(?m)^\s*//.*$','',open(sys.argv[1]).read()))" <文件>` 确认还是合法 JSON
|
|
21
|
+
5. 告诉用户输入 `/reload` 生效
|
|
22
|
+
|
|
23
|
+
## MCP 格式
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
"mcp": {
|
|
27
|
+
"名字": {"type": "local", "command": ["npx", "-y", "包名"], "environment": {"KEY": "{env:KEY}"}},
|
|
28
|
+
"名字": {"type": "remote", "url": "https://...", "headers": {"Authorization": "Bearer {env:TOKEN}"}},
|
|
29
|
+
"名字": {"...": "...", "enabled": false}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- 不确定一个 MCP 包叫什么、要哪些环境变量时,先用 bash 查(`npm view <包名>`,或看它的 README),不要编
|
|
34
|
+
- 关掉一个 server 用 `"enabled": false`,不要删掉整段
|
|
35
|
+
|
|
36
|
+
## 模型格式
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
"model": "provider名/模型id",
|
|
40
|
+
"provider": {
|
|
41
|
+
"名字": {"api": "openai-chat | openai-responses | anthropic", "baseURL": "...", "apiKey": "{env:KEY}",
|
|
42
|
+
"options": {"原样并进请求体的参数": "..."}}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
baseURL 的写法和各家 SDK 一致:openai 两种协议写到 `/v1` 为止,anthropic 不带 `/v1`。
|