qi-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/loader.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""内容装载器(P2):agent 目录发现(两层)、agent.md/技能解析、校验、import。
|
|
2
|
+
|
|
3
|
+
对齐 design/agent-config-design.md §3/§6/§10:
|
|
4
|
+
- 位置: ~/.qi/agents + <项目>/.qi/agents + 包内置 qi_agent/builtin/agents
|
|
5
|
+
- 优先级: 项目 > 用户 > 内置(静默覆盖);同层重复报错
|
|
6
|
+
- 校验: name==目录名 / description 非空 / tools 存在 / include 存在 / 技能同名冲突
|
|
7
|
+
- import: 与装载共用同一校验器;明文凭证扫描
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from . import paths
|
|
19
|
+
from .models import Skill
|
|
20
|
+
from .settings import (
|
|
21
|
+
QiSettings,
|
|
22
|
+
load_settings_by_scope,
|
|
23
|
+
settings_exclude_paths,
|
|
24
|
+
settings_include_paths,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
AGENT_FILE = "agent.md"
|
|
28
|
+
SKILL_FILE = "SKILL.md"
|
|
29
|
+
ASSETS_DIR = "assets"
|
|
30
|
+
SKILLS_DIR = "skills"
|
|
31
|
+
# Agent Skills 标准的跨工具目录(~/.agents/skills、.agents/skills);
|
|
32
|
+
# 不对应单一工具,见 design/agent-config-design.md。
|
|
33
|
+
CROSS_TOOL_DIR = ".agents"
|
|
34
|
+
SYSTEM_FILE_NAME = "SYSTEM.md" # 基座提示词覆盖文件
|
|
35
|
+
|
|
36
|
+
_PLAINTEXT_KEY_RE = re.compile(
|
|
37
|
+
r'("?(?:api[_-]?key|password|secret|access_token|auth_config)"?\s*[:=]\s*["\'])([^"\']{6,})'
|
|
38
|
+
r'|(["\']Bearer\s+)([A-Za-z0-9._~+/=-]{12,})',
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LoadError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def split_frontmatter(text: str) -> tuple[dict, str]:
|
|
48
|
+
"""解析 '---' 包裹的 YAML frontmatter + 正文。"""
|
|
49
|
+
if not text.startswith("---"):
|
|
50
|
+
return {}, text
|
|
51
|
+
lines = text.splitlines()
|
|
52
|
+
end = None
|
|
53
|
+
for i in range(1, len(lines)):
|
|
54
|
+
if lines[i].strip() == "---":
|
|
55
|
+
end = i
|
|
56
|
+
break
|
|
57
|
+
if end is None:
|
|
58
|
+
return {}, text
|
|
59
|
+
meta = yaml.safe_load("\n".join(lines[1:end])) or {}
|
|
60
|
+
body = "\n".join(lines[end + 1 :]).strip()
|
|
61
|
+
return (meta if isinstance(meta, dict) else {}), body
|
|
62
|
+
def _skill_from_file(skill_file: Path, *, fallback_name: str, source: str) -> Skill | None:
|
|
63
|
+
"""从 SKILL.md(或根级 *.md)读出一个技能;frontmatter 无 description 时返回 None。"""
|
|
64
|
+
try:
|
|
65
|
+
meta, _body = split_frontmatter(skill_file.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, UnicodeDecodeError, yaml.YAMLError):
|
|
67
|
+
return None
|
|
68
|
+
desc = str(meta.get("description") or "").strip()
|
|
69
|
+
if not desc:
|
|
70
|
+
return None
|
|
71
|
+
name = str(meta.get("name") or fallback_name)
|
|
72
|
+
return Skill(name=name, description=desc, path=skill_file, source=source)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def scan_skills(agent_dir: Path, source: str = "") -> list[Skill]:
|
|
76
|
+
"""agent 自带技能:`<agent_dir>/skills/<name>/SKILL.md`(单层,同名报错)。"""
|
|
77
|
+
skills: dict[str, Skill] = {}
|
|
78
|
+
label = source or f"agent:{agent_dir.name}"
|
|
79
|
+
skills_root = agent_dir / SKILLS_DIR
|
|
80
|
+
if skills_root.is_dir():
|
|
81
|
+
for skill_dir in sorted(skills_root.iterdir()):
|
|
82
|
+
skill_file = skill_dir / SKILL_FILE
|
|
83
|
+
if not skill_file.is_file():
|
|
84
|
+
continue
|
|
85
|
+
meta, _body = split_frontmatter(skill_file.read_text(encoding="utf-8"))
|
|
86
|
+
name = meta.get("name") or skill_dir.name
|
|
87
|
+
desc = str(meta.get("description", "")).strip()
|
|
88
|
+
if name in skills:
|
|
89
|
+
raise LoadError(f"{skill_file}:技能 {name!r} 在 {agent_dir} 内重复")
|
|
90
|
+
skills[name] = Skill(name=name, description=desc or "(无描述)",
|
|
91
|
+
path=skill_file, source=label)
|
|
92
|
+
return list(skills.values())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def scan_skill_root(root: Path, *, label: str, out: list[Skill],
|
|
96
|
+
allow_root_md: bool = True) -> None:
|
|
97
|
+
"""递归扫描一个技能根(对齐 pi):
|
|
98
|
+
|
|
99
|
+
- 含 SKILL.md 的目录即技能,**不再向内递归**
|
|
100
|
+
- 无 SKILL.md 的子目录继续向内找(支持分组目录)
|
|
101
|
+
- `allow_root_md` 时,根下带 description 的 `*.md` 也算独立技能
|
|
102
|
+
"""
|
|
103
|
+
if root.is_file():
|
|
104
|
+
if root.suffix == ".md":
|
|
105
|
+
skill = _skill_from_file(root, fallback_name=root.stem, source=label)
|
|
106
|
+
if skill:
|
|
107
|
+
out.append(skill)
|
|
108
|
+
return
|
|
109
|
+
if not root.is_dir():
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
def walk(directory: Path, *, is_root: bool) -> None:
|
|
113
|
+
try:
|
|
114
|
+
children = sorted(directory.iterdir())
|
|
115
|
+
except OSError:
|
|
116
|
+
return
|
|
117
|
+
for child in children:
|
|
118
|
+
if child.is_dir():
|
|
119
|
+
nested = child / SKILL_FILE
|
|
120
|
+
if nested.is_file():
|
|
121
|
+
skill = _skill_from_file(nested, fallback_name=child.name, source=label)
|
|
122
|
+
if skill:
|
|
123
|
+
out.append(skill)
|
|
124
|
+
continue # 已是技能,不再向内递归
|
|
125
|
+
walk(child, is_root=False)
|
|
126
|
+
elif is_root and allow_root_md and child.suffix == ".md":
|
|
127
|
+
skill = _skill_from_file(child, fallback_name=child.stem, source=label)
|
|
128
|
+
if skill:
|
|
129
|
+
out.append(skill)
|
|
130
|
+
|
|
131
|
+
walk(root, is_root=True)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _skill_layer_entries(cwd: Path,
|
|
135
|
+
scopes: dict[str, QiSettings],
|
|
136
|
+
) -> list[tuple[Path, str, bool]]:
|
|
137
|
+
"""构造技能层列表(低 → 高);`scopes` 必须是各作用域**各自**的设置。"""
|
|
138
|
+
entries: list[tuple[Path, str, bool]] = [
|
|
139
|
+
(Path.home() / CROSS_TOOL_DIR / SKILLS_DIR, "agents-global", False),
|
|
140
|
+
(paths.global_home() / SKILLS_DIR, "qi-global", True),
|
|
141
|
+
]
|
|
142
|
+
entries += [(p, "settings-global", True)
|
|
143
|
+
for p in settings_include_paths(scopes.get("user"), "user", "skills", cwd)]
|
|
144
|
+
for ancestor in reversed(paths.project_context_ancestors(cwd)): # 远 → 近
|
|
145
|
+
entries.append((ancestor / CROSS_TOOL_DIR / SKILLS_DIR, "agents-project", False))
|
|
146
|
+
entries.append((paths.project_home(cwd) / SKILLS_DIR, "qi-project", True))
|
|
147
|
+
entries += [(p, "settings-project", True)
|
|
148
|
+
for p in settings_include_paths(scopes.get("project"), "project", "skills", cwd)]
|
|
149
|
+
return entries
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def top_level_skill_dirs(cwd: Path | None = None,
|
|
153
|
+
settings: QiSettings | None = None,
|
|
154
|
+
) -> list[tuple[Path, str, bool]]:
|
|
155
|
+
"""顶层技能根的完整优先级顺序(低 → 高),返回 (目录, 层标签, 允许根级 md)。
|
|
156
|
+
|
|
157
|
+
对齐 pi 的发现规则,并叠上 qi 的私有层:
|
|
158
|
+
|
|
159
|
+
1. `~/.agents/skills` 跨工具共享(全局,永远视为已信任)
|
|
160
|
+
2. `~/.qi/agent/skills` qi 全局私有
|
|
161
|
+
3. user settings 的 `skills[]` 追加路径(相对 `~/.qi/agent`)
|
|
162
|
+
4. `.agents/skills`(cwd→git 根,远→近) 跨工具共享(项目,需信任)
|
|
163
|
+
5. `<git根>/.qi/skills` qi 项目私有
|
|
164
|
+
6. project settings 的 `skills[]` 追加路径(相对 `<git根>/.qi`)
|
|
165
|
+
|
|
166
|
+
同一标签内的同名技能视为冲突(报错);跨标签同名则高优先级静默覆盖。
|
|
167
|
+
`settings` 参数只为兼容旧调用保留 —— 两个作用域的 `skills[]` 一律从各自的
|
|
168
|
+
原始文件读取,因为相对路径必须按各自所在目录解析。
|
|
169
|
+
"""
|
|
170
|
+
del settings # 保留参数以兼容调用方;实际按作用域各读各的
|
|
171
|
+
root = Path(cwd) if cwd else Path.cwd()
|
|
172
|
+
return _skill_layer_entries(root, load_settings_by_scope(root))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_top_level_skills(cwd: Path | None = None,
|
|
176
|
+
settings: QiSettings | None = None,
|
|
177
|
+
extra_paths: list[Path] | None = None,
|
|
178
|
+
enabled: bool = True) -> list[Skill]:
|
|
179
|
+
"""装载顶层技能(低 → 高覆盖);`extra_paths`(CLI `--skill`)最高优先。
|
|
180
|
+
|
|
181
|
+
`enabled=False` 时跳过目录发现,但仍装载 extra_paths —— 对齐 pi 的
|
|
182
|
+
`--no-skills`:`--skill <path>` 在禁用发现时依然生效。
|
|
183
|
+
|
|
184
|
+
来源取值见 `top_level_skill_dirs`;返回值按优先级从低到高,可见项在后。
|
|
185
|
+
|
|
186
|
+
`settings` 只为兼容旧调用保留(实际按作用域各读各的)。
|
|
187
|
+
"""
|
|
188
|
+
merged: dict[str, Skill] = {}
|
|
189
|
+
labels: dict[str, str] = {} # 技能名 → 已占用的层标签
|
|
190
|
+
root = Path(cwd) if cwd else Path.cwd()
|
|
191
|
+
scopes = load_settings_by_scope(root)
|
|
192
|
+
# 排除项(`!pat` / `-pat`)作用于**整个发现集**,不只是数组里纳入的根
|
|
193
|
+
excludes: list[Path] = []
|
|
194
|
+
for scope in ("user", "project"):
|
|
195
|
+
for path in settings_exclude_paths(scopes.get(scope), scope, "skills", root):
|
|
196
|
+
if path not in excludes:
|
|
197
|
+
excludes.append(path)
|
|
198
|
+
|
|
199
|
+
def excluded(skill: Skill) -> bool:
|
|
200
|
+
return any(skill.path == ex or ex in skill.path.parents for ex in excludes)
|
|
201
|
+
|
|
202
|
+
def add(skill: Skill) -> None:
|
|
203
|
+
if excluded(skill):
|
|
204
|
+
return
|
|
205
|
+
owner = labels.get(skill.name)
|
|
206
|
+
if owner is not None and owner == skill.source:
|
|
207
|
+
raise LoadError(
|
|
208
|
+
f"技能名 {skill.name!r} 在 {skill.source} 层重复(冲突文件:{skill.path})"
|
|
209
|
+
)
|
|
210
|
+
if owner is not None:
|
|
211
|
+
merged.pop(skill.name, None) # 高优先级覆盖低优先级
|
|
212
|
+
labels[skill.name] = skill.source
|
|
213
|
+
merged[skill.name] = skill
|
|
214
|
+
|
|
215
|
+
if enabled:
|
|
216
|
+
for layer_root, label, allow_root_md in _skill_layer_entries(root, scopes):
|
|
217
|
+
found: list[Skill] = []
|
|
218
|
+
scan_skill_root(layer_root, label=label, out=found, allow_root_md=allow_root_md)
|
|
219
|
+
for skill in found:
|
|
220
|
+
add(skill)
|
|
221
|
+
|
|
222
|
+
for raw in (extra_paths or []):
|
|
223
|
+
found = []
|
|
224
|
+
scan_skill_root(Path(raw).expanduser(), label="cli", out=found, allow_root_md=True)
|
|
225
|
+
for skill in found:
|
|
226
|
+
add(skill)
|
|
227
|
+
|
|
228
|
+
return list(merged.values())
|
|
229
|
+
def system_file_candidates(cwd: Path | None = None) -> list[tuple[Path, str]]:
|
|
230
|
+
"""可能的 SYSTEM.md 路径,按优先级从高到低返回 (文件, 来源)。"""
|
|
231
|
+
return [
|
|
232
|
+
(paths.project_home(cwd) / SYSTEM_FILE_NAME, "project"),
|
|
233
|
+
(paths.global_home() / SYSTEM_FILE_NAME, "user"),
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def resolve_base_prompt(cwd: Path | None = None) -> tuple[str, str]:
|
|
238
|
+
"""解析**自定义**基座,返回 `(文本, 来源说明)`;来源取值 `project|user|builtin`。
|
|
239
|
+
|
|
240
|
+
文本为空串表示没有自定义基座 —— 调用方改用代码内默认
|
|
241
|
+
(`system_prompt.default_base_prompt()`),所以基座层永不为空。
|
|
242
|
+
空文件/读取失败视为未配置,继续往下一层找(不会静默降级成空提示词)。
|
|
243
|
+
"""
|
|
244
|
+
for path, source in system_file_candidates(cwd):
|
|
245
|
+
if not path.is_file():
|
|
246
|
+
continue
|
|
247
|
+
try:
|
|
248
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
249
|
+
except (OSError, UnicodeDecodeError):
|
|
250
|
+
continue
|
|
251
|
+
if text:
|
|
252
|
+
return text, f"{source}:{path}"
|
|
253
|
+
return "", "builtin"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── 项目上下文(AGENTS.md / CLAUDE.md,对齐 pi 的 project_context)────
|
|
257
|
+
#
|
|
258
|
+
# 每级目录按候选顺序取**第一个**命中的文件(顺序照搬 pi);全局(agent 目录)在最前,
|
|
259
|
+
# 项目祖先链由远到近追加 —— 近者可覆盖远者的同义约定。
|
|
260
|
+
# 与 pi 的两点差异:祖先链止于 **git 根**(qi 的 project_context_ancestors 约定,
|
|
261
|
+
# 与 .agents/skills 的继承范围一致;pi 一路走到文件系统根),空文件视为未配置。
|
|
262
|
+
|
|
263
|
+
CONTEXT_FILE_NAMES = ("AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_context_file_from_dir(directory: Path) -> tuple[Path, str] | None:
|
|
267
|
+
"""目录内按候选顺序取第一个 AGENTS/CLAUDE 文件;空文件/读失败视为没有。"""
|
|
268
|
+
for name in CONTEXT_FILE_NAMES:
|
|
269
|
+
path = directory / name
|
|
270
|
+
try:
|
|
271
|
+
if not path.is_file():
|
|
272
|
+
continue
|
|
273
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
274
|
+
except (OSError, UnicodeDecodeError):
|
|
275
|
+
continue
|
|
276
|
+
if text:
|
|
277
|
+
return path, text
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def load_project_context(cwd: Path | None = None) -> list[tuple[Path, str]]:
|
|
282
|
+
"""项目上下文文件 `(路径, 正文)`:全局 → 远祖先 → 近祖先(含 cwd);按路径去重。"""
|
|
283
|
+
out: list[tuple[Path, str]] = []
|
|
284
|
+
seen: set[Path] = set()
|
|
285
|
+
|
|
286
|
+
def take(directory: Path) -> None:
|
|
287
|
+
found = load_context_file_from_dir(directory)
|
|
288
|
+
if found is None:
|
|
289
|
+
return
|
|
290
|
+
key = found[0].resolve(strict=False)
|
|
291
|
+
if key in seen:
|
|
292
|
+
return
|
|
293
|
+
seen.add(key)
|
|
294
|
+
out.append(found)
|
|
295
|
+
|
|
296
|
+
take(paths.global_home())
|
|
297
|
+
for ancestor in reversed(paths.project_context_ancestors(cwd)): # 远 → 近
|
|
298
|
+
take(ancestor)
|
|
299
|
+
return out
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""从 provider 的 OpenAI 兼容 `/models` 端点拉模型列表(给 `qi init --refresh`)。
|
|
2
|
+
|
|
3
|
+
**为什么需要它**:`presets.py` 里那张表是**种子** —— 离线可用、带核过的 `contextWindow` /
|
|
4
|
+
`maxTokens`,但**模型 id 会漂**(上新 / 下线 / 改名)。实测例子:DeepSeek 把
|
|
5
|
+
`deepseek-v4-flash` 换成了 `deepseek-flash`(V4.1 Flash,旧名仍路由到新模型)——
|
|
6
|
+
这种变更只能靠厂商公告或接口,写死的表迟早落后一拍。所以另给一条**从接口拉**的路。
|
|
7
|
+
|
|
8
|
+
全部国产 provider 都提供 OpenAI 兼容的 `GET {baseUrl}/models`(返回 `{"data": [{"id": …}]}`),
|
|
9
|
+
拿同一个 API key 鉴权即可。这里**不做网络以外的猜测**:接口返回什么 id 就用什么 id;
|
|
10
|
+
`contextWindow` / `maxTokens` 接口不返回,新加进来的条目先用 qi 的默认值(要精确就照厂商文档补)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
#: 一次刷新的超时(秒)。厂商的 /models 通常几十毫秒,20s 只用于兜住挂死的网络。
|
|
21
|
+
DEFAULT_TIMEOUT = 20.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CatalogError(Exception):
|
|
25
|
+
"""拉取失败(网络 / HTTP 状态 / 返回体不是预期的形状)。"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def models_url(base_url: str) -> str:
|
|
29
|
+
"""`baseUrl` → `/models` 端点。
|
|
30
|
+
|
|
31
|
+
规则就是拼一个 `/models`:各家的 OpenAI 兼容前缀都直接支持 ——
|
|
32
|
+
`https://api.deepseek.com` → `/models`,`…/compatible-mode/v1` → `/compatible-mode/v1/models`。
|
|
33
|
+
|
|
34
|
+
**只认 http(s)** —— `baseUrl` 来自用户的 `models.json`,与其它配置同信任域,但
|
|
35
|
+
`urlopen` 遇到 `file://` 会去读本地文件,那种“靠配置读盘”的口子不值得留
|
|
36
|
+
(`ftp://` / `data:` 同理)。写错了就报错,不静默换个东西去读。
|
|
37
|
+
"""
|
|
38
|
+
url = base_url.rstrip("/") + "/models"
|
|
39
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
40
|
+
raise CatalogError(f"baseUrl 必须是 http(s),拿到的是:{base_url!r}")
|
|
41
|
+
return url
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def model_ids_from_payload(payload: Any) -> list[str]:
|
|
45
|
+
"""从 `/models` 的返回体里取模型 id(去重、保序)。
|
|
46
|
+
|
|
47
|
+
认三种形状:OpenAI 的 `{"data": [{"id": …}]}`、裸列表 `[{…}]`、以及
|
|
48
|
+
`{"data": ["id", …]}`(少数网关直接给字符串)。取不到就抛 `CatalogError`
|
|
49
|
+
—— 不静默返回空列表(那会让人以为“厂商没有模型”)。
|
|
50
|
+
"""
|
|
51
|
+
items = payload.get("data") if isinstance(payload, dict) else payload
|
|
52
|
+
if not isinstance(items, list):
|
|
53
|
+
raise CatalogError("返回体里没有 `data` 列表")
|
|
54
|
+
ids: list[str] = []
|
|
55
|
+
for item in items:
|
|
56
|
+
if isinstance(item, dict):
|
|
57
|
+
value = item.get("id") or item.get("model")
|
|
58
|
+
else:
|
|
59
|
+
value = item
|
|
60
|
+
text = str(value or "").strip()
|
|
61
|
+
if text and text not in ids:
|
|
62
|
+
ids.append(text)
|
|
63
|
+
if not ids:
|
|
64
|
+
raise CatalogError("返回体里没有任何模型 id")
|
|
65
|
+
return ids
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def fetch_model_ids(base_url: str, api_key: str | None, *,
|
|
69
|
+
timeout: float = DEFAULT_TIMEOUT) -> list[str]:
|
|
70
|
+
"""`GET {baseUrl}/models` 并取出模型 id。失败一律抛 `CatalogError`。
|
|
71
|
+
|
|
72
|
+
只读、需要网络:**只由显式的 `qi init --refresh` 调用** —— qi 的启动路径不做任何网络请求。
|
|
73
|
+
"""
|
|
74
|
+
if not base_url:
|
|
75
|
+
raise CatalogError("这个 provider 没有 baseUrl,拉不了模型列表")
|
|
76
|
+
url = models_url(base_url) # 顺带把非 http(s) 的 baseUrl 挡在外面
|
|
77
|
+
request = urllib.request.Request(url, headers={"Accept": "application/json"}) # noqa: S310
|
|
78
|
+
if api_key:
|
|
79
|
+
request.add_header("Authorization", f"Bearer {api_key}")
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 URL 已过 scheme 检查
|
|
82
|
+
raw = response.read().decode("utf-8", "replace")
|
|
83
|
+
except urllib.error.HTTPError as exc:
|
|
84
|
+
hint = "API key 不对或没权限" if exc.code in (401, 403) else exc.reason
|
|
85
|
+
raise CatalogError(f"{url} 返回 HTTP {exc.code}({hint})") from exc
|
|
86
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
87
|
+
raise CatalogError(f"连不上 {url}: {exc}") from exc
|
|
88
|
+
try:
|
|
89
|
+
payload = json.loads(raw)
|
|
90
|
+
except json.JSONDecodeError as exc:
|
|
91
|
+
raise CatalogError(f"{url} 返回的不是 JSON") from exc
|
|
92
|
+
return model_ids_from_payload(payload)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def merge_model_ids(entry: dict[str, Any], ids: list[str]) -> tuple[list[str], list[str]]:
|
|
96
|
+
"""把接口来的 id 并进 provider 段(`entry` 原地改),返回 `(新增, 本地有但接口没返回)`。
|
|
97
|
+
|
|
98
|
+
**只加不删**:厂商的 `/models` 有可能只列你**有权限**的模型、或者干脆列不全,
|
|
99
|
+
所以本地多出来的条目保留(调用方可以提示用户,但不替他删配置)。
|
|
100
|
+
新加的条目只写 `id` —— `contextWindow` / `maxTokens` 走 qi 的默认值,
|
|
101
|
+
要精确就照厂商文档在 `models.json` 里补(或让 `--preset` 的种子覆盖它)。
|
|
102
|
+
"""
|
|
103
|
+
models: list[Any] = entry.setdefault("models", [])
|
|
104
|
+
existing = [str(m.get("id")) for m in models if isinstance(m, dict) and m.get("id")]
|
|
105
|
+
added = [model_id for model_id in ids if model_id not in set(existing)]
|
|
106
|
+
for model_id in added:
|
|
107
|
+
models.append({"id": model_id})
|
|
108
|
+
return added, [model_id for model_id in existing if model_id not in ids]
|
qi_agent/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""核心数据模型:Skill / Message / 事件 / 工具结果。
|
|
2
|
+
|
|
3
|
+
角色相关类型(AgentConfig / AgentUnit / DataSource / McpServerSpec)随 P-E4c 移出 core ——
|
|
4
|
+
它们属于 qi-agents / qi-mcp(E1.1/E15)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Skill:
|
|
16
|
+
name: str
|
|
17
|
+
description: str # 进 system prompt(渐进披露)
|
|
18
|
+
path: Path # SKILL.md 位置(用时 read 全文)
|
|
19
|
+
source: str = "" # 来源标签(诊断用:qi-global / agents-project / agent …)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
@dataclass
|
|
24
|
+
@dataclass
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolCall:
|
|
27
|
+
id: str
|
|
28
|
+
name: str
|
|
29
|
+
args: dict
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# 工具执行状态(ToolOutcome.status)
|
|
33
|
+
TOOL_OK = "ok"
|
|
34
|
+
TOOL_ERROR = "error"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ToolOutcome:
|
|
39
|
+
"""一次工具执行的**结构化**结果。
|
|
40
|
+
|
|
41
|
+
前端渲染工具卡片(status 点、耗时、退出码)只依赖这些字段,不再靠解析 `result`
|
|
42
|
+
字符串前缀。`result` 保留为**模型可见文本**,与旧版 `execute()` 的返回值逐字一致,
|
|
43
|
+
因此模型行为不变。
|
|
44
|
+
|
|
45
|
+
`duration_ms` 由 AgentRunner 统一计时,工具自己不必上报。
|
|
46
|
+
需要上报退出码的进程型工具(bash)返回本对象;其余工具继续返回 `str` 即可,
|
|
47
|
+
由 AgentRunner 包成 `ok`。
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
status: str = TOOL_OK # ok | error
|
|
51
|
+
result: str = "" # 模型可见文本
|
|
52
|
+
duration_ms: int = 0
|
|
53
|
+
exit_code: int | None = None # 仅进程型工具有意义
|
|
54
|
+
error: str | None = None # 机器可读原因: unknown_tool|tool_error|exception|timeout|denied
|
|
55
|
+
details: dict | None = None # 给**客户端**看的自由结构;不进 LLM 上下文
|
|
56
|
+
|
|
57
|
+
# `details` 是插件与 UI 之间**唯一的**下行通道(见 design/web.md §16)。
|
|
58
|
+
# 为什么需要它:`result` 是给模型看的散文,UI 不该去解析它;而
|
|
59
|
+
# status/duration_ms/exit_code 只够画一个状态点,画不了插件自己的东西
|
|
60
|
+
# (todo 清单、查询结果、进度……)。给它一个开放字典,宿主就不必为每个插件改接口。
|
|
61
|
+
#
|
|
62
|
+
# 约定:想画结构化 UI 时放 `details["ui"]`,词汇表见 design/web.md §16.2;
|
|
63
|
+
# 不认识 `ui` 的客户端会退回"折叠显示原始 JSON",所以**永不会白屏或报错**。
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ok(self) -> bool:
|
|
67
|
+
return self.status == TOOL_OK
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Message:
|
|
72
|
+
role: str # user | assistant | tool
|
|
73
|
+
content: str = ""
|
|
74
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
75
|
+
tool_call_id: str | None = None
|
|
76
|
+
agent_id: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class AgentEvent:
|
|
81
|
+
kind: str # agent_start|text_delta|assistant_message|tool_start|tool_end|text|agent_end|dispatch|opening|error
|
|
82
|
+
agent: str | None = None
|
|
83
|
+
tool: str | None = None
|
|
84
|
+
text: str = ""
|
|
85
|
+
data: dict = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
# 事件 data 的字段约定(消费者据此渲染,不必解析 text):
|
|
88
|
+
# text_delta → 逐字增量(在 text 上;Web 端打字靠它。CLI/TUI 只读回合末尾的 text 事件)
|
|
89
|
+
# assistant_message → 每轮 LLM 回复完成一次;{"step": int, "tool_calls": [名字]}。
|
|
90
|
+
# 宣布了工具调用的那条属于"过程"(runtime 落成 custom entry),
|
|
91
|
+
# 不带工具调用的那条才是最终回答(落成 message entry);也供轨迹视图分段
|
|
92
|
+
# tool_start → {"args": {...}}
|
|
93
|
+
# tool_end → {"status": "ok|error", "duration_ms": int,
|
|
94
|
+
# "exit_code": int|None, "error": str|None}
|
|
95
|
+
# agent_end → {"messages": [...], "usage": {"prompt_tokens": int,
|
|
96
|
+
# "completion_tokens": int, "total_tokens": int,
|
|
97
|
+
# "llm_calls": int, "turns": int}}
|
|
98
|
+
# dispatch → {"confidence", "source", "agent", "display_name", "reasoning"}
|
|
99
|
+
# opening → {"suggestions": [...]}
|
|
100
|
+
#
|
|
101
|
+
# 顺序不变量:一轮里 text_delta* 与 tool_start/tool_end 按**真实发生顺序**穿插
|
|
102
|
+
# (工具调用之间的叙述不再丢弃)。消费端不得把所有 text_delta 提到开头、
|
|
103
|
+
# 把所有工具行推到末尾 —— 那样会失去因果顺序。
|