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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/runtime.py ADDED
@@ -0,0 +1,1940 @@
1
+ """AutoRuntime(runtime.py):manual/auto + @点名 + 每轮重新路由 + 会话 JSONL 持久化。
2
+
3
+ stream(): 一次用户输入 → 事件(CLI/TUI/HTTP 共享的 consumer 源)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Iterable
14
+
15
+ from . import paths
16
+ from .auth import AuthOverride, AuthStore, resolve_key
17
+ from .compaction import (
18
+ DEFAULT_KEEP_RECENT_TOKENS,
19
+ DEFAULT_RESERVE_TOKENS,
20
+ branch_to_summarize,
21
+ compact,
22
+ estimate_tokens,
23
+ messages_tokens,
24
+ prepare_compaction,
25
+ should_compact,
26
+ summarize_branch,
27
+ summary_context_message,
28
+ )
29
+ from .abort import AbortSignal
30
+ from .config import (ProviderConfig, ResolvedModel, load_config, resolve_default_model,
31
+ resolve_model, resolve_router_model)
32
+ from .extensions import (
33
+ CommandRegistry,
34
+ ExtensionBus,
35
+ ExtensionContext,
36
+ ExtensionUi,
37
+ FlagRegistry,
38
+ ModelRegistryView,
39
+ ModelView,
40
+ RendererRegistry,
41
+ SessionView,
42
+ CliCommandRegistry,
43
+ )
44
+ from .llm import (
45
+ DEFAULT_THINKING_LEVEL,
46
+ ChatMessage,
47
+ LiteLLMClient,
48
+ LLMClient,
49
+ ThinkingLLMClient,
50
+ chat_message_from_dict,
51
+ normalize_thinking_level,
52
+ )
53
+ from .loader import LoadError, load_top_level_skills, resolve_base_prompt
54
+ from .system_prompt import build_system_prompt, default_base_prompt
55
+ from .models import AgentEvent
56
+ from .registry import CapabilityRegistry, ToolCatalog, discover_extensions
57
+ from .runner import AgentRunner, RunnerSettings, RunSpec
58
+ from .session import (MODEL_CHANGE, THINKING_LEVEL_CHANGE, Session, SessionStore,
59
+ context_settings, has_title)
60
+ from .titling import suggest_title
61
+ from .settings import (default_tools, extension_dirs, load_settings, load_settings_by_scope,
62
+ branch_summary_skip_prompt, model_thinking_level,
63
+ resolve_project_trust, session_dir)
64
+ from .tools import ToolContext, register_builtin_tools
65
+ from .tools.shell import session_env
66
+
67
+
68
+ #: core 里那个**唯一**的运行单元的标签(事件归属 + 会话 entry 的 `agent_id`)。
69
+ #: 角色概念整体交给 qi-agents(E1.1/E15),所以 core 只需要一个固定名字。
70
+ #: 选 "qi" 而不是 "general":基座提示词的身份就是 qi,而且它不再是“兼底角色”。
71
+ CORE_AGENT_NAME = "qi"
72
+
73
+
74
+ def _as_int(value: Any, default: int = 0) -> int:
75
+ """配置里的数字可能是字符串/None —— 收成 int,坏值回落默认(不抛)。"""
76
+ try:
77
+ return int(value)
78
+ except (TypeError, ValueError):
79
+ return default
80
+
81
+
82
+ @dataclass
83
+ class RuntimeConfig:
84
+ workdir: Path
85
+ # 轮次与请求超时都不在这里:
86
+ # · 轮次是 `QiRuntime(stop_after=…)` 的谓词(对齐 pi 的 shouldStopAfterTurn);
87
+ # · 请求级超时/重试归 provider 层(settings.json 的 `retry.provider`,见 llm.py)。
88
+ #
89
+ # P-E4c 删掉了 `confidence_min`:那是 Dispatcher 的旋钮,而分派已移出 core(E15)。
90
+
91
+
92
+ # 落盘工具结果的字符上限。tools/ 内置工具已自行截断(200 行 / 50k 字符),
93
+ # 但插件工具可能不截断——落盘前再过一道上限,避免单个工具撑破会话文件。
94
+ MAX_TOOL_ENTRY_CHARS = 8000
95
+ MAX_TOOL_DETAILS_CHARS = 8000
96
+ #: 自动命名最多等这么久(秒)。等不到就把落盘挂到回调上 —— 见 `_apply_title`。
97
+ TITLE_WAIT_S = 2.0 # 与 result 同档:插件不该让会话文件无界增长
98
+
99
+ #: 扩展用 `input` 的 `handled` 接管一轮、又没给 `text` 时的占位。
100
+ #: 不给提示会让用户以为程序卡住了(`ctx.ui` 要到 P-E3 才有,现在没有别的展示通道)。
101
+ INPUT_HANDLED_PLACEHOLDER = "(这一轮由扩展处理,没有文本输出)"
102
+
103
+
104
+ def _salvaged(final_text: str, partial: str) -> str:
105
+ """收尾用哪段文本:正常结束用 `agent_end` 的全文,被硬取消时用流式累计的半截。"""
106
+ return final_text if final_text else partial
107
+
108
+
109
+ def split_tool_list(text: str) -> list[str]:
110
+ """`"read, grep write"` → `["read", "grep", "write"]`(逗号 / 空格 / 顿号都行)。"""
111
+ return [part for part in text.replace(",", " ").replace("、", " ").split() if part]
112
+
113
+
114
+ def parse_model_flag(spec: str) -> tuple[str, str | None]:
115
+ """`--model` 的写法对齐 pi:`provider/模型`,可以带 `:<思考级别>` 后缀。
116
+
117
+ → `("provider/模型", 思考级别或 None)`。**只有后缀是已知的思考级别才算** ——
118
+ 否则模型 id 自带的冒号会被误切(如 `openrouter/x:free` 里的 `:free` 不是级别)。
119
+ """
120
+ from .llm import THINKING_LEVELS
121
+
122
+ text = spec.strip()
123
+ head, sep, tail = text.rpartition(":")
124
+ if sep and "/" in head and tail.strip().lower() in THINKING_LEVELS:
125
+ return head, tail.strip().lower()
126
+ if "/" not in text:
127
+ raise ValueError(f'`--model` 要写成 "provider/模型"(可带 ":<思考级别>"):收到 {spec!r}')
128
+ return text, None
129
+
130
+
131
+ def tool_source(catalog: Any, name: str) -> str:
132
+ """工具名 → 来源(`builtin` / 扩展名 …)。`source_info` 只由 `register_tool` 盖章。"""
133
+ tool = catalog.get(name)
134
+ if tool is None:
135
+ return ""
136
+ return str((getattr(tool, "source_info", None) or {}).get("source") or "")
137
+
138
+
139
+ class QiRuntime:
140
+ """装配好的一次性运行时(每进程一个):装载配置/插件/agents,提供 stream()。"""
141
+
142
+ def __init__(self, cwd: Path | None = None, runtime_cfg: RuntimeConfig | None = None,
143
+ session_store: SessionStore | None = None,
144
+ llm: LLMClient | None = None,
145
+ skills_enabled: bool = True,
146
+ thinking_level: str | None = None,
147
+ stop_after: Callable[[int], bool] | None = None,
148
+ extra_skill_paths: Iterable[Path] | None = None,
149
+ extra_extension_paths: Iterable[Path] | None = None,
150
+ approve_project: bool | None = None,
151
+ has_ui: bool = False,
152
+ ui_frontend: Any = None,
153
+ mode: str | None = None,
154
+ extension_flags: Iterable[str] | None = None,
155
+ tools: str | None = None,
156
+ exclude_tools: str | None = None,
157
+ no_tools: bool = False,
158
+ no_builtin_tools: bool = False,
159
+ append_system_prompt: Iterable[str] | None = None,
160
+ base_prompt_override: str | None = None,
161
+ no_extensions: bool = False,
162
+ no_context_files: bool = False,
163
+ session_dir_path: Path | str | None = None,
164
+ model_override: str | None = None,
165
+ api_key: str | None = None,
166
+ scoped_models: Iterable[str] | None = None):
167
+ self.cwd = Path(cwd) if cwd else Path.cwd()
168
+ # 旧版扁平布局 → ~/.qi/agent/(幂等;显式设了 QI_AGENT_HOME 时不动)
169
+ paths.ensure_layout()
170
+ self.cfg, self.config_files = load_config(self.cwd)
171
+ self.settings, self.settings_files = load_settings(self.cwd)
172
+ self._has_ui = has_ui # `ctx.has_ui`:交互式前端=True,`-p`/脚本=False
173
+ #: 同一 runtime 对同一会话只发一次 `session_start`(见 `start_session`)
174
+ self._started_sessions: set[str] = set()
175
+ self.runtime_cfg = runtime_cfg or RuntimeConfig(workdir=self.cwd)
176
+ # 轮次政策由嵌入方给(对齐 pi 的 shouldStopAfterTurn):None = 不限
177
+ # (qi 自己不设上限 —— 钩子留着但生产代码不传,同 pi 定义了却不实现它)
178
+ self.stop_after = stop_after
179
+ self.workdir = self.runtime_cfg.workdir
180
+ # 基座:项目 .qi/SYSTEM.md > ~/.qi/agent/SYSTEM.md;都没有则空串
181
+ # (空串 = 用 system_prompt.py 里的代码内默认基座,见 docs/configuration.md)
182
+ self.base_prompt, self.base_prompt_source = resolve_base_prompt(self.cwd)
183
+ # `--system-prompt`:命令行直接给基座 —— 与 `.qi/SYSTEM.md` **同一语义**(整体替换)
184
+ if base_prompt_override and base_prompt_override.strip():
185
+ self.base_prompt, self.base_prompt_source = base_prompt_override.strip(), "cli"
186
+ # `--no-context-files/-nc`:关掉 AGENTS.md / CLAUDE.md 的注入
187
+ self._no_context_files = no_context_files
188
+ #: `-a` / `-na` 的原始三态(None = 没表态)。有表态时**不问** `project_trust` 事件
189
+ #: —— 用户当场的直接指令优先于程序化策略。
190
+ self._approve_flag = approve_project
191
+ #: 第二趟装载要用(`-e` 显式给的路径)。
192
+ self._extra_extension_paths = list(extra_extension_paths or ())
193
+ #: `ctx.reload()` 重扫技能时要用(同一次运行里 CLI 给的额外技能路径)。
194
+ self._extra_skill_paths = list(extra_skill_paths or ())
195
+ # `--models <patterns>`:本次运行 Ctrl+P 的轮换清单。**不回写 settings** ——
196
+ # 命令行给的东西不该静默落盘(要持久化走 TUI 的 `/scoped-models`)。
197
+ self.scoped_models: list[str] | None = (
198
+ [str(item).strip() for item in scoped_models if str(item).strip()]
199
+ if scoped_models is not None else None)
200
+ # `--append-system-prompt <text>`(可重复):追加到**每回合** system prompt 的末尾。
201
+ # 多段用空行连接;全空则 None(不占位)。
202
+ self.append_system_prompt = "\n\n".join(
203
+ part.strip() for part in (append_system_prompt or ())
204
+ if part and part.strip()) or None
205
+ if session_dir_path is not None:
206
+ # `--session-dir`:命令行优先于 settings.sessionDir(pi 同名旗标)
207
+ self.sessions = SessionStore(root=Path(session_dir_path).expanduser())
208
+ elif session_store is not None:
209
+ self.sessions = session_store
210
+ else:
211
+ # sessionDir(settings.json)覆盖默认会话目录;对齐 pi 的优先级链
212
+ override_dir = session_dir(self.settings, self.cwd)
213
+ self.sessions = SessionStore(root=override_dir) if override_dir else SessionStore()
214
+
215
+ self.catalog = ToolCatalog()
216
+ register_builtin_tools(self.catalog)
217
+ self.capabilities = CapabilityRegistry()
218
+ #: 启动提示 + handler 异常的汇总通道(runtime 不做 IO,前端自己决定怎么展示)。
219
+ #: 必须在 `ExtensionBus` **之前**建:总线拿它当 handler 异常的去处。
220
+ self.notes: list[str] = []
221
+ # 事件总线:扩展在 `register(api)` 里 `api.on(...)` 订阅的东西都落在这里。
222
+ # 零扩展时 `is_empty` = True,宿主可以据此跳过整条派发路径。
223
+ # 同一个对象也挂着**扩展之间**的消息频道(`api.events`)。
224
+ self.bus = ExtensionBus(notes=self.notes)
225
+ #: 扩展的工具集覆盖(`api.setActiveTools`)。None = 按 agent 的 `tools` 解析
226
+ self._tool_override: set[str] | None = None
227
+ # 信任门控(P-E1 / E16):未信任 → **不扫**项目级扩展(扩展是仓库控制的任意代码)。
228
+ # 提示不在这里打印(runtime 不做 IO):攒进 `self.notes`,由前端决定怎么展示。
229
+ #: 当前回合绑定的会话(`api.appendEntry` / `ctx.session_manager` 靠它读/写)。
230
+ #: 由 `stream()` 的 wrapper 设置并在 `finally` 里清掉。
231
+ self._active_session: Session | None = None
232
+ #: **选会话时就绑定的**那个会话(前端调 `bind_session()`)。与 `_active_session` 分开:
233
+ #: 后者只管一个回合,而换模型 / 换思考级别是**回合外**发生的(扩展在 handler 里
234
+ #: `api.setModel`、TUI 的 `/model`),那时也必须知道该往哪个文件里记一笔。
235
+ self._bound_session: Session | None = None
236
+ #: 上一轮**已还原过设置**的那个会话 id(见 `bind_session`)。
237
+ #:
238
+ #: 判据是"绑的会话换了没有",**不是**"这个 id 还原过没有":早先记的是一个
239
+ #: `set[str]`,于是同一 runtime 里 A → B → A 切回来时,A 不再还原 ——
240
+ #: 而一个 runtime 服务多个会话正是 web 的常态(`runtime_for` 按 cwd 缓存),
241
+ #: 表现是"切回会话 A,却还拿着 B 的模型"。同级重绑(每回合开跑前都会调一次)
242
+ #: 仍然不重复还原,那才是这个判据要防的事。
243
+ self._restored_session: str | None = None
244
+ #: `--model` / `--thinking`(或 `--model p/m:级别`)在命令行上给过 —— 命令行优先级
245
+ #: **高于**会话里记的旧值,所以这类运行不从会话恢复(见 `_restore_context_settings`)。
246
+ self._model_pinned = model_override is not None
247
+ #: 扩展主动发的消息(`api.sendMessage` / `sendUserMessage`),按**送达时机**分桶。
248
+ #: 元素是 `(文本, 来源扩展, 调用方式)`。steer / follow_up 由 runner 在回合内排空;
249
+ #: next_turn 留到下一次用户输入(见 `_drain_messages` 与 `_stream_inner` 开头)。
250
+ self._pending_messages: dict[str, list[dict[str, Any]]] = {
251
+ "steer": [], "follow_up": [], "next_turn": []}
252
+ # 信任决定**只从用户级 settings 读**(`defaultProjectTrust`)。项目级那份不能自己声明
253
+ # “我可信”:否则仓库只要提交一行 `"defaultProjectTrust": "always"` 就能让自己的
254
+ # 任意代码跑起来 —— 而这正是这道门控要防的事(实测过:确实能跑)。
255
+ # pi 同模型:它的信任决定也存在用户 home 里(`trust.json`),不在仓库里。
256
+ scopes = load_settings_by_scope(self.cwd)
257
+ from .trust import TrustStore # 局部导入:信任库只在构造时用一次
258
+
259
+ self.project_trusted, self.trust_reason = resolve_project_trust(
260
+ scopes.get("user"), approve=approve_project, has_ui=has_ui,
261
+ stored=TrustStore().get(self.cwd))
262
+ #: 信任是否已被**最终**定下(见 `_bind_project_trust`)。构造期那份可能是临时的。
263
+ self._trust_bound = False
264
+ #: 本会话里思考级别被**显式**设过吗(是 → 换模型不再按 `modelThinkingLevels` 覆盖)。
265
+ #: 记的是**会话 id** 而不是一个 bool:显式选择属于它被设的那个会话,否则 web 那种
266
+ #: "一个 runtime 服务多个会话"的场景里,在 A 上设一次之后切到 B 也还带着它
267
+ #: (A 的显式选择泄漏给了 B)。判断见 `_level_pinned()`。
268
+ self._level_pins: set[str] = set()
269
+ #: 思考级别被**命令行**钉住(`--thinking`,或 `--model p/m:级别`)—— 对所有会话生效。
270
+ #: 与 `_level_pins` 分开:那是会话级的,这个是**整场运行**的。
271
+ #: 命令行给的值是当次显式覆盖,不该被会话里记的旧值顶掉(与 `_model_pinned` 对称)。
272
+ self._level_pinned_cli = False
273
+ declared_by_repo = (getattr(scopes.get("project"), "defaultProjectTrust", None)
274
+ or "").strip()
275
+ if declared_by_repo:
276
+ # 不静默:一个仓库自称可信是值得报警的事
277
+ self.notes.append(
278
+ f"项目 settings 里写了 defaultProjectTrust={declared_by_repo} —— **已忽略**:"
279
+ "信任决定不能由仓库自己声明(用 `qi -a`,或写进**用户级** settings)")
280
+ if not self.project_trusted and paths.project_extensions_dir(self.cwd).is_dir():
281
+ self.notes.append(
282
+ f"未信任项目({self.trust_reason}):`.qi/extensions/` 未加载;用 `qi -a` 信任")
283
+ legacy_ext = paths.legacy_project_extensions_dir(self.cwd)
284
+ if legacy_ext is not None:
285
+ self.notes.append(
286
+ f"检测到旧目录 `{legacy_ext.relative_to(self.cwd)}` —— 已改名为 "
287
+ f"`extensions/`,请手动改名(不会自动改仓库内容)")
288
+ # `ctx.ui`:前端(TUI)在构造时把自己的实现递进来;没有前端时它按调用方给的
289
+ # default 回答,所以 `-p` 不会卡在这里。notes 共用同一个列表 —— 无界面时
290
+ # `ui.notify` 落进来就自动变成启动提示。
291
+ #
292
+ # 为什么走构造参数而不是一个 `set_ui_frontend()`:后者多一个“别忘了调”的次序隐患,
293
+ # 而且每次给假运行时加方法都会撞一遍。
294
+ # (web 不行 —— 每个浏览器连接是一个不同的前端,那边要按回合解析,见 §5.1。)
295
+ #
296
+ # `mode` 是 pi 的 `ExtensionMode`(tui / rpc / json / print):组件层 ctx.ui
297
+ # 只在 tui 下有意义,扩展用 `ctx.mode` 做门控。不显式给就按 has_ui 推。
298
+ self.mode = mode or ("tui" if has_ui else "print")
299
+ self.ui = ExtensionUi(frontend=ui_frontend, notes=self.notes, mode=self.mode,
300
+ bus=self.bus,
301
+ ctx_factory=lambda signal=None: self.extension_ctx(signal))
302
+ #: 渲染回调登记处(`registerMessageRenderer` / `registerEntryRenderer` /
303
+ #: `registerMarkdownTransformer`)。前端按它决定怎么画 —— TUI-only。
304
+ self.renderers = RendererRegistry()
305
+ #: `resources_discover` 带给我们的提示词/主题路径(前端从这儿取)
306
+ self.extra_resource_paths: list[Path] = []
307
+ #: 正在跑回合吗(`ctx.isIdle()`)。由 `stream()` 的 wrapper 维护。
308
+ self._idle = True
309
+ #: 空闲等待用的信号(`ctx.waitForIdle()`)
310
+ self._idle_event = asyncio.Event()
311
+ self._idle_event.set()
312
+ #: 当前回合的中断信号(`ctx.abort()` 的回退路径)
313
+ self._abort_signal: AbortSignal | None = None
314
+ #: 最近一次建好的 system prompt(`ctx.getSystemPrompt()` 优先返回它)
315
+ self._last_system_prompt: str | None = None
316
+ #: 扩展请求“空闲时开一轮”的队列(`sendUserMessage(trigger_turn=True)`)。
317
+ #: 前端在本轮/命令处理完后用 `take_turn_request()` 领取。
318
+ self._turn_requests: list[str] = []
319
+ #: 前端注入的退出实现(`ctx.shutdown()`);没注入时是 no-op。
320
+ self._shutdown: Any = None
321
+ # 扩展命令/快捷键的汇合点(TUI 按它分发 `/cmd` 与按键)
322
+ self.commands = CommandRegistry()
323
+ # CLI 旗标(扩展用 `registerFlag` 声明;值走 core 的 `--ext name=value`)
324
+ self.flags = FlagRegistry()
325
+ #: `--ext` 里**不能用**的那些(名字打错 / 值非法)。与 `notes` 分开:
326
+ #: 这个是致命的(用户打错了命令行),CLI 据此退出码 2;notes 只是告知。
327
+ self.flag_errors: list[str] = []
328
+ # 附加扩展目录先算好:嵌在 kwargs 里会让这次调用看不出“传了哪几样”
329
+ if no_extensions:
330
+ # `--no-extensions`(pi):关掉**发现**(entry point / 内建目录 / settings),
331
+ # 但 `-e` 显式给的那些仍然加载
332
+ extra_extensions: list[Any] = list(extra_extension_paths or ())
333
+ else:
334
+ extra_extensions = extension_dirs(
335
+ self.cwd, trusted=self.project_trusted,
336
+ extra=list(extra_extension_paths or ()))
337
+ self.extensions = discover_extensions(
338
+ self.catalog, self.capabilities, self.cwd,
339
+ cli_commands=CliCommandRegistry(),
340
+ renderers=self.renderers,
341
+ bus=self.bus,
342
+ host=self,
343
+ commands=self.commands,
344
+ flags=self.flags,
345
+ on_warning=self.notes.append,
346
+ extra_dirs=extra_extensions,
347
+ project_trusted=self.project_trusted,
348
+ no_discovery=no_extensions)
349
+ # `--ext` 的值要在**扩展声明之后**才能解析(名字与类型都在那边),所以放在这里
350
+ for pair in extension_flags or ():
351
+ problem = self.flags.provide(str(pair))
352
+ if problem:
353
+ self.flag_errors.append(problem)
354
+ self.notes.extend(self.flags.problems)
355
+ # 有角色目录、但没装能读它的扩展 —— 这是最容易"文件在那里但毫无作用"的一种静默
356
+ self._note_missing_agent_support()
357
+
358
+ # 顶层技能(~/ .agents > qi 全局 > .agents 项目 > qi 项目 > settings)。
359
+ # v3:技能是 **core** 的能力(与角色无关),所以它直接进提示词,不再“随 agent 绑定”。
360
+ self.top_skills = load_top_level_skills(
361
+ self.cwd, self.settings,
362
+ list(extra_skill_paths or ()),
363
+ enabled=skills_enabled and self.settings.skillsEnabled,
364
+ )
365
+
366
+ auth: AuthStore = AuthOverride(api_key) if api_key else AuthStore()
367
+ #: 换模型时要重建客户端,所以凭证存储要留在身上(`set_model` 用)
368
+ self._auth = auth
369
+ if model_override:
370
+ # `--model`:`provider/模型[:思考级别]`(对齐 pi)
371
+ ref, _flag_thinking = parse_model_flag(model_override)
372
+ provider, _, model_name = ref.partition("/")
373
+ default: ResolvedModel = resolve_model(self.cfg, provider.strip(), model_name.strip())
374
+ else:
375
+ _flag_thinking = None
376
+ default = resolve_default_model(self.cfg, self.cwd)
377
+ # 思考级别:显式传参(CLI --thinking)> `--model` 的后缀 > settings.defaultThinkingLevel
378
+ # > `DEFAULT_THINKING_LEVEL`(pi 默认 medium)。四级优先(pi 的口径):`--thinking` >
379
+ # `--model provider/id:<级别>` > `modelThinkingLevels[该模型]` > `defaultThinkingLevel`。
380
+ level = (thinking_level if thinking_level is not None
381
+ else _flag_thinking if _flag_thinking is not None
382
+ else model_thinking_level(self.settings, default)
383
+ or self.settings.defaultThinkingLevel
384
+ or DEFAULT_THINKING_LEVEL)
385
+ self.thinking_level = normalize_thinking_level(level)
386
+ # 命令行给过级别(任一形式)→ 整场运行都不再从会话里还原级别:
387
+ # 它是**当次**的显式覆盖,与 `--model` 同级(见 `_restore_context_settings`)。
388
+ self._level_pinned_cli = thinking_level is not None or _flag_thinking is not None
389
+ self.llm_exec = llm or self._make_client(default)
390
+ #: 还原失败时的**退路**。`llm_exec` 是**可变**的(换模型会整个换掉它),
391
+ #: 而"会话里记的模型没能恢复"时不能只是记一句 note 就完事 —— 那会把它留在
392
+ #: **上一个会话**的模型上(web 那种一个 runtime 服务多个会话的场景里必然发生)。
393
+ #: 保留构造期这份,才谈得上"已用默认模型继续"。见 `_restore_context_settings`。
394
+ self._default_llm_exec = self.llm_exec
395
+ # CLI 的工具收窄(pi 的 `--tools` / `-xt` / `-nt` / `-nbt`)。
396
+ # 必须等扩展注册完(B)才能算 —— `--no-builtin-tools` 要知道每个工具的来源。
397
+ self._apply_tool_flags(tools=tools, exclude_tools=exclude_tools,
398
+ no_tools=no_tools, no_builtin_tools=no_builtin_tools)
399
+
400
+ def _apply_tool_flags(self, *, tools: str | None, exclude_tools: str | None,
401
+ no_tools: bool, no_builtin_tools: bool) -> None:
402
+ """把工具集来源落成一次"工具集覆盖"(对齐 pi 的口径)。
403
+
404
+ 优先级照 pi(`docs/usage.md` 的旗标表 + `docs/settings.md` 的 `defaultTools` 段):
405
+ `--tools` 是**严格白名单**(替换默认集),`--no-tools` 全禁,`--no-builtin-tools`
406
+ 只去内置;`--exclude-tools` **最后**过滤结果。`--tools` 与后两者矛盾,已在 CLI 拦住。
407
+
408
+ **没给任何 CLI 旗标**时才看 `settings.defaultTools`(pi 同名段):它只挑**内置**那一档,
409
+ 扩展装的工具照旧全留 —— pi 的原话是 "An empty array starts with no built-in tools
410
+ while preserving extension/custom tools",所以 `defaultTools: []` 是个**有意义**的配置
411
+ (不要内置、只留扩展),不能与"没配"混为一谈(那是 `None`)。
412
+
413
+ 未注册的工具名**不静默丢掉**:记一条 note(与 `api.setActiveTools` 同一口径)。
414
+ """
415
+ defaults = default_tools(self.settings)
416
+ if not any((tools, exclude_tools, no_tools, no_builtin_tools)):
417
+ if defaults is None: # 没配 → 不动覆盖(None ≠ 空集)
418
+ return
419
+ builtin = {n for n in self.catalog.names
420
+ if tool_source(self.catalog, n) == "builtin"}
421
+ keep = set(defaults)
422
+ weird = sorted(keep - builtin)
423
+ if weird:
424
+ self.notes.append("settings.defaultTools 里有未注册的内置工具:"
425
+ + "、".join(weird))
426
+ chosen = sorted((set(self.catalog.names) - builtin) | (keep & builtin))
427
+ self.set_tool_names(chosen)
428
+ self.notes.append("本次运行的工具集(defaultTools):"
429
+ + ("、".join(chosen) if chosen else "(空)"))
430
+ return
431
+ known = set(self.catalog.names)
432
+ unknown: list[str] = []
433
+ if tools:
434
+ wanted = split_tool_list(tools)
435
+ unknown += [n for n in wanted if n not in known]
436
+ chosen = [n for n in wanted if n in known]
437
+ elif no_tools:
438
+ chosen = []
439
+ elif no_builtin_tools:
440
+ chosen = sorted(n for n in known if tool_source(self.catalog, n) != "builtin")
441
+ else:
442
+ chosen = sorted(known)
443
+ if exclude_tools:
444
+ dropped = split_tool_list(exclude_tools)
445
+ unknown += [n for n in dropped if n not in known]
446
+ gone = set(dropped)
447
+ chosen = [n for n in chosen if n not in gone]
448
+ if unknown:
449
+ self.notes.append("工具旗标里有未注册的名字:" + "、".join(dict.fromkeys(unknown)))
450
+ self.set_tool_names(chosen)
451
+ self.notes.append("本次运行的工具集:" + ("、".join(chosen) if chosen else "(空)"))
452
+
453
+ # ── 工具上下文(每个会话独立) ──
454
+ def _has_agent_support(self) -> bool:
455
+ """这个运行里有没有「角色」这件事。
456
+
457
+ 判据用**行为**而不是包名:qi-agents 的全部工作就是注册 `subagent` 工具,
458
+ 而它的名字随安装通道而变(entry point 叫 `agents`、目录通道叫目录名)。
459
+ """
460
+ if "subagent" in self.catalog.names:
461
+ return True
462
+ return any(str(name).replace("_", "-") in {"agents", "qi-agents"}
463
+ for name in (self.extensions or ()))
464
+
465
+ def _note_missing_agent_support(self) -> None:
466
+ """有角色目录、但没有能读它的扩展 → 说一句(否则那些文件静默无效)。
467
+
468
+ 与 design/extensions-design.md 的迁移清单同源:`.qi/agents/` 归 qi-agents,
469
+ core 不读它。未信任的项目目录也有可能读不到 —— 但那种情况另有提示
470
+ (信任门控自己会说),这里只说"扩展不在"。
471
+ """
472
+ if self._has_agent_support():
473
+ return
474
+ roots = [paths.global_home() / paths.AGENTS_DIR_NAME,
475
+ paths.project_home(self.cwd) / paths.AGENTS_DIR_NAME]
476
+ found = [root for root in roots
477
+ if root.is_dir() and any(root.glob(f"*/{paths.AGENT_FILE_NAME}"))]
478
+ if not found:
479
+ return
480
+ listed = "、".join(str(root) for root in found)
481
+ self.notes.append(
482
+ f"检测到角色目录({listed})但没装读它的扩展:`qi-agents` 未加载 —— "
483
+ f"这些角色不会被使用(装法:`pip install qi-agents`,然后 `qi doctor` 确认)")
484
+
485
+ def _tool_ctx(self, agent_name: str) -> ToolContext:
486
+ """工具上下文。
487
+
488
+ v3 里没有 `data_sources` 了 —— 数据源实例住在 agent 目录里,而 agent 归 qi-agents
489
+ (E15/E18:它会把作用域交给提供该配置种类的扩展自己去读)。
490
+ """
491
+ return ToolContext(agent_name=agent_name, workdir=self.workdir,
492
+ ask=self._ask,
493
+ shell_path=self.settings.shellPath,
494
+ project_trusted=self.project_trusted,
495
+ ui=self.ui,
496
+ session_env=self._session_env())
497
+
498
+ def _session_env(self) -> dict[str, str]:
499
+ """这一回合的会话环境变量(pi 的 `exposeSessionEnvironment`)。
500
+
501
+ 取的是**绑定的会话 + 当前模型 + 当前级别** —— 与 `ctx.model` / `ctx.thinkingLevel`
502
+ 同一份真实来源,所以 bash 里 `env | grep QI_` 得到的答案与 footer 显示的一致。
503
+ """
504
+ session = self.current_session()
505
+ view = self._model_view()
506
+ return session_env(
507
+ session_id=getattr(session, "id", None),
508
+ session_file=str(getattr(session, "path", "") or "") or None,
509
+ provider=getattr(view, "provider", None) if view is not None else None,
510
+ model=getattr(view, "id", None) if view is not None else None,
511
+ reasoning_level=self.thinking_level)
512
+
513
+ def _child_session_env(self, model_ref: Any) -> dict[str, str]:
514
+ """子运行的环境变量:模型按**子运行自己的**那个算,其余继承会话。
515
+
516
+ pi 的 `ctx.model` 在子运行里就是子运行的模型;若照抄父的 `QI_MODEL`,
517
+ 子 agent 自查时会得到"我在用别的模型"这种假答案。
518
+ """
519
+ env = dict(self._session_env())
520
+ if not model_ref:
521
+ return env
522
+ provider, _, model_id = str(model_ref).partition("/")
523
+ if provider and model_id:
524
+ env["QI_PROVIDER"] = provider
525
+ env["QI_MODEL"] = model_id
526
+ return env
527
+
528
+ # ── 受管子运行(扩展的 `runAgent` / E12)──
529
+ def _make_client(self, resolved: ResolvedModel) -> LiteLLMClient:
530
+ """建 provider 客户端并接上**三件套事件**(唯一入口)。
531
+
532
+ 三个调用点(默认客户端 / 换模型 / 子运行)共用同一份接线 —— 否则“某个路径上的
533
+ 扩展看不到请求”会变成一种只在特定入口复现的鬼故事。
534
+ """
535
+ return LiteLLMClient(resolved, self._auth,
536
+ thinking_level=self.thinking_level,
537
+ retry=self.settings.retry,
538
+ thinking_budgets=self.settings.thinkingBudgets,
539
+ bus=self.bus,
540
+ extension_ctx=lambda signal: self.extension_ctx(signal))
541
+
542
+ def _client_for(self, model_ref: str | None) -> Any:
543
+ """子运行用的客户端:给了 `"provider/model"` 就**另建一个**,不给就用当前的。
544
+
545
+ 关键点:另建时**不碰** `self.llm_exec` —— 子运行换模型不应改变父会话的模型。
546
+ """
547
+ if not model_ref:
548
+ return self.llm_exec
549
+ provider, _, model = str(model_ref).partition("/")
550
+ if not model:
551
+ raise ValueError(f'模型要写成 "provider/model",收到 {model_ref!r}')
552
+ return self._make_client(resolve_model(self.cfg, provider, model))
553
+
554
+ async def run_agent(self, spec: Any, task: str, *, abort: AbortSignal | None = None,
555
+ on_event: Any = None) -> str:
556
+ """在宿主内起一个**受管的子运行**(E12):独立上下文、自己的工具集与模型。
557
+
558
+ **与 `stream()` 的区别**(为什么不直接嵌套 stream):子运行**不碰会话** ——
559
+ 不落盘、不派发、不改 active_agent。它是“借一次工具循环”,不是“再来一轮对话”。
560
+ 所以它不会污染父会话的上下文,也不会在历史上多出几条看不懂的消息。
561
+
562
+ **但扩展事件照常派发**(用同一个总线与 ctx):所以权限闸门(`tool_call`)
563
+ 对子运行一样生效 —— 这是“进程内”相对“子进程”的一个真实好处(子进程里宿主看不见)。
564
+
565
+ `spec` 是 dict:`system_prompt`(必填)/ `tools`(缺省**继承父**的当前集合,
566
+ 而不是 catalog 全部 —— 给子运行比父多出权限是提权)/ `model`(缺省继承父)/ `name`。
567
+ 递归深度由**扩展自己**管(E12):宿主不猜你允许多深。
568
+ """
569
+ data = spec if isinstance(spec, dict) else {
570
+ "system_prompt": getattr(spec, "prompt", ""),
571
+ "tools": getattr(spec, "tools", None),
572
+ "name": getattr(spec, "name", None)}
573
+ prompt = str(data.get("system_prompt") or "").strip()
574
+ if not prompt:
575
+ raise ValueError("runAgent 需要 system_prompt(子运行要有自己的提示词)")
576
+ tools = data.get("tools")
577
+ if tools is None:
578
+ tools = self.tool_names() # 继承父的当前集合(不是 catalog 全部)
579
+ name = str(data.get("name") or "subagent")
580
+ run_spec = RunSpec(name=name, prompt=prompt, tools=[str(t) for t in tools])
581
+ ctx = ToolContext(agent_name=name, workdir=self.workdir, ask=self._ask,
582
+ shell_path=self.settings.shellPath, ui=self.ui,
583
+ project_trusted=self.project_trusted,
584
+ session_env=self._child_session_env(data.get("model")),
585
+ abort=abort)
586
+ runner = AgentRunner(run_spec, self.catalog, self._client_for(data.get("model")),
587
+ tool_ctx=ctx,
588
+ bus=self.bus,
589
+ extension_ctx=lambda signal: self.extension_ctx(signal),
590
+ report=self.notes.append)
591
+ final = ""
592
+ async for event in runner.run(task, abort=abort):
593
+ if event.kind == "agent_end":
594
+ final = event.text or final
595
+ if on_event is not None:
596
+ on_event(event)
597
+ return final
598
+
599
+ async def _ask(self, question: str) -> str | None:
600
+ """`clarify` 工具的交互入口。
601
+
602
+ 有 UI 前端时走 `ctx.ui.input` —— 这修掉一个旧缺陷:`_ask` 以前无条件返回 None,
603
+ 所以**内置的 clarify 在 TUI 里也从没问过人**(它一直只会说“无人可问”)。
604
+ 没前端(无头/脚本)仍返回 None,保持“不吃 stdin”的行为。
605
+ """
606
+ if not self.ui.has_frontend:
607
+ return None
608
+ return await self.ui.input(question, title="澄清")
609
+
610
+ # ── 扩展上下文与事件 ──
611
+ def _model_label(self) -> str | None:
612
+ """`"provider/model"`;拿不到 spec 就 None。"""
613
+ view = self._model_view()
614
+ return str(view) if view is not None else None
615
+
616
+ def _model_view(self) -> ModelView | None:
617
+ """当前模型的 `ModelView`(pi 的 `ctx.model` 形状;`str` 子类所以旧用法不变)。"""
618
+ spec = getattr(self.llm_exec, "spec", None)
619
+ if spec is None:
620
+ return None
621
+ try:
622
+ return ModelView.from_resolved(spec)
623
+ except Exception: # noqa: BLE001 第三方/测试替身可能不是 ResolvedModel
624
+ return None
625
+
626
+ def extension_ctx(self, signal: AbortSignal | None = None,
627
+ session: Session | None = None) -> ExtensionContext:
628
+ """构造 handler 用的 `ctx`(§3.3 的最小集)。
629
+
630
+ **不是**缓存单例:每回合的信号不同(`signal`),而 ctx 冻结这些值。
631
+
632
+ `session` 缺省用回合绑定的那个(`_active_session`)—— 所以 runner 里的 handler
633
+ 拿到的 `ctx.session_manager` 就是当前会话;回合外(如 TUI 命令)拿不到时
634
+ `available` 为 False、写口报错。
635
+ """
636
+ return ExtensionContext(
637
+ cwd=self.cwd,
638
+ model=self._model_view(),
639
+ thinking_level=self.thinking_level,
640
+ signal=signal,
641
+ has_ui=self._has_ui,
642
+ project_trusted=self.project_trusted,
643
+ notes=self.notes,
644
+ ui=self.ui,
645
+ mode=self.mode,
646
+ session=session if session is not None else self._active_session,
647
+ scoped_models=tuple(self.scoped_models or ()),
648
+ host=self,
649
+ model_registry=ModelRegistryView(self),
650
+ session_manager=SessionView(session if session is not None
651
+ else self._active_session),
652
+ )
653
+
654
+ # ── provider(扩展的 `registerProvider`)──
655
+ def register_provider(self, name: str, config: dict, source: str) -> None:
656
+ """动态注册/覆盖一个 provider。**只改内存里的 cfg,不写 `models.json`。**
657
+
658
+ 写盘会让“跑一次带代理的扩展”永久改变用户的模型配置 —— 而那是个静默的副作用。
659
+ 所以扩展注册的 provider 只活在这个进程里(pi 允许扩展持久化目录元数据、
660
+ 带 generation 校验 —— 那是另一套机制,qi 没做)。
661
+ """
662
+ if name in self.cfg.providers:
663
+ self.notes.append(f"扩展 {source} 覆盖了已有的 provider {name}")
664
+ try:
665
+ self.cfg.providers[name] = ProviderConfig.model_validate(config)
666
+ except Exception as exc: # noqa: BLE001 pydantic 校验错
667
+ raise ValueError(f"provider {name} 配置不合法: {exc}") from exc
668
+
669
+ def append_extension_entry(self, custom_type: str, data: dict, source: str) -> None:
670
+ """扩展自定义 entry 的**唯一写口**(`api.appendEntry` 走这里)。
671
+
672
+ 章由宿主盖:`source`(哪个扩展写的)、`agent`(当时哪个角色在跑)。扩展自己拼 entry
673
+ 的话迟早会出现两种形状,而回放/诊断都得同时认两种。
674
+
675
+ 没有活动会话时**报错** —— 回合外的写往往是“想存但存错地方”的第一步。
676
+ """
677
+ session = self._active_session
678
+ if session is None:
679
+ raise RuntimeError("没有活动会话:appendEntry 只能在回合内调用")
680
+ self.sessions.append(session, {
681
+ "type": "custom", "custom_type": custom_type,
682
+ "source": source, "agent": CORE_AGENT_NAME,
683
+ "data": data})
684
+
685
+ def queue_extension_message(self, text: str, deliver_as: str, source: str,
686
+ kind: str = "sendMessage", *, custom_type: Any = None,
687
+ display: Any = None, details: Any = None,
688
+ trigger_turn: bool = False) -> None:
689
+ """扩展消息的唯一入队口(`api.send_message` / `send_user_message` 走这里)。
690
+
691
+ **不在这里落盘**:送达时机在 runner 手里(每次 LLM 调用前 / 本该收工时 / 下次输入),
692
+ 而“什么时候进对话”与“什么时候进文件”必须是同一个时刻 —— 先落盘会让历史里出现一条
693
+ 还没送达的消息。落盘在 `_drain_messages` 里做,与排空同时。
694
+
695
+ `trigger_turn` 且此刻**空闲**时走另一条路:不当成排队消息,而是记一条“该开一轮了”
696
+ 的请求(`take_turn_request()`),由前续流程开一轮 —— pi 的 `triggerTurn` 就是这个语义。
697
+ """
698
+ if trigger_turn and self._idle:
699
+ self._turn_requests.append(text)
700
+ return
701
+ bucket = self._pending_messages.get(deliver_as)
702
+ if bucket is None: # `api` 侧已经挡过,这里只是双保险
703
+ raise ValueError(f"未知的 deliver_as: {deliver_as!r}")
704
+ bucket.append({"text": text, "source": source, "kind": kind,
705
+ "custom_type": custom_type, "display": display,
706
+ "details": details})
707
+
708
+ def take_turn_request(self) -> str | None:
709
+ """取一条“扩展要求开一轮”的请求(没有则 None)。前端在空闲时调用。"""
710
+ return self._turn_requests.pop(0) if self._turn_requests else None
711
+
712
+ def _drain_messages(self, deliver_as: str) -> list[str]:
713
+ """排空某一档的待发消息,**同时落盘**。
714
+
715
+ 落盘放这里而不是 runner 里:runner 不认识会话(它的 `msgs` 是本地上下文表),
716
+ 而“谁负责落盘”只有一个答案才是稳的。
717
+
718
+ 回合外(`_active_session` 为空)只排空不落盘 —— 那种情况只可能是 next_turn
719
+ 在输入之前被取走,而它本来就还没进对话。
720
+ """
721
+ bucket = self._pending_messages.get(deliver_as) or []
722
+ if not bucket:
723
+ return []
724
+ texts = [str(item.get("text") or "") for item in bucket]
725
+ session = self._active_session
726
+ if session is not None:
727
+ for item in bucket:
728
+ entry: dict = {"type": "message", "role": "user",
729
+ "content": item.get("text") or "",
730
+ "agent_id": CORE_AGENT_NAME,
731
+ "injected_by": item.get("kind"),
732
+ "deliver_as": deliver_as, "source": item.get("source")}
733
+ # pi 的 `sendMessage({customType, display, details})`:进上下文的同时
734
+ # 带上由前端决定怎么画的元数据(customType 进 custom_type,同 qi 既有键名)
735
+ if item.get("custom_type"):
736
+ entry["custom_type"] = item["custom_type"]
737
+ if item.get("display") is not None:
738
+ entry["display"] = item["display"]
739
+ if item.get("details") is not None:
740
+ entry["details"] = item["details"]
741
+ self.sessions.append(session, entry)
742
+ bucket.clear()
743
+ return texts
744
+
745
+ async def _bind_project_trust(self) -> None:
746
+ """定信任(可被 `project_trust` 事件改),并按最终决定补装**项目级**扩展。
747
+
748
+ 为什么信任判定要推迟到这里:pi 的 `project_trust` 事件是 **async** 的(扩展可以投票),
749
+ 而构造期没有事件循环 —— TUI 在 `on_mount` 里构造 runtime(`tui.py`),那里已经在 loop 中,
750
+ `asyncio.run` 会直接抛 "cannot be called from a running event loop"(实测)。
751
+
752
+ 所以构造期先按 **fail-closed** 装完:用户级目录与 entry point 与项目信任无关,先装好正好
753
+ 让它们能在事件里投票 —— pi 的语义正是"只有用户级/CLI 扩展拥有投票权"。项目级那一半等
754
+ 决定下来再补(见 `_load_project_extensions`)。
755
+
756
+ 幂等;`-a` / `-na` 给过就**不发事件**(用户当场的指令优先)。
757
+ """
758
+ if self._trust_bound:
759
+ return
760
+ self._trust_bound = True
761
+ if self._approve_flag is not None or not self.bus.has("project_trust"):
762
+ return
763
+ result = await self.bus.emit_until(
764
+ "project_trust",
765
+ {"cwd": str(self.cwd), "has_ui": self._has_ui},
766
+ ctx=self.extension_ctx(),
767
+ # pi 的契约:`{trusted: "yes" | "no" | "undecided"}`,**首个 yes/no 拥有决定权**,
768
+ # `undecided` 继续往下走(所以 stop_values 按"键 → 取值集合"给)。
769
+ stop_values={"trusted": ("yes", "no")})
770
+ for source, exc in result.errors:
771
+ self.notes.append(f"扩展 {source} 的 project_trust 处理失败: {exc}")
772
+ verdict = str(result.payload.get("trusted") or "").strip().lower()
773
+ if verdict not in ("yes", "no"):
774
+ return # 没人下结论 → 构造期那份就是最终决定
775
+ decided = verdict == "yes"
776
+ if bool(result.payload.get("remember")):
777
+ # pi 的 `remember: true` → 落盘(下次启动直接用它,不再问)
778
+ try:
779
+ from .trust import TrustStore
780
+
781
+ TrustStore().set(self.cwd, decided)
782
+ self.notes.append("信任决定已记住(trust.json)")
783
+ except OSError as exc:
784
+ self.notes.append(f"信任决定没能记住: {exc}")
785
+ if decided == self.project_trusted:
786
+ self.trust_reason = f"扩展决定了{'信任' if decided else '不信任'}"
787
+ return
788
+ self.project_trusted = decided
789
+ self.trust_reason = f"扩展决定了{'信任' if decided else '不信任'}"
790
+ if decided:
791
+ # 只有"不信任 → 信任"需要动作;反方向不用做事 —— 构造期本来就是 fail-closed
792
+ # (一个项目级的扩展、配置都没装)。
793
+ self._load_project_extensions()
794
+
795
+ def _load_project_extensions(self) -> None:
796
+ """第二趟:只装**项目级**(构造期按 fail-closed 跳过的那半)。
797
+
798
+ 为什么只装项目级:用户目录与 entry point 第一趟已经装过,再来一遍会撞 catalog 的
799
+ "工具重复注册"。`extension_dirs(trusted=True)` 里筛出 project 作用域的那些即可 ——
800
+ 它们正是第一趟因为不信任而没拿到的。
801
+ """
802
+ extra = [item for item in extension_dirs(
803
+ self.cwd, trusted=True, extra=self._extra_extension_paths)
804
+ if item[1] == "project"]
805
+ loaded = discover_extensions(
806
+ self.catalog, self.capabilities, self.cwd,
807
+ cli_commands=CliCommandRegistry(),
808
+ renderers=self.renderers,
809
+ bus=self.bus, host=self, commands=self.commands, flags=self.flags,
810
+ on_warning=self.notes.append,
811
+ extra_dirs=extra, project_trusted=True, only_project=True)
812
+ self.extensions.extend(loaded)
813
+ self.notes.append("信任由扩展决定:已加载项目级扩展"
814
+ + (f"({len(loaded)} 个)" if loaded else ""))
815
+
816
+ async def start_session(self, session: Session, reason: str = "startup") -> None:
817
+ """告知扩展"会话已绑定"(pi 的 `session_start { reason }`)。
818
+
819
+ **幂等**:同一 runtime 对同一会话只派发一次。前端可能会在多个时机绑定同一会话,
820
+ 重复派发会把扩展的"开一次资源"变成开 N 次 —— 宿主自己记账,扩展不必防重。
821
+
822
+ handler 抛异常不往外传(§4 规则 3):记进 `notes`,会话照跑。扩展坏在启动时,
823
+ 用户至少能在界面上看到一行字,而不是"啥都没发生"。
824
+ """
825
+ # 信任必须在**任何**项目级资源被用到之前定下(零扩展时也照样要做)。
826
+ await self._bind_project_trust()
827
+ # 会话设置(模型 / 思考级别)在**通知扩展之前**定下来:扩展在 `session_start`
828
+ # 里就会读 `ctx.model` / `ctx.thinkingLevel`,那时必须已经是这个会话的值。
829
+ self.bind_session(session)
830
+ if session.id in self._started_sessions:
831
+ return
832
+ self._started_sessions.add(session.id)
833
+ if self.bus.is_empty:
834
+ return
835
+ event = await self.bus.emit(
836
+ "session_start",
837
+ {"reason": reason, "session": session.id, "cwd": str(self.cwd)},
838
+ ctx=self.extension_ctx())
839
+ for source, exc in event.errors:
840
+ self.notes.append(f"扩展 {source} 的 session_start 处理失败: {exc}")
841
+ # pi 在 `session_start` **之后**再发 `resources_discover`:扩展这时可以补技能/
842
+ # 提示词/主题路径。reason 只有 startup / reload 两个取值(pi 同形)。
843
+ await self._emit_resources_discover(
844
+ reason="reload" if reason == "reload" else "startup")
845
+
846
+ # ── 工具集(扩展 `setActiveTools` / `getActiveTools` 的后端)──
847
+ def tool_names(self) -> list[str]:
848
+ """本回合实际启用的工具名。
849
+
850
+ 覆盖(`setActiveTools`)优先;没有覆盖时就是 catalog 里的全部。
851
+ v3 里 agent 不再声明 `tools`(角色归 qi-agents),所以这里不再需要 unit 参数 ——
852
+ 需要收窄的场景就是 `setActiveTools`,那一条路径已经独立。
853
+ 覆盖里已经不在 catalog 的名字会被滤掉(`setActiveTools` 允许先写名字、
854
+ 工具随后才注册)。
855
+ """
856
+ if self._tool_override is not None:
857
+ return [n for n in sorted(self._tool_override) if n in self.catalog.names]
858
+ return sorted(self.catalog.names)
859
+
860
+ def set_tool_names(self, names: Iterable[str]) -> None:
861
+ """覆盖工具集(扩展走的入口是 `api.setActiveTools`),对**后续回合**生效。"""
862
+ self._tool_override = set(names)
863
+
864
+ # ── 会话绑定(设置类 entry 要写进哪个文件)──
865
+ def bind_session(self, session: Session | None) -> None:
866
+ """把「当前会话」定下来(前端选完会话就调一次)。
867
+
868
+ 为什么要有它:`_active_session` 只在**一个回合内**有效(`stream()` 里设、
869
+ `finally` 里清),而换模型 / 换思考级别是回合外发生的 —— TUI 的 `/model`
870
+ 在两次提问之间、扩展在自己的 handler 里都可能调。那时若没有绑定会话,
871
+ `model_change` 就不知道该写进哪个文件(pi 的 `sessionManager` 是常驻的,
872
+ qi 这一层以前不存在,所以补一个**显式**的绑定,而不是去猜)。
873
+
874
+ 传 `None` 解绑(会话被删掉时用)。绑定同时把会话里记的模型/级别恢复过来 ——
875
+ 与 pi 一样:**先看会话记了什么**,没有再退回 settings 默认。
876
+
877
+ **绑的会话换了才还原**(见 `_restored_session`):同级重绑(web 每回合开跑前
878
+ 都会调一次)不重复还原,而 A → B → A 切回来会 —— 后者以前不还原,于是
879
+ "切回 A 却还拿着 B 的模型"。
880
+ """
881
+ self._bound_session = session
882
+ if session is not None and session.id != self._restored_session:
883
+ self._restored_session = session.id
884
+ self._restore_context_settings(session)
885
+
886
+ def _level_pinned(self) -> bool:
887
+ """本回合的思考级别是否被**显式**设过(命令行 / 本会话里的 `/thinking`)。
888
+
889
+ 命令行那档对所有会话生效;会话那档只认**当前绑定的这个会话** ——
890
+ 否则"在 A 上设过"会泄漏到 B(见 `_level_pins`)。
891
+ """
892
+ if self._level_pinned_cli:
893
+ return True
894
+ session = self.current_session()
895
+ return session is not None and session.id in self._level_pins
896
+
897
+ def current_session(self) -> Session | None:
898
+ """当前会话:回合内用回合绑定的那个,否则用前端绑定的那个。"""
899
+ return self._active_session or self._bound_session
900
+
901
+ def _restore_context_settings(self, session: Session) -> None:
902
+ """按会话里记的设置类 entry 还原模型与思考级别(pi 的 `getSessionContextSettings`)。
903
+
904
+ 命令行显式给过(`--model` / `--thinking`)就不还原 —— 那是有意的当次覆盖,
905
+ 不该被历史里的旧值顶掉。
906
+
907
+ 解析不出来的模型(provider 已删、凭证没了)**退回默认并记一条 note**:
908
+ 静默用默认值会让人以为"切换没生效",而直接报错会让会话打不开 —— pi 的
909
+ `Could not restore model …` 也是这个取舍。
910
+ """
911
+ branch = session.branch()
912
+ if not branch:
913
+ # 新会话/空会话:没有历史可还原,把**当前**设置记成这个会话的起点
914
+ self._record_model_change(session)
915
+ self._record_thinking_change(session)
916
+ return
917
+ saved = context_settings(branch)
918
+ # **先看凭证**:provider 还在配置里、但密钥已经被删/没登录 —— 这时若照样还原,
919
+ # 用户会得到"一次注定 401 的请求",而按 pi 的口径(`hasConfiguredAuth`)应当
920
+ # 退回一个能用的模型。
921
+ #
922
+ # 判据用 `resolve_key(...).ok` 而不是 `has_configured_auth`:后者按 qi 的"预置
923
+ # 兜底"口径认**约定环境变量**(provider 名 → `DEFAULT_API_KEY_ENV`),比这里要的
924
+ # 严松度更宽 —— 沿用会话时"能不能真的拿到 key"才是那个问题。
925
+ resolved = None
926
+ if not self._model_pinned and saved["model"] is not None:
927
+ provider, model_id = saved["model"]
928
+ reason = ""
929
+ try:
930
+ candidate = resolve_model(self.cfg, provider, model_id)
931
+ except Exception as exc: # noqa: BLE001 配置问题不该让会话打不开
932
+ reason = str(exc)
933
+ else:
934
+ key = resolve_key(candidate.provider, candidate.api_key_ref, self._auth)
935
+ if key.ok:
936
+ resolved = candidate
937
+ else:
938
+ reason = key.source or "没有可用密钥"
939
+ if resolved is not None:
940
+ self.llm_exec = self._make_client(resolved)
941
+ else:
942
+ # **真的退回默认**,而不是只记一句 note:此刻 `llm_exec` 可能还是
943
+ # **上一个会话**的模型(web:一个 runtime 服务多个会话),留在那里
944
+ # 就会拿别人的模型跑,而提示词里还写着"已用默认模型继续" —— 那是谎话。
945
+ self.llm_exec = self._default_llm_exec
946
+ self.notes.append(
947
+ f"会话里记的模型 {provider}/{model_id} 没能恢复({reason});"
948
+ "已用默认模型继续")
949
+ level = saved["thinking_level"]
950
+ if level is None:
951
+ # 老会话(这个字段上线前建的):补一条,避免它每次都被当"没记过"
952
+ self._record_thinking_change(session)
953
+ elif not self._level_pinned_cli:
954
+ # **只尊重命令行那档**:会话里记的级别**就是**用户在这个会话上显式选的那个
955
+ # (`set_thinking_level` 落的就是这条 entry),所以还原它不是在"覆盖用户的选择",
956
+ # 而是在把它装回来。会话级的 `_level_pins` 只用在一处 —— `set_model` 的
957
+ # 自动联动(`modelThinkingLevels` 不该覆盖显式选择)—— 不要在这里也用它,
958
+ # 否则"A 上设过 high"会让之后切回 A 读到的还是 B 的 low。
959
+ self.thinking_level = normalize_thinking_level(level)
960
+ client = getattr(self, "llm_exec", None)
961
+ if isinstance(client, ThinkingLLMClient):
962
+ client.thinking_level = self.thinking_level
963
+
964
+ def _record_model_change(self, session: Session | None = None) -> bool:
965
+ """落一条 `model_change`(pi 的 `appendModelChange`)。返回是否真写了。"""
966
+ target = session if session is not None else self.current_session()
967
+ spec = getattr(getattr(self, "llm_exec", None), "spec", None)
968
+ if target is None or spec is None:
969
+ return False
970
+ try:
971
+ return self.sessions.set_context_setting(
972
+ target, MODEL_CHANGE,
973
+ {"provider": str(spec.provider), "model_id": str(spec.model)})
974
+ except OSError as exc: # 写盘失败不该把一次切换搞垮(与 `set_session_title` 同形)
975
+ self.notes.append(f"模型切换没能记进会话文件: {exc}")
976
+ return False
977
+
978
+ def _record_thinking_change(self, session: Session | None = None,
979
+ level: str | None = None) -> bool:
980
+ """落一条 `thinking_level_change`(pi 的 `appendThinkingLevelChange`)。"""
981
+ target = session if session is not None else self.current_session()
982
+ if target is None:
983
+ return False
984
+ value = normalize_thinking_level(level if level is not None else self.thinking_level)
985
+ try:
986
+ return self.sessions.set_context_setting(
987
+ target, THINKING_LEVEL_CHANGE, {"thinking_level": value})
988
+ except OSError as exc:
989
+ self.notes.append(f"思考级别没能记进会话文件: {exc}")
990
+ return False
991
+
992
+ # ── 模型与思考级别(扩展的 `setModel` / `setThinkingLevel` 走这里)──
993
+ def set_model(self, provider: str, model: str, *, source: str = "set") -> ResolvedModel:
994
+ """运行期换模型(**下一回合**生效)。返回解析后的模型(前端要拿它刷新显示)。
995
+
996
+ **唯一的换模型入口** —— UI(TUI 的 `/model`、Ctrl+P)与扩展(`api.setModel`)共用,
997
+ 所以 `model_select` 只从这一处发:两个入口各自发事件,迟早一个漏发或多发。
998
+
999
+ 重建客户端时**必须带上当前 `thinking_level` 与 `retry`**:否则切模型会默默丢掉它们
1000
+ (footer 显示 high、请求里却没有 —— 这类表现最难查)。
1001
+ """
1002
+ resolved = resolve_model(self.cfg, provider, model)
1003
+ # 换到那个模型时,**按模型配的档自动生效** —— 但要在重建客户端**之前**改,
1004
+ # 否则客户端拿着旧级别(footer 显示 high、请求里却没有,这类最难查)。
1005
+ # 本会话里被显式设过(`/thinking` / shift+tab / `api.setThinkingLevel`)就不再覆盖:
1006
+ # 显式选择 > 配置。
1007
+ tuned = model_thinking_level(self.settings, resolved)
1008
+ if tuned is not None and not self._level_pinned():
1009
+ adopted = normalize_thinking_level(tuned)
1010
+ if adopted != self.thinking_level:
1011
+ self.thinking_level = adopted
1012
+ self._emit_notice("thinking_level_select", {
1013
+ "level": adopted, "previous_level": None, "source": "auto"})
1014
+ # 换模型带来的级别变化也要落 entry(pi:它走的是同一个 `setThinkingLevel`)
1015
+ self._record_thinking_change(level=adopted)
1016
+ previous = getattr(getattr(self, "llm_exec", None), "spec", None)
1017
+ self.llm_exec = self._make_client(resolved)
1018
+ # 落一条 `model_change`:时间线上留下切换点,并且**续会话时按它还原**
1019
+ # (pi 的 `appendModelChange` 就在这个位置)。回合外切换也能写 —— 靠 `bind_session`。
1020
+ self._record_model_change()
1021
+ self._emit_notice("model_select", {
1022
+ "model": f"{resolved.provider}/{resolved.model}",
1023
+ "previous": (f"{previous.provider}/{previous.model}" if previous else None),
1024
+ "source": source})
1025
+ return resolved
1026
+
1027
+ def reload_credentials(self) -> bool:
1028
+ """重新解析凭证并重建客户端(登录 / 退出登录 / 换了 key 之后调)。
1029
+
1030
+ 为什么必须有:`LiteLLMClient` 在**构造时**就把 key 解析好了(`self._resolved`),
1031
+ 所以写完 `auth.json` 不重建,用户以为登上了、请求还是拿旧 key 去 401。
1032
+
1033
+ 返回是否真重建了(没有客户端时 False)—— 调用方据此决定要不要提“下一回合生效”。
1034
+ """
1035
+ spec = getattr(getattr(self, "llm_exec", None), "spec", None)
1036
+ if spec is None:
1037
+ return False
1038
+ self.llm_exec = self._make_client(spec)
1039
+ return True
1040
+
1041
+ def set_thinking_level(self, level: str, *, source: str = "set") -> str:
1042
+ """改思考级别(下一回合生效)。返回归一后的级别。
1043
+
1044
+ `source` 只进事件 payload("set" = 显式设、"cycle" = shift+tab 轮转)。
1045
+ """
1046
+ previous = self.thinking_level
1047
+ self.thinking_level = normalize_thinking_level(level)
1048
+ client = getattr(self, "llm_exec", None)
1049
+ # 可选能力:测试替身 / 第三方实现可能连 `thinking_level` 都没有
1050
+ if client is not None and isinstance(client, ThinkingLLMClient):
1051
+ client.thinking_level = self.thinking_level
1052
+ self._emit_notice("thinking_level_select", {
1053
+ "level": self.thinking_level, "previous_level": previous, "source": source})
1054
+ # 记在**当前会话**上(没有会话就退回命令行那一档的语义:本次运行有效)——
1055
+ # 这样"在 A 上设过"不会泄漏到 B(见 `_level_pinned`)。
1056
+ session = self.current_session()
1057
+ if session is not None:
1058
+ self._level_pins.add(session.id)
1059
+ else:
1060
+ self._level_pinned_cli = True
1061
+ # 与 `set_model` 对称:只在级别**真的变了**时才落 entry(pi 的 `isChanging`)
1062
+ if previous != self.thinking_level:
1063
+ self._record_thinking_change()
1064
+ return self.thinking_level
1065
+
1066
+ def set_session_title(self, session: Session, title: str, *, source: str = "auto") -> None:
1067
+ """改会话显示名(落盘 + 发 `session_info_changed`)。
1068
+
1069
+ 标题有两条来源:自动命名(`source="auto"`)与用户 `/name`(`source="user"`)。
1070
+ 集中到这一处是为了**事件只发一次**,而且“空标题不覆盖”那条规则只写一遍。
1071
+ """
1072
+ cleaned = (title or "").strip()
1073
+ if not cleaned:
1074
+ return
1075
+ try:
1076
+ self.sessions.set_title(session, cleaned)
1077
+ except OSError:
1078
+ # 落盘失败不该把这一轮搞垮(与本仓其它“不因此中断主流程”的 except 同形)
1079
+ return
1080
+ self._emit_notice("session_info_changed", {"name": cleaned, "source": source})
1081
+
1082
+ # ── 扩展宿主面(ctx 上的方法回向这里)────────────────────────────
1083
+ #
1084
+ # 这一整块是 pi 的 `ExtensionContext` / `ExtensionCommandContext` 对应物的后端。
1085
+ # `ctx` 是 frozen 的只读值,要问运行时的事就走这些方法。
1086
+ def is_idle(self) -> bool:
1087
+ """agent 空闲吗(没在流式)。"""
1088
+ return self._idle
1089
+
1090
+ def abort(self) -> None:
1091
+ """中断当前回合(`ctx.abort()`);没有回合在跑时 no-op。"""
1092
+ if self._abort_signal is not None:
1093
+ self._abort_signal.abort()
1094
+
1095
+ def has_pending_messages(self) -> bool:
1096
+ """有没有排队等送达的扩展消息 / 待开一轮的请求。"""
1097
+ return (any(self._pending_messages.values())
1098
+ or bool(self._turn_requests))
1099
+
1100
+ def set_shutdown_hook(self, fn: Any) -> None:
1101
+ """前端注入退出实现(`ctx.shutdown()` 走它)。"""
1102
+ self._shutdown = fn
1103
+
1104
+ def shutdown(self) -> None:
1105
+ """优雅退出(pi 的 `ctx.shutdown()`)。没注入实现时记一条 note。"""
1106
+ if callable(self._shutdown):
1107
+ self._shutdown()
1108
+ return
1109
+ self.notes.append("ctx.shutdown():没有前端注入退出实现,已忽略")
1110
+
1111
+ def context_usage(self) -> dict | None:
1112
+ """当前上下文占用(pi 的 `ContextUsage`:`{tokens, contextWindow, percent}`)。"""
1113
+ window = self._context_window()
1114
+ if window <= 0:
1115
+ return None
1116
+ session = self._active_session
1117
+ tokens = messages_tokens(self._history(session)) if session is not None else 0
1118
+ return {"tokens": tokens, "contextWindow": window, "context_window": window,
1119
+ "percent": (round(tokens / window * 100, 1) if window else None)}
1120
+
1121
+ def compact(self, options: dict | None = None) -> None:
1122
+ """触发一次压缩,**不等**它跑完(pi 的 `ctx.compact()` 同语义)。"""
1123
+ session = self._active_session
1124
+ if session is None:
1125
+ self.notes.append("ctx.compact():回合外没有会话,已忽略")
1126
+ return
1127
+ options = options or {}
1128
+ instructions = str(options.get("customInstructions")
1129
+ or options.get("custom_instructions") or "") or None
1130
+
1131
+ async def _run() -> None:
1132
+ try:
1133
+ await self.compact_session(session, instructions)
1134
+ except Exception as exc: # noqa: BLE001 后台任务里的失败要看得见
1135
+ self.notes.append(f"扩展触发的压缩失败: {exc}")
1136
+
1137
+ try:
1138
+ asyncio.get_running_loop().create_task(_run())
1139
+ except RuntimeError:
1140
+ return
1141
+
1142
+ def current_system_prompt(self) -> str:
1143
+ """当前生效的 system prompt(TUI 的 `/sysprompt`、扩展的 `ctx.getSystemPrompt()`)。
1144
+
1145
+ 优先给**本回合实际建好的那一份**(扩展的 `before_agent_start` 改过),没跑过回合时
1146
+ 现算一份 —— 否则“读系统提示词”会得到一个与模型看到的不一样的东西。
1147
+ """
1148
+ if self._last_system_prompt is not None:
1149
+ return self._last_system_prompt
1150
+ return build_system_prompt(self.base_prompt, cwd=self.workdir,
1151
+ tools=self.catalog.resolve(self.tool_names()),
1152
+ skills=self.top_skills,
1153
+ append=self.append_system_prompt,
1154
+ context_files=[] if self._no_context_files else None)
1155
+
1156
+ def system_prompt_options(self) -> dict:
1157
+ """造 system prompt 用的结构化选项(pi 的 `BuildSystemPromptOptions` 对应物)。"""
1158
+ return {"cwd": str(self.workdir),
1159
+ "tools": self.tool_names(),
1160
+ "skills": [getattr(s, "name", str(s)) for s in self.top_skills],
1161
+ "append_system_prompt": self.append_system_prompt,
1162
+ "context_files": [] if self._no_context_files else None,
1163
+ "base_prompt_source": self.base_prompt_source,
1164
+ "agent": CORE_AGENT_NAME}
1165
+
1166
+ async def wait_for_idle(self) -> None:
1167
+ """等当前回合跑完(pi 的 `ctx.waitForIdle()`)。"""
1168
+ await self._idle_event.wait()
1169
+
1170
+ # ── 会话名与 label ──
1171
+ def set_session_name(self, name: str) -> None:
1172
+ """改当前会话名(`api.set_session_name` 走这里)。"""
1173
+ session = self._active_session
1174
+ if session is None:
1175
+ raise RuntimeError("没有活动会话:set_session_name 只能在绑定会话后调用")
1176
+ self.set_session_title(session, name, source="extension")
1177
+
1178
+ def get_session_name(self) -> str | None:
1179
+ session = self._active_session
1180
+ return (session.title or None) if session is not None else None
1181
+
1182
+ def set_entry_label(self, entry_id: str, label: str | None) -> None:
1183
+ """给某条 entry 打/清 label(label entry 盖在 `targetId` 上,pi 同形)。"""
1184
+ session = self._active_session
1185
+ if session is None:
1186
+ raise RuntimeError("没有活动会话:set_label 只能在回合内调用")
1187
+ self.sessions.append(session, {"type": "label", "targetId": entry_id,
1188
+ "label": label})
1189
+
1190
+ # ── 模型与 provider ──
1191
+ def set_extension_model(self, model: Any) -> bool:
1192
+ """扩展切模型(`api.set_model` 走这里)。接受 `"provider/model"` / `ModelView` /
1193
+ `{provider, id}`。解析/鉴权失败只记 note 并返回 False(不把扩展的回合打断)。"""
1194
+ provider = model_id = None
1195
+ if isinstance(model, str):
1196
+ provider, _, model_id = model.partition("/")
1197
+ elif isinstance(model, dict):
1198
+ provider = model.get("provider")
1199
+ model_id = model.get("id") or model.get("model")
1200
+ else:
1201
+ provider = getattr(model, "provider", None)
1202
+ model_id = getattr(model, "id", None)
1203
+ if not provider or not model_id:
1204
+ raise ValueError("set_model 需要 'provider/model'、ModelView 或 {provider, id}")
1205
+ try:
1206
+ self.set_model(str(provider), str(model_id), source="extension")
1207
+ except Exception as exc: # noqa: BLE001 未登记的模型 / 配置错
1208
+ self.notes.append(f"扩展切模型失败({provider}/{model_id}): {exc}")
1209
+ return False
1210
+ return True
1211
+
1212
+ def unregister_provider(self, name: str) -> None:
1213
+ """注销先前注册的 provider(pi 的 `unregisterProvider`)。找不到则 no-op。"""
1214
+ if self.cfg.providers.pop(name, None) is None:
1215
+ return
1216
+ self.notes.append(f"扩展注销了 provider {name}")
1217
+
1218
+ def model_catalog(self) -> list[ModelView]:
1219
+ """配置里**全部**模型(`ctx.model_registry.get_all()`)。"""
1220
+ out: list[ModelView] = []
1221
+ for provider, prov in (self.cfg.providers or {}).items():
1222
+ for entry in (prov.models or []):
1223
+ out.append(ModelView(provider, entry.id,
1224
+ api=entry.api or prov.api or "",
1225
+ base_url=prov.baseUrl,
1226
+ reasoning=bool(entry.reasoning),
1227
+ context_window=_as_int(entry.contextWindow),
1228
+ max_tokens=_as_int(entry.maxTokens),
1229
+ name=entry.name or entry.id))
1230
+ return out
1231
+
1232
+ def available_models(self) -> list[ModelView]:
1233
+ """能用的模型(当前口径与 `model_catalog()` 一致 —— qi 不做逐模型鉴权过滤)。"""
1234
+ return self.model_catalog()
1235
+
1236
+ def has_configured_auth(self, model: Any) -> bool:
1237
+ """这个模型的 provider 有凭据吗(配置里的 apiKey 或凭证库)。"""
1238
+ provider = getattr(model, "provider", None) or (
1239
+ model.get("provider") if isinstance(model, dict) else None)
1240
+ if not provider:
1241
+ return False
1242
+ prov = (self.cfg.providers or {}).get(str(provider))
1243
+ if prov is not None and prov.apiKey:
1244
+ return True
1245
+ try:
1246
+ return self._auth.get(str(provider)) is not None
1247
+ except Exception: # noqa: BLE001 凭证库读失败不该把只读查询搞崩
1248
+ return False
1249
+
1250
+ def provider_display_name(self, provider: str) -> str:
1251
+ """provider 的展示名(没配就回落到名字本身,pi 同义)。"""
1252
+ prov = (self.cfg.providers or {}).get(str(provider))
1253
+ name = getattr(prov, "name", None) if prov is not None else None
1254
+ return str(name) if name else str(provider)
1255
+
1256
+ # ── 会话操作(pi 的 ExtensionCommandContext)────────────────────────
1257
+ async def _session_gate(self, event: str, payload: dict, ctx: Any) -> dict | None:
1258
+ """发一个**可取消**的会话事件。返回那个裁决 dict(None = 没人拦)。"""
1259
+ if not self.bus.has(event):
1260
+ return None
1261
+ result = await self.bus.emit_until(event, payload,
1262
+ ctx=ctx or self.extension_ctx(),
1263
+ stop_keys=("cancel",))
1264
+ for src, exc in result.errors:
1265
+ self.notes.append(f"扩展 {src} 的 {event} 处理失败: {exc}")
1266
+ return result.result
1267
+
1268
+ async def extension_new_session(self, ctx: Any = None,
1269
+ options: dict | None = None) -> dict:
1270
+ """开一个新会话(`ctx.new_session()` 走这里)。"""
1271
+ verdict = await self._session_gate("session_before_switch", {"reason": "new"}, ctx)
1272
+ if verdict and verdict.get("cancel"):
1273
+ return {"cancelled": True}
1274
+ previous = self._active_session
1275
+ if previous is not None:
1276
+ await self.emit_session_shutdown("new")
1277
+ session = self.sessions.create("", cwd=self.cwd)
1278
+ self._active_session = session
1279
+ await self.start_session(session, reason="new")
1280
+ return {"cancelled": False, "session": session}
1281
+
1282
+ async def extension_fork(self, ctx: Any, entry_id: str,
1283
+ options: dict | None = None) -> dict:
1284
+ """从某条 entry fork 出新会话(`ctx.fork()` 走这里;不传 entry_id 则空历史)。"""
1285
+ source = self._active_session
1286
+ if source is None:
1287
+ raise RuntimeError("没有活动会话:fork 需要先绑定会话")
1288
+ verdict = await self._session_gate(
1289
+ "session_before_fork", {"entryId": entry_id}, ctx)
1290
+ if verdict and verdict.get("cancel"):
1291
+ return {"cancelled": True}
1292
+ new = self.sessions.fork_at(source, entry_id)
1293
+ self._active_session = new
1294
+ await self.start_session(new, reason="fork")
1295
+ return {"cancelled": False, "session": new}
1296
+
1297
+ async def extension_navigate_tree(self, ctx: Any, target_id: str,
1298
+ options: dict | None = None) -> dict:
1299
+ """把当前节点移到会话树里的另一处(`ctx.navigate_tree()`)。"""
1300
+ session = self._active_session
1301
+ if session is None:
1302
+ raise RuntimeError("没有活动会话:navigate_tree 需要先绑定会话")
1303
+ verdict = await self._session_gate(
1304
+ "session_before_tree", {"targetId": target_id}, ctx)
1305
+ if verdict and verdict.get("cancel"):
1306
+ return {"cancelled": True}
1307
+ if not self.sessions.set_position(session, target_id):
1308
+ return {"cancelled": True, "error": f"未知的 entry: {target_id}"}
1309
+ self._emit_notice("session_tree", {"newLeafId": target_id})
1310
+ return {"cancelled": False, "session": session}
1311
+
1312
+ async def extension_switch_session(self, ctx: Any, session_path: str,
1313
+ options: dict | None = None) -> dict:
1314
+ """切到另一个会话文件(`ctx.switch_session()`)。"""
1315
+ verdict = await self._session_gate(
1316
+ "session_before_switch", {"reason": "resume", "targetSessionFile": session_path},
1317
+ ctx)
1318
+ if verdict and verdict.get("cancel"):
1319
+ return {"cancelled": True}
1320
+ session = self.sessions.open_file(session_path)
1321
+ if session is None:
1322
+ return {"cancelled": True, "error": f"打不开会话文件: {session_path}"}
1323
+ await self.emit_session_shutdown("resume")
1324
+ self._active_session = session
1325
+ await self.start_session(session, reason="resume")
1326
+ return {"cancelled": False, "session": session}
1327
+
1328
+ async def reload(self) -> None:
1329
+ """重载资源与提示词(pi 的 `ctx.reload()` 的 qi 版)。
1330
+
1331
+ **不重新 import 扩展模块**(Python 不保证能安全重载:模块级状态、已注册的
1332
+ 工具/命令都可能重复)—— 重扫的是技能/基座提示词这类**资源**,并重发
1333
+ `resources_discover` 与 `session_start(reason="reload")` 让扩展自己刷新。
1334
+ """
1335
+ self.base_prompt, self.base_prompt_source = resolve_base_prompt(self.cwd)
1336
+ self.top_skills = load_top_level_skills(
1337
+ self.cwd, self.settings, list(self._extra_skill_paths),
1338
+ enabled=self.settings.skillsEnabled)
1339
+ await self._emit_resources_discover(reason="reload")
1340
+ session = self._active_session
1341
+ if session is not None:
1342
+ self._started_sessions.discard(session.id)
1343
+ await self.start_session(session, reason="reload")
1344
+ else:
1345
+ self.notes.append("ctx.reload():扩展模块不会重新 import(要重启进程);"
1346
+ "技能与提示词已重扫")
1347
+
1348
+ async def emit_session_shutdown(self, reason: str,
1349
+ target_session_file: str | None = None) -> None:
1350
+ """发 `session_shutdown`(退出 / 重载 / 换会话之前)。
1351
+
1352
+ 幂等由调用方保证(同一会话只发一次) —— 这里只负责派发与错误隔离。
1353
+ """
1354
+ if not self.bus.has("session_shutdown"):
1355
+ return
1356
+ payload: dict = {"reason": reason}
1357
+ if target_session_file:
1358
+ payload["targetSessionFile"] = target_session_file
1359
+ result = await self.bus.emit("session_shutdown", payload,
1360
+ ctx=self.extension_ctx())
1361
+ for src, exc in result.errors:
1362
+ self.notes.append(f"扩展 {src} 的 session_shutdown 处理失败: {exc}")
1363
+
1364
+ async def _emit_resources_discover(self, reason: str = "startup") -> None:
1365
+ """发 `resources_discover`:扩展可以再补技能/提示词/主题路径。
1366
+
1367
+ pi 的形状是返回 `{skillPaths, promptPaths, themePaths}` 三个绝对路径列表。
1368
+ qi 收到后**真加载**:技能进 `top_skills`,提示词/主题路径进 `self.extra_resource_paths`
1369
+ (前端/TUI 从那里取),所以「扩展提供资源」不是只收返回值。
1370
+ """
1371
+ if not self.bus.has("resources_discover"):
1372
+ return
1373
+ result = await self.bus.emit(
1374
+ "resources_discover", {"cwd": str(self.cwd), "reason": reason},
1375
+ ctx=self.extension_ctx())
1376
+ for src, exc in result.errors:
1377
+ self.notes.append(f"扩展 {src} 的 resources_discover 处理失败: {exc}")
1378
+ for key in ("skillPaths", "skill_paths"):
1379
+ for raw in result.payload.get(key) or ():
1380
+ path = Path(str(raw)).expanduser()
1381
+ if not path.exists():
1382
+ self.notes.append(f"resources_discover 给的技能路径不存在: {path}")
1383
+ continue
1384
+ # 给的是「技能根」还是「某个技能目录」?两种都认 —— pi 传根,而扩展作者
1385
+ # 很容易直接指向自己的 `skills/mine/`。
1386
+ root = path.parent if (path / "SKILL.md").is_file() else path
1387
+ try:
1388
+ found = load_top_level_skills(self.cwd, self.settings, [root],
1389
+ enabled=self.settings.skillsEnabled)
1390
+ except Exception as exc: # noqa: BLE001 坏技能目录不该弄崩启动
1391
+ self.notes.append(f"resources_discover 的技能路径加载失败({path}): {exc}")
1392
+ continue
1393
+ self.top_skills = [*self.top_skills, *found]
1394
+ for key in ("promptPaths", "prompt_paths", "themePaths", "theme_paths"):
1395
+ for raw in result.payload.get(key) or ():
1396
+ path = Path(str(raw)).expanduser()
1397
+ if path.exists():
1398
+ self.extra_resource_paths.append(path)
1399
+ else:
1400
+ self.notes.append(f"resources_discover 给的路径不存在: {path}")
1401
+
1402
+ async def before_session_op(self, event: str, payload: dict) -> dict | None:
1403
+ """`session_before_switch` / `_fork` / `_tree` 的**公开**入口。
1404
+
1405
+ 前端在**用户发起**的会话操作(`/new` `/resume` `/fork` `/tree`)前调它。
1406
+ 让用户按键与扩展调 `ctx.fork()` 走**同一道闸门** —— 否则“扩展能拦扩展发起的
1407
+ 操作,却拦不住用户按的键”,闸门就名不副实。返回 `{cancel: true}` 即拦下。
1408
+ """
1409
+ return await self._session_gate(event, payload, self.extension_ctx())
1410
+
1411
+ def notify_session_tree(self, new_leaf_id: str | None) -> None:
1412
+ """前端完成一次树跳转后通知扩展(`session_tree`)。"""
1413
+ self._emit_notice("session_tree", {"newLeafId": new_leaf_id})
1414
+
1415
+ def _emit_notice(self, event: str, payload: dict) -> None:
1416
+ """发一个**通知型**扩展事件(同步入口,不阻塞调用方)。
1417
+
1418
+ `bus.emit` 是 async,而 `set_model` 这类调用点在 UI 的同步路径上 —— 用一个
1419
+ 后台任务跑完全够(通知型事件的返回值本来就被忽略),避免把 UI 路径改成 async。
1420
+ 没订阅时**不建任务**(零扩展零开销)。
1421
+ """
1422
+ if not self.bus.has(event):
1423
+ return
1424
+ payload = dict(payload)
1425
+ payload["session"] = self._active_session.id if self._active_session else None
1426
+
1427
+ async def _run() -> None:
1428
+ result = await self.bus.emit(event, payload,
1429
+ ctx=self.extension_ctx(session=self._active_session))
1430
+ for src, exc in result.errors:
1431
+ self.notes.append(f"扩展 {src} 的 {event} 处理失败: {exc}")
1432
+
1433
+ try:
1434
+ asyncio.get_running_loop().create_task(_run())
1435
+ except RuntimeError:
1436
+ # 没在事件循环里(如脚本直接构造 runtime):通知型事件丢掉不影响主流程
1437
+ return
1438
+
1439
+ # ── 主流程 ──
1440
+ def _history(self, session: Session) -> list:
1441
+ """当前分支的上下文 = 最近一次压缩摘要 + 压缩点之后的消息(+ 分支摘要)。
1442
+
1443
+ pi 的语义:压缩后模型看到的是 `system | summary | firstKeptEntryId 起头的消息`。
1444
+ 没压过时退回旧的「最近 40 条」简易窗口 —— 那时压缩还没接管窗口。
1445
+ """
1446
+ branch = session.branch()
1447
+ last_compaction = next((e for e in reversed(branch)
1448
+ if e.get("type") == "compaction"), None)
1449
+ out: list[ChatMessage] = []
1450
+ keep_from = 0
1451
+ if last_compaction is not None:
1452
+ out.append(summary_context_message(str(last_compaction.get("summary") or "")))
1453
+ first_kept = str(last_compaction.get("firstKeptEntryId") or "")
1454
+ keep_from = next((i for i, x in enumerate(branch)
1455
+ if str(x.get("id")) == first_kept), len(branch))
1456
+ for e in branch[keep_from:]:
1457
+ kind = e.get("type")
1458
+ if kind == "branch_summary":
1459
+ out.append(summary_context_message(str(e.get("summary") or ""), kind="branch"))
1460
+ elif kind == "message" and e.get("role") != "system":
1461
+ d = dict(e)
1462
+ d.pop("type", None); d.pop("ts", None); d.pop("agent_id", None)
1463
+ out.append(chat_message_from_dict(d))
1464
+ return out if last_compaction is not None else out[-40:]
1465
+
1466
+ # ── 上下文压缩(pi 的 /compact + 自动压缩)──
1467
+ def _compaction_options(self) -> tuple[bool, int, int]:
1468
+ """读取 `settings.compaction`:`(enabled, reserveTokens, keepRecentTokens)`。"""
1469
+ raw = self.settings.compaction or {}
1470
+ enabled = bool(raw.get("enabled", True))
1471
+ reserve = raw.get("reserveTokens")
1472
+ keep = raw.get("keepRecentTokens")
1473
+ return (enabled,
1474
+ reserve if isinstance(reserve, int) and reserve > 0 else DEFAULT_RESERVE_TOKENS,
1475
+ keep if isinstance(keep, int) and keep > 0 else DEFAULT_KEEP_RECENT_TOKENS)
1476
+
1477
+ def _context_window(self) -> int:
1478
+ spec = getattr(self.llm_exec, "spec", None)
1479
+ window = getattr(spec, "context_window", 0)
1480
+ return window if isinstance(window, int) else 0
1481
+
1482
+ async def _maybe_auto_compact(self, session: Session):
1483
+ """开新一回合前检查上下文体积:超了就先自动压一次(pi 的 auto-compaction)。
1484
+
1485
+ 预算口径:分支上会进上下文的内容 + 基座提示词;阈值 = `contextWindow - reserveTokens`。
1486
+ 压缩失败不应该把整轮卡死 —— 只报错,继续跑。
1487
+ """
1488
+ enabled, reserve, _keep = self._compaction_options()
1489
+ window = self._context_window()
1490
+ if not enabled or window <= 0:
1491
+ return
1492
+ # 用**重建后的上下文**估算,不是原始 entry 之和 —— 压缩过的内容不该再计入
1493
+ base = self.base_prompt or default_base_prompt() # 无自定义基座时按代码内默认计入
1494
+ tokens = messages_tokens(self._history(session)) + estimate_tokens(base)
1495
+ if not should_compact(tokens, window, enabled=enabled, reserve_tokens=reserve):
1496
+ return
1497
+ yield AgentEvent(kind="compaction_start", text="正在自动压缩上下文…",
1498
+ data={"auto": True, "tokens": tokens, "contextWindow": window})
1499
+ try:
1500
+ entry = await self.compact_session(session)
1501
+ except Exception as exc: # noqa: BLE001 压缩失败不能拖垮这一轮
1502
+ yield AgentEvent(kind="error", text=f"自动压缩失败(继续本轮): {exc}")
1503
+ return
1504
+ if entry is not None:
1505
+ yield AgentEvent(kind="compaction_end", text=str(entry.get("summary") or ""),
1506
+ data={"auto": True, "tokensBefore": entry.get("tokensBefore"),
1507
+ "entry": entry})
1508
+
1509
+ async def compact_session(self, session: Session,
1510
+ instructions: str | None = None) -> dict | None:
1511
+ """执行一次压缩并落盘;没什么可压时返回 None(调用方据此提示用户)。
1512
+
1513
+ **这是压缩的唯一入口**(TUI 的 `/compact` 与自动压缩都走它),所以 P-E3d-2 的三个
1514
+ 事件都在这一处发:
1515
+
1516
+ - `session_before_compact`:可 `{cancel: true}` 拦下这次压缩,也可以 `{summary: "…"}`
1517
+ 自带摘要 —— 自带时**不调模型**(扩展可能比模型更清楚该记住什么);
1518
+ - `session_compact`:成功落盘后(带 `entry`);
1519
+ - `session_compact_failed`:失败时**先发事件再把异常抛出去** —— 失败必须传到调用方,
1520
+ 但扩展也该看得见。
1521
+
1522
+ payload 键名用 snake_case(与其它事件一致);`cancel` / `summary` 是文档里那两个契约键。
1523
+ """
1524
+ _enabled, _reserve, keep = self._compaction_options()
1525
+ prep = prepare_compaction(session.branch(), keep_recent_tokens=keep)
1526
+ if prep is None:
1527
+ return None
1528
+ if self.bus.has("session_before_compact"):
1529
+ result = await self.bus.emit_until(
1530
+ "session_before_compact",
1531
+ {"instructions": instructions, "tokens_before": prep.tokens_before},
1532
+ ctx=self.extension_ctx(), stop_keys=("cancel",))
1533
+ for source, exc in result.errors:
1534
+ self.notes.append(f"扩展 {source} 的 session_before_compact 处理失败: {exc}")
1535
+ if result.payload.get("cancel"):
1536
+ return None
1537
+ provided = result.payload.get("summary")
1538
+ if isinstance(provided, str) and provided.strip():
1539
+ # 与 `compaction.compact()` 造同一个形状(只是摘要来自扩展、usage 为空)
1540
+ entry = {"type": "compaction", "summary": provided.strip(),
1541
+ "firstKeptEntryId": prep.first_kept_entry_id,
1542
+ "tokensBefore": prep.tokens_before, "usage": {}}
1543
+ self.sessions.append(session, entry)
1544
+ self._emit_notice("session_compact",
1545
+ {"entry": entry, "summary": entry["summary"],
1546
+ "provided": True})
1547
+ return entry
1548
+ try:
1549
+ entry = await compact(self.llm_exec, prep, instructions=instructions)
1550
+ except Exception as exc: # noqa: BLE001 先让扩展看见,再原样抛给调用方
1551
+ self._emit_notice("session_compact_failed", {"error": str(exc)})
1552
+ raise
1553
+ self.sessions.append(session, entry)
1554
+ self._emit_notice("session_compact",
1555
+ {"entry": entry, "summary": str(entry.get("summary") or ""),
1556
+ "provided": False})
1557
+ return entry
1558
+
1559
+ async def summarize_branch_for_jump(self, session: Session, source_branch: list[dict],
1560
+ from_id: str | None, target_id: str | None
1561
+ ) -> dict | None:
1562
+ """`/tree` 跳到别的分支时,把「被放弃的那段」压成摘要挂到新位置。
1563
+
1564
+ `source_branch` 必须由调用方在**移动 position 之前**取好:`/tree` 一移动 current,
1565
+ 再调 `session.branch()` 拿到的就是目标分支了(摘要会静默变成空)。
1566
+ """
1567
+ # 先过 `branchSummary.skipPrompt` 与"要不要摘要"这一问(pi 的 `/tree` 行为):
1568
+ # `skipPrompt: true` = **不问也不摘要**;否则问一句,默认"不摘要" —— 无前端时
1569
+ # `ui.confirm` 返回调用方给的 default,正好就是 pi 的 "defaults to no summary"。
1570
+ if branch_summary_skip_prompt(self.settings):
1571
+ return None
1572
+ if not await self.ui.confirm("要把被放弃的那段压成摘要吗?", default=False):
1573
+ return None
1574
+ target_branch = session.branch(target_id)
1575
+ entries = branch_to_summarize(source_branch, from_id, target_branch)
1576
+ if not entries:
1577
+ return None
1578
+ summary, usage = await summarize_branch(self.llm_exec, entries)
1579
+ if not summary:
1580
+ return None
1581
+ entry = {"type": "branch_summary", "summary": summary,
1582
+ "fromId": from_id, "usage": usage}
1583
+ # 挂到跳过去的位置下(所以新 leaf 就是这条摘要)
1584
+ self.sessions.set_position(session, target_id)
1585
+ self.sessions.append(session, entry)
1586
+ return entry
1587
+
1588
+ async def _emit_input(self, text: str, session: Session, source: str,
1589
+ abort: AbortSignal | None) -> tuple[str, dict | None, str | None]:
1590
+ """派发 `input`(pi 的 input 事件):可拦截 / 改写 / 吞掉。
1591
+
1592
+ 返回 `(最终文本, handled 裁决, 裁决者)`;裁决非空表示扩展接管了这一轮。
1593
+
1594
+ **两个键,两种含义**(不能合):
1595
+ * `text` —— **改写后的用户输入**(`transform` 用);
1596
+ * `reply` —— **扩展给用户的答复**(`handled` 用)。
1597
+
1598
+ 分开不是洁癖:裁决的字段会被 patch 进 payload,如果两者同名,扩展的答复就会被
1599
+ 当成用户输入落盘 —— 测试里真的出现过用户消息变成 "pong" 的那种结果。
1600
+ pi 不需要区分是因为它的 `handled` 不带文本(靠 `ctx.ui` 自己展示);
1601
+ qi 的 `ctx.ui` 要到 P-E3 才有,所以这里要一个 `reply`。
1602
+ """
1603
+ if not self.bus.has("input"):
1604
+ return text, None, None
1605
+ result = await self.bus.emit_until(
1606
+ "input",
1607
+ {"text": text, "source": source, "session": session.id},
1608
+ ctx=self.extension_ctx(abort),
1609
+ stop_values={"action": ("handled",)})
1610
+ for src, exc in result.errors:
1611
+ self.notes.append(f"扩展 {src} 的 input 处理失败: {exc}")
1612
+ final = result.payload.get("text")
1613
+ return (str(final) if final is not None else text), result.result, result.stopped_by
1614
+
1615
+ async def _handled_turn(self, session: Session, verdict: dict, source: str | None):
1616
+ """扩展接管了这一轮(pi 的 `action: handled`):**不跑 agent**。
1617
+
1618
+ 仍然**落盘助手侧**—— 否则回放里这一轮凭空消失,而“直播看得见、刷新就没了”
1619
+ 是本仓反复出现过的那类不一致。用户消息由 `stream()` 先落过了(那是“用户确实
1620
+ 说了这句话”的记录,与“谁处理了它”无关)。
1621
+
1622
+ 没给 `reply` 时给一句占位:`ctx.ui` 要到 P-E3 才有,现在扩展没有别的展示
1623
+ 通道,静默吞掉会让用户以为程序卡了。
1624
+ """
1625
+ agent = "extension"
1626
+ reply = str(verdict.get("reply") or "").strip()
1627
+ self._persist_final(session, agent, reply or INPUT_HANDLED_PLACEHOLDER)
1628
+ yield AgentEvent(kind="text", agent=agent, text=reply or INPUT_HANDLED_PLACEHOLDER)
1629
+ yield AgentEvent(kind="agent_end", agent=agent, text=reply,
1630
+ data={"messages": [], "handled_by": source,
1631
+ "usage": {"turns": 0, "context_tokens": 0}})
1632
+
1633
+ async def _before_agent_start(self, text: str, tools: list[str],
1634
+ abort: AbortSignal | None) -> tuple[str, str | None]:
1635
+ """造出本轮的 system prompt,并坐许扩展改它(`before_agent_start`,链式)。
1636
+
1637
+ 返回 `(system_prompt, 注入的消息)`。
1638
+
1639
+ **角色层不在 core 里**:qi-agents 通过这个钩子把角色说明拼进来(它返回的
1640
+ `system_prompt` 会链式生效)。core 只拼「基座 + 项目上下文 + 技能 + cwd」。
1641
+
1642
+ 注入的消息(pi 的 `{message: …}`)收字符串或 `{"content": …}`。按 pi 的语义它是
1643
+ **持久**的(落盘,下一轮仍在上下文里),所以落盘由调用方(`_stream_inner`)做。
1644
+ """
1645
+ built = build_system_prompt(self.base_prompt,
1646
+ cwd=self.workdir,
1647
+ tools=self.catalog.resolve(tools),
1648
+ skills=self.top_skills,
1649
+ append=self.append_system_prompt,
1650
+ # `--no-context-files` → 显式空列表(不是 None:None 会去读)
1651
+ context_files=[] if self._no_context_files else None)
1652
+ if not self.bus.has("before_agent_start"):
1653
+ return built, None
1654
+ result = await self.bus.emit(
1655
+ "before_agent_start",
1656
+ {"prompt": text, "system_prompt": built, "agent": CORE_AGENT_NAME},
1657
+ ctx=self.extension_ctx(abort))
1658
+ for src, exc in result.errors:
1659
+ self.notes.append(f"扩展 {src} 的 before_agent_start 处理失败: {exc}")
1660
+ changed = result.payload.get("system_prompt")
1661
+ raw = result.payload.get("message")
1662
+ injected: str | None = None
1663
+ if isinstance(raw, str):
1664
+ injected = raw.strip() or None
1665
+ elif isinstance(raw, dict):
1666
+ injected = str(raw.get("content") or "").strip() or None
1667
+ final = str(changed) if changed else built
1668
+ # 让 `ctx.getSystemPrompt()` 看到的是**模型真正收到的那一份**(含扩展改写)
1669
+ self._last_system_prompt = final
1670
+ return final, injected
1671
+
1672
+ async def stream(self, text: str, session: Session, agent_override: str | None = None,
1673
+ abort: AbortSignal | None = None, source: str = "interactive"):
1674
+ """处理一轮用户输入,产出事件。
1675
+
1676
+ **`agent_override` 已不再有任何作用(P-E4c 遗留)**:它此前是"这一轮直派哪个
1677
+ agent"的入口,而 core 收窄成单 agent 之后角色归 qi-agents(靠 `before_agent_start`
1678
+ 改提示词,E14)—— 流水线里没有任何地方读它了。参数留着不删是为了让旧调用点
1679
+ (qi-web 曾用它)不因签名变化而崩,但**它不会生效、也不报错**,所以:
1680
+ 要"以某个角色跑"请用 qi-agents 的 `--ext agent=<名>`,或自己挂 `before_agent_start`。
1681
+ (web 端正是栽在这里 —— 界面显示着角色、提示词里却没有;见 docs/extensions.md §6.1。)
1682
+
1683
+ `source` 进 `input` 事件的 payload(交互式 / 无头 / rpc)—— 扩展据此决定
1684
+ “要不要弹问”。目前调用方都用默认值:精确标签等真正需要它的人来传。
1685
+
1686
+ `abort` 透传给 runner(协作式中断,见 abort.py)。即使被**硬取消**
1687
+ (`CancelledError`:web 的客户端 abort、TUI 的强制终止),本方法也会先把已有文本
1688
+ 落盘再往上抛 —— 否则用户看过的半截回答在会话文件里消失(直播与回放不一致)。
1689
+ """
1690
+ # 回合内把会话绑在 runtime 上:扩展的 `appendEntry` / `ctx.session_manager` 靠它。
1691
+ # 用 wrapper + `finally` 而不是在主体里包 `try`:主体有十几个 `return`
1692
+ # (handled 提前结束、无 agent、错误返回…),逐个清一定会漏 —— 而漏掉的后果是
1693
+ # “回合外的写落到上一个会话里”,那是很难查的一类串状态。
1694
+ self._active_session = session
1695
+ # `ctx.isIdle()` / `ctx.waitForIdle()` / `ctx.abort()` 靠这三个字段。
1696
+ # 与 `_active_session` 同一个 wrapper:`finally` 里恢复,否则硬取消会把它们永远
1697
+ # 留在“忙”的状态(下一次 `waitForIdle` 就死等)。
1698
+ self._idle = False
1699
+ self._idle_event.clear()
1700
+ self._abort_signal = abort
1701
+ try:
1702
+ async for event in self._stream_inner(text, session, agent_override, abort, source):
1703
+ yield event
1704
+ finally:
1705
+ self._abort_signal = None
1706
+ self._active_session = None
1707
+ self._idle = True
1708
+ self._idle_event.set()
1709
+
1710
+ async def _stream_inner(self, text: str, session: Session,
1711
+ agent_override: str | None, abort: AbortSignal | None,
1712
+ source: str):
1713
+ # 旧会话首次被使用时回填 cwd(只写一次);新会话在 create(cwd=…) 时已带
1714
+ self.sessions.ensure_cwd(session, self.cwd)
1715
+ # `next_turn` 档在这里送达:它说的就是“下一次用户输入时再说”,而这就是那个时刻。
1716
+ # 排空 + 落盘之后,下面的 `_history()` 会自然把它算进上下文。
1717
+ self._drain_messages("next_turn")
1718
+ # `input` 在**自动压缩之前**:它处理的是“用户说了什么”,与上下文体积无关;而且
1719
+ # 改写后的文本要影响下游全部(标题、分派、历史)。
1720
+ text, handled, handled_by = await self._emit_input(text, session, source, abort)
1721
+ if handled is not None:
1722
+ self.sessions.append(session, {"type": "message", "role": "user",
1723
+ "content": text, "agent_id": "extension"})
1724
+ async for event in self._handled_turn(session, handled, handled_by):
1725
+ yield event
1726
+ return
1727
+ # 自动命名:**并行**跑(不拖首字延迟),回合末尾才套用(见 `_apply_title`)。
1728
+ # 触发条件是"这个会话还没有标题" —— 新建的与本功能上线前建的老会话都算
1729
+ # (`has_title`:历史默认名 `tui` 也算没有,否则那两千多条老会话永远不被命名)。
1730
+ title_task: asyncio.Task[str | None] | None = None
1731
+ if not has_title(session.title):
1732
+ # 输入取会话原本的第一句话,不是这一轮说的话:老会话续聊时,
1733
+ # "接着再补个测试"会把一个讲仓库结构的会话命名成"补充测试"。
1734
+ # (本轮的用户消息此刻还没落盘 —— 它在下面 append,所以新会话会回落到 text。)
1735
+ title_task = asyncio.create_task(
1736
+ suggest_title(self.llm_exec, self.first_user_text(session) or text))
1737
+ async for event in self._maybe_auto_compact(session):
1738
+ yield event
1739
+ # 单 agent:core 不认识“角色”,所以这里没有分派 —— 就一个固定的运行单元。
1740
+ # 角色(qi-agents)通过 `before_agent_start` 改提示词参与(§1.1),
1741
+ # 而不是让 core 去选一个 agent。下面直接选上下文、落 user 消息。
1742
+ # 顺序要紧:先取上下文(不含本轮),再把 user 消息立即落盘。
1743
+ # 旧实现把 user 写在回合**结束后**,于是运行中刷新/断线就看不到自己说了什么;
1744
+ # 而若先落盘再取 history,本轮输入会进上下文两次(两条同样的 user)。
1745
+ history = self._history(session)
1746
+ self.sessions.append(session, {"type": "message", "role": "user",
1747
+ "content": text, "agent_id": CORE_AGENT_NAME})
1748
+ # `before_agent_start` 在 user 落盘**之后**才 fire(pi 同款):扩展从
1749
+ # `ctx.session_manager` 里就看得到本轮那句话,而且注入的 message 天然排在它后面。
1750
+ tools = self.tool_names()
1751
+ system_prompt, injected = await self._before_agent_start(text, tools, abort)
1752
+ if injected: # 注入的消息**落盘**(下一轮仍在上下文里,pi 说的 persistent message),
1753
+ # 同时交给 runner 插到本轮 user 之后 —— 取 history 时它还不存在,所以不会重复。
1754
+ self.sessions.append(session, {"type": "message", "role": "user",
1755
+ "content": injected, "agent_id": CORE_AGENT_NAME,
1756
+ "injected_by": "before_agent_start"})
1757
+ runner = AgentRunner(
1758
+ # 运行单元:core 只认识“提示词 + 工具名单 + 名字”(见 `RunSpec`)
1759
+ RunSpec(name=CORE_AGENT_NAME, prompt=system_prompt, tools=tools),
1760
+ self.catalog, self.llm_exec,
1761
+ RunnerSettings(stop_after=self.stop_after),
1762
+ tool_ctx=self._tool_ctx(CORE_AGENT_NAME),
1763
+ injected_messages=[injected] if injected else None,
1764
+ drain_injections=self._drain_messages,
1765
+ bus=self.bus,
1766
+ extension_ctx=lambda signal: self.extension_ctx(signal, session),
1767
+ report=self.notes.append)
1768
+ final_text = ""
1769
+ partial = ""
1770
+ # 正常结束才有(RUN_FINISHED 的 usage);硬取消那条路径拿不到。
1771
+ end_usage: dict = {}
1772
+ #: 并发执行时会有多条工具同时在途 —— 靠 `tool_call_id` 配对(以前是单个槽位)
1773
+ pending_tools: dict[str, dict] = {}
1774
+ try:
1775
+ async for event in runner.run(text, history, abort=abort):
1776
+ if event.kind == "agent_end":
1777
+ final_text = event.text
1778
+ end_usage = (event.data or {}).get("usage") or {}
1779
+ elif event.kind == "text_delta":
1780
+ # 当前轮的增量:硬取消发生在 assistant_message 之前时,就靠它抢救半截回答
1781
+ partial += event.text or ""
1782
+ elif event.kind == "assistant_message":
1783
+ partial = event.text or "" # 该轮的权威全文
1784
+ # 思考**先**落:它发生在这条消息之前。顺序反了,回放就成了
1785
+ # "先回答、再思考"。
1786
+ self._persist_thinking(session, CORE_AGENT_NAME,
1787
+ str((event.data or {}).get("thinking") or ""))
1788
+ if event.text and event.data.get("tool_calls"):
1789
+ # 宣布了工具调用的助手消息是"过程"而非最终回答 → 立刻落成 custom entry。
1790
+ # **立即**落盘而不缓冲到下一轮:否则它将被写在它触发的工具卡片**之后**,
1791
+ # 回放顺序就变成"工具卡 → 叙述",与真实因果相反。
1792
+ # 不带工具调用那条由回合末尾的 message entry 代表,所以不会重复。
1793
+ self._persist_narration(session, CORE_AGENT_NAME, event.text)
1794
+ elif event.kind == "tool_start":
1795
+ call_id = str((event.data or {}).get("tool_call_id") or "")
1796
+ pending_tools[call_id] = {
1797
+ "tool": event.tool,
1798
+ "args": (event.data or {}).get("args") or {}}
1799
+ elif event.kind == "tool_end":
1800
+ call_id = str((event.data or {}).get("tool_call_id") or "")
1801
+ pending = pending_tools.pop(call_id, None)
1802
+ if pending is None and len(pending_tools) == 1:
1803
+ # 兼容不给 id 的来源(第三方 AgentEvent 生产者):只有一条在途时可确定
1804
+ _, pending = pending_tools.popitem()
1805
+ if pending is None:
1806
+ pending = {"tool": event.tool, "args": {}}
1807
+ self._persist_tool(session, CORE_AGENT_NAME, event, pending)
1808
+ yield event
1809
+ except asyncio.CancelledError:
1810
+ # 硬取消:收尾不做任何 await(已在取消状态),只做同步落盘
1811
+ self._persist_final(session, CORE_AGENT_NAME, _salvaged(final_text, partial))
1812
+ if title_task is not None:
1813
+ title_task.cancel() # 别把它漏在后台(它还会往会话里写)
1814
+ raise
1815
+
1816
+ # 助手侧在回合结束后落盘(与旧版一致;tool 往返已在上方单独落盘)
1817
+ self._persist_final(session, CORE_AGENT_NAME, final_text, usage=end_usage)
1818
+ if title_task is not None:
1819
+ await self._apply_title(session, title_task)
1820
+ # `agent_settled`(pi):回合**真的**结束了 —— 没有重试、没有压缩残留、没有排队消息。
1821
+ # qi 的 provider 级重试在客户端内部(那时本方法还没跑完),所以到这里就是终点;唯一要
1822
+ # 排掉的是“还有排队消息会让它再跑一轮”的情形。
1823
+ if self.bus.has("agent_settled") and not self.has_pending_messages():
1824
+ settled = await self.bus.emit("agent_settled", {},
1825
+ ctx=self.extension_ctx(session=session))
1826
+ for src, exc in settled.errors:
1827
+ self.notes.append(f"扩展 {src} 的 agent_settled 处理失败: {exc}")
1828
+
1829
+ def _persist_final(self, session: Session, agent: str, text: str,
1830
+ usage: dict | None = None) -> None:
1831
+ """回合末尾的助手消息(正常结束与中断收尾共用)。
1832
+
1833
+ `usage` 落盘在本条 entry 上:它原来是**只活在流里**的(RUN_FINISHED 的
1834
+ metadata),刷新/重开就没了 —— 于是"这个会话用了多少 token"在设置页与
1835
+ 输入卡下方都无从得知。放在助手消息上是因为它天然按轮分片(一輪一条),
1836
+ 汇总就是会话级(见 `usage_summary`)。
1837
+
1838
+ 参数与分支摘要条目里的 `usage` 同名同义(那边是压缩那一次的用量)。
1839
+ """
1840
+ entry: dict = {"type": "message", "role": "assistant",
1841
+ "content": text or "(无文本输出)",
1842
+ "agent_id": agent}
1843
+ if usage:
1844
+ entry["usage"] = usage
1845
+ self.sessions.append(session, entry)
1846
+
1847
+ @staticmethod
1848
+ def first_user_text(session: Session) -> str:
1849
+ """当前分支上第一条用户消息(没有就空串)。命名拿它当输入。"""
1850
+ for entry in session.branch():
1851
+ if entry.get("type") == "message" and entry.get("role") == "user":
1852
+ return str(entry.get("content") or "")
1853
+ return ""
1854
+
1855
+ async def _apply_title(self, session: Session, task: asyncio.Task[str | None]) -> None:
1856
+ """把并行起的命名结果套到会话上。
1857
+
1858
+ **最多等 `TITLE_WAIT_S`**:命名是体验改进,但不该拖住"这一轮结束"(前端在
1859
+ RUN_FINISHED 之后才刷新列表 —— 等太久就是可见的卡顿)。等不到就把落盘挂到
1860
+ 回调上,它完成时自己写盘:下一次刷新(下一轮 / 重开会话)就能看到标题。
1861
+ """
1862
+ done, _pending = await asyncio.wait({task}, timeout=TITLE_WAIT_S)
1863
+ if task in done:
1864
+ self._write_title(session, task.result())
1865
+ return
1866
+ task.add_done_callback(
1867
+ lambda t: self._write_title(session, None if t.cancelled() else t.result()))
1868
+
1869
+ def _write_title(self, session: Session, title: str | None) -> None:
1870
+ """落盘**模型给的**标题。空标题、或用户已经手动改过 → 不动。
1871
+
1872
+ 两条都必须有:模型回垃圾时保持「未命名」好过写个垃圾进文件;而用户在流式期间
1873
+ 手动改了名,自动命名不能把它覆盖回去。
1874
+
1875
+ 实际落盘走 `set_session_title` —— 那里同时发 `session_info_changed`,
1876
+ 所以自动命名与用户改名两条路都不会漏事件(也不会重发)。
1877
+ """
1878
+ if not title or has_title(session.title):
1879
+ return
1880
+ self.set_session_title(session, title, source="auto")
1881
+
1882
+ def _persist_thinking(self, session: Session, agent: str, text: str) -> None:
1883
+ """落盘一步思考(entry `type=custom`, `custom_type=assistant_thinking`)。
1884
+
1885
+ 与叙述同一个理由:思考是**过程**,直播时看得见(pi 的 thinking block),
1886
+ 不落盘则刷新/回放里整段消失 —— 界面上"思考与回答之间那条分隔线"也就
1887
+ 少了上半截(只剩一条孤零零的线)。
1888
+
1889
+ 做成 `custom` 而不是 `message` 是**刻意的**:`_history()` 只读 `message`,
1890
+ 所以思考**不进 LLM 上下文**(零提示词回归风险)。
1891
+
1892
+ 思考可能很长:与工具结果同档封顶,不让会话文件无界增长。
1893
+ 每步一条 `assistant_message`,空文本跳过。
1894
+ """
1895
+ if not text.strip():
1896
+ return
1897
+ body = (text if len(text) <= MAX_TOOL_ENTRY_CHARS
1898
+ else text[:MAX_TOOL_ENTRY_CHARS] + "…(落盘已截断)")
1899
+ self.sessions.append(session, {"type": "custom",
1900
+ "custom_type": "assistant_thinking",
1901
+ "agent": agent, "content": body})
1902
+
1903
+ def _persist_narration(self, session: Session, agent: str, text: str) -> None:
1904
+ """落盘"工具调用之前"的助手叙述(custom entry,**不进对话上下文**)。
1905
+
1906
+ 直播时这些文字由 `text_delta` 送到前端;不落盘则刷新/回放就只剩工具卡片,
1907
+ 直播与回放不一致。做成 `custom` 而非 `message` 是**刻意的**:`_history()` 只读
1908
+ `message`,所以模型跨轮上下文完全不变(零提示词回归风险)。
1909
+ """
1910
+ self.sessions.append(session, {"type": "custom",
1911
+ "custom_type": "assistant_narration",
1912
+ "agent": agent, "content": text})
1913
+
1914
+ def _persist_tool(self, session: Session, agent: str, event: AgentEvent,
1915
+ pending: dict | None) -> None:
1916
+ """把一次工具往返落盘(entry `type=tool`)。
1917
+
1918
+ 补齐 `session.py` 已声明的第五类 entry(PLAN A5):此前 tool 往返**从不落盘**,
1919
+ 于是历史回放里工具卡片无法重现。`status`/`duration_ms`/`exit_code` 来自
1920
+ AgentRunner 的结构化结果,前端不必解析 `result` 字符串。
1921
+ """
1922
+ data = event.data or {}
1923
+ result = event.text or ""
1924
+ if len(result) > MAX_TOOL_ENTRY_CHARS:
1925
+ result = result[:MAX_TOOL_ENTRY_CHARS] + "…(落盘已截断)"
1926
+ # 插件能给的结构可能很大(一份查询结果、一棵文件树)。同样封顶,
1927
+ # 但**换成可渲染的标记**而不是切字符串 —— 切 JSON 会得到非法 JSON。
1928
+ details = data.get("details")
1929
+ if details is not None:
1930
+ encoded = json.dumps(details, ensure_ascii=False)
1931
+ if len(encoded) > MAX_TOOL_DETAILS_CHARS:
1932
+ details = {"_truncated": True, "_full_chars": len(encoded)}
1933
+ self.sessions.append(session, {
1934
+ "type": "tool", "agent": agent, "tool": event.tool,
1935
+ "args": (pending or {}).get("args", {}),
1936
+ "status": data.get("status"), "duration_ms": data.get("duration_ms"),
1937
+ "exit_code": data.get("exit_code"), "error": data.get("error"),
1938
+ "details": details,
1939
+ "result": result,
1940
+ })