mosscode 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 (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. mosscode-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1046 @@
1
+ """Stable facade: one independent AgentLoop owns state and history."""
2
+
3
+ import asyncio
4
+ import difflib
5
+ from contextlib import aclosing
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+
10
+ from .agent import DEFAULT_MAX_ITERATIONS, AgentLoop, Provider
11
+ from .agents import AgentCatalog
12
+ from .agents.runtime import AUTO_BACKGROUND, GLOBAL_BAN, MAX_BACKGROUND, SubAgents
13
+ from .context import ContextBudget, estimate_text
14
+ from .memory import INDEX_TOKENS, MemoryStore
15
+ from .memory_extract import MemoryExtractor
16
+ from .messages import (
17
+ NOTIFICATION,
18
+ CompactFailed,
19
+ CompactFinished,
20
+ Completed,
21
+ ContentBlock,
22
+ Message,
23
+ ProviderError,
24
+ RunStopped,
25
+ ToolProgress,
26
+ UsageUpdate,
27
+ )
28
+ from .prompts import (
29
+ display_path,
30
+ gap_reminder,
31
+ git_snapshot,
32
+ hook_context,
33
+ instructions_section,
34
+ load_instructions,
35
+ memory_section,
36
+ )
37
+ from .session import (
38
+ Restored,
39
+ Session,
40
+ SessionLocked,
41
+ Transcript,
42
+ find_session,
43
+ list_sessions,
44
+ load_session,
45
+ provider_record,
46
+ skill_record,
47
+ )
48
+ from .skills import ACTIVE_LIMIT, LOAD_SKILL, ForkRequest, SkillCatalog, SkillDef, missing_tools
49
+ from .tools.agent import AgentTool, TaskOutput, TaskStop
50
+ from .tools.base import ToolFailure, ToolRegistry
51
+ from .tools.memory import Memory
52
+ from .tools.skill import LoadSkill, SkillTool
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Notice:
57
+ """A local note raised outside the model's events, e.g. a transcript write failure."""
58
+
59
+ text: str
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class MemoryUpdated:
64
+ """The background extractor wrote or deleted notes."""
65
+
66
+ names: tuple[str, ...]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Resumed:
71
+ """A session was restored; shown before any compaction it needs."""
72
+
73
+ restored: Restored
74
+ messages: int
75
+ notices: tuple[str, ...]
76
+
77
+
78
+ class Conversation:
79
+ def __init__(
80
+ self,
81
+ provider: Provider,
82
+ system: str = "",
83
+ registry: ToolRegistry | None = None,
84
+ *,
85
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
86
+ unknown_limit: int = 3,
87
+ mcp_manager=None,
88
+ context_root: Path | None = None,
89
+ instructions_home: Path | None = None,
90
+ budget: ContextBudget | None = None,
91
+ session=None,
92
+ auto_compact: bool = True,
93
+ notices: tuple[str, ...] = (),
94
+ persist: bool = False,
95
+ provider_info: tuple[str, str, str] | None = None,
96
+ sessions_home: Path | None = None,
97
+ memory: MemoryStore | None = None,
98
+ auto_memory: bool = False,
99
+ memory_provider: Provider | None = None,
100
+ skills: SkillCatalog | None = None,
101
+ provider_configs=(),
102
+ provider_factory=None,
103
+ hooks=None,
104
+ agents: AgentCatalog | None = None,
105
+ agent_auto_background: float = AUTO_BACKGROUND,
106
+ agent_max_background: int = MAX_BACKGROUND,
107
+ worktree_links=(),
108
+ worktree_stale_hours: float = 24.0,
109
+ teams: bool = False,
110
+ team_max_members: int = 4,
111
+ teammate_mode: str = "in-process",
112
+ team_launch: dict | None = None,
113
+ coordinator: bool = False,
114
+ ):
115
+ self.provider, self.system, self.registry = provider, system, registry
116
+ # The worktree manager is built on first use; it needs a git repository.
117
+ self.worktree_links, self.worktree_stale_hours = tuple(worktree_links), worktree_stale_hours
118
+ self._worktrees = None
119
+ self._worktree_cleanup: asyncio.Task | None = None
120
+ # The HookEngine; None runs no hooks at all.
121
+ self.hooks = hooks
122
+ self._session_started = False
123
+ self.mcp_manager = mcp_manager
124
+ self.skills = skills if skills is not None else SkillCatalog() # Empty without a scan.
125
+ self._skill_tools: dict[str, tuple[str, ...]] = {} # Skill name -> its tools registered here.
126
+ # A fork skill's model names one of these; the factory builds it (create_provider by default).
127
+ self.provider_configs, self.provider_factory = tuple(provider_configs), provider_factory
128
+ # Project files and git are product-level context; None keeps the core hermetic.
129
+ self.context_root, self.instructions_home = context_root, instructions_home
130
+ self._context_loaded = False
131
+ self.instruction_files: tuple[str, ...] = ()
132
+ self._mcp_background = False
133
+ self._start_lock = asyncio.Lock()
134
+ self._notices = list(notices) # Reported once, by the first start().
135
+ self._raised: list[str] = [] # Notices raised while running, delivered as Notice events.
136
+ self._agent = AgentLoop(provider, system, registry, max_iterations=max_iterations, unknown_limit=unknown_limit)
137
+ self.system = self._agent.system
138
+ if budget is not None:
139
+ self._agent.budget = budget
140
+ self._agent.auto_compact = auto_compact
141
+ # (name, protocol, model) of the current provider: written to the transcript on a switch.
142
+ self.provider_info = provider_info
143
+ # Transcripts need a session from Session.new; persist=False keeps only stored results.
144
+ self.persist = persist and session is not None and session.transcript_path is not None
145
+ self.sessions_home = sessions_home
146
+ self._transcript: Transcript | None = None
147
+ self._agent.on_commit = self._record
148
+ if hooks is not None:
149
+ hooks.on_notice = self._hook_notice
150
+ self._agent.hooks = hooks
151
+ if registry is not None:
152
+ registry.hooks = hooks
153
+ self._use_session(session)
154
+ # Notes are injected only when context is loaded (context_root); None turns memory off.
155
+ self.memory = memory
156
+ self.memory_provider = memory_provider
157
+ self.on_memory = None # Set by the TUI: receives MemoryUpdated and Notice from the background.
158
+ self.extractor: MemoryExtractor | None = None
159
+ self._cursor = 0 # History index up to which messages went to the extractor.
160
+ self._cut: int | None = None # Cut of the compaction in progress, once its messages were handed over.
161
+ if memory is not None:
162
+ if registry is not None:
163
+ if registry.get("memory") is None:
164
+ registry.register(Memory(registry.permissions.root, memory))
165
+ registry.permissions.memory_dirs = tuple(path.resolve() for path in memory.dirs.values())
166
+ if auto_memory:
167
+ self.extractor = MemoryExtractor(
168
+ memory,
169
+ lambda: self.memory_provider or self.provider,
170
+ on_result=lambda r: self._emit(MemoryUpdated(r.names)),
171
+ on_notice=lambda text: self._emit(Notice(text)),
172
+ on_usage=self._agent._usage,
173
+ )
174
+ self._agent.on_compact = self._handover
175
+ self._notices.extend(self._offer_skills())
176
+ # Sub-agents. The agent tools are offered only with a catalog (None keeps the core hermetic);
177
+ # the runtime itself always exists, Skill fork runs on it.
178
+ self.project_instructions = "" # The project files only, for definition sub-agents.
179
+ self.on_tasks = None # Set by the UI: background results wait while the main agent is idle.
180
+ self.subagents = SubAgents(
181
+ self, agents, auto_background=agent_auto_background, max_background=agent_max_background
182
+ )
183
+ self.agents_enabled = agents is not None
184
+ if hooks is not None:
185
+ hooks.agent_runner = self.subagents.hook_agent
186
+ self._notices.extend(self._offer_agents())
187
+ # The lead's team tools from the start, in the interactive UI with tools and agents.
188
+ self.teams = None
189
+ self._team_context_due = False # After /clear or /resume, once before the next message.
190
+ if teams and self.registry is not None and self.agents_enabled:
191
+ from .teams.runtime import TeamRuntime
192
+ from .tools.team import lead_tools
193
+
194
+ self.teams = TeamRuntime(
195
+ self, home=sessions_home, max_members=team_max_members, teammate_mode=teammate_mode, launch=team_launch
196
+ )
197
+ for tool in lead_tools(self.registry.permissions.root, self.teams):
198
+ if self.registry.get(tool.name) is None:
199
+ self.registry.register(tool)
200
+ self._agent.team_guide = self.teams.guide()
201
+ # Decided at startup (the CLI checks both switches), fixed for the session.
202
+ self.coordinator = False
203
+ if coordinator:
204
+ from .coordinator import NO_AGENTS, SECTION, withheld
205
+
206
+ if self.teams is None:
207
+ self._notices.append(NO_AGENTS)
208
+ else:
209
+ self.coordinator = True
210
+ self.registry.withheld = withheld
211
+ self._agent.team_guide += "\n\n" + SECTION
212
+
213
+ def _use_session(self, session) -> None:
214
+ if self._transcript is not None:
215
+ self._transcript.close()
216
+ self.session = session
217
+ # Stored tool results and compaction records; without it large results are only cut to a preview.
218
+ self._agent.session = session
219
+ if self.hooks is not None:
220
+ self.hooks.use_session(session)
221
+ if self.registry is not None and session is not None:
222
+ self.registry.use_session(session)
223
+ root = self.context_root or Path.cwd()
224
+ self._transcript = Transcript(session, root.resolve()) if self.persist else None
225
+
226
+ def _record(self, record: dict) -> None:
227
+ if self._transcript is not None:
228
+ notice = self._transcript.append(record)
229
+ if notice:
230
+ self._raised.append(notice)
231
+
232
+ def _emit(self, event) -> None:
233
+ if self.on_memory is not None:
234
+ self.on_memory(event)
235
+
236
+ # Hooks --------------------------------------------------------------------------------
237
+
238
+ def _hook_notice(self, text: str) -> None:
239
+ """Background failures go out at once; without a listener they join the next events."""
240
+ if self.on_memory is not None:
241
+ self._emit(Notice(text))
242
+ else:
243
+ self._raised.append(text)
244
+
245
+ async def _fire(self, name: str, fields: dict):
246
+ if self.hooks is None or not self.hooks.wants(name):
247
+ return ()
248
+ from .hooks import HookEvent
249
+
250
+ return (await self.hooks.fire(HookEvent(name, fields=fields))).notes
251
+
252
+ async def _session_start(self, source: str) -> None:
253
+ """Its output is the hook-context prefix of the whole session."""
254
+ self._session_started = True
255
+ if self.hooks is not None:
256
+ self._agent.hook_context = hook_context(await self._fire("session_start", {"source": source}))
257
+
258
+ async def _session_end(self, reason: str) -> None:
259
+ if self._session_started:
260
+ self._session_started = False
261
+ await self._fire("session_end", {"reason": reason})
262
+
263
+ async def _turn_end(self, reason: str, notice: str) -> None:
264
+ if reason == "error":
265
+ await self._fire("error", {"error": notice}) # Before turn_end; hook failures never fire it.
266
+ await self._fire("turn_end", {"reason": reason})
267
+
268
+ def _turn_end_later(self, reason: str) -> None:
269
+ """A cancelled turn cannot wait: its end hooks run in the background."""
270
+ if self.hooks is not None and self.hooks.wants("turn_end"):
271
+ self.hooks.spawn(self._fire("turn_end", {"reason": reason}))
272
+
273
+ # Background extraction ---------------------------------------------------------
274
+
275
+ def _extract(self) -> None:
276
+ """After a completed task: everything since the cursor goes to the extractor."""
277
+ if self.extractor is None:
278
+ return
279
+ history = self._agent.history
280
+ messages, self._cursor = history[self._cursor :], len(history)
281
+ self.extractor.schedule(messages)
282
+
283
+ def _handover(self, removed) -> None:
284
+ """Before a compaction: messages about to be summarized that the extractor has not seen yet."""
285
+ if self._cursor < len(removed):
286
+ self.extractor.schedule(removed[self._cursor :])
287
+ self._cursor = max(self._cursor, len(removed))
288
+ self._cut = len(removed)
289
+
290
+ def _observe(self, event) -> None:
291
+ if isinstance(event, CompactFinished) and self._cut is not None:
292
+ self._cursor, self._cut = 1 + self._cursor - self._cut, None # Points past the boundary.
293
+ elif isinstance(event, CompactFailed):
294
+ self._cut = None
295
+ elif isinstance(event, RunStopped) and event.reason == "completed":
296
+ self._extract()
297
+
298
+ def _rewind(self) -> None:
299
+ """/clear and resume: nothing already in the history is extracted again."""
300
+ self._cursor, self._cut = len(self._agent.history), None
301
+ if self.extractor is not None:
302
+ self.extractor.reset()
303
+
304
+ def _drain(self):
305
+ notices, self._raised = self._raised, []
306
+ return [Notice(text) for text in notices]
307
+
308
+ @property
309
+ def session_id(self) -> str | None:
310
+ return self.session.id if self.session is not None else None
311
+
312
+ @property
313
+ def history(self):
314
+ return tuple(deepcopy(self._agent.history))
315
+
316
+ @property
317
+ def _busy(self):
318
+ return self._agent.busy
319
+
320
+ @property
321
+ def mode(self):
322
+ return self._agent.mode
323
+
324
+ @property
325
+ def total_usage(self):
326
+ return self._agent.total_usage
327
+
328
+ @property
329
+ def budget(self) -> ContextBudget:
330
+ return self._agent.budget
331
+
332
+ @property
333
+ def compactions(self) -> int:
334
+ return self._agent.compactions
335
+
336
+ @property
337
+ def auto_compact_paused(self) -> bool:
338
+ return self._agent.auto_paused
339
+
340
+ def context_estimate(self) -> int | None:
341
+ """Approximate size of the next request; None until the first usage report."""
342
+ if self._agent.meter.anchor is None and not self._agent.history:
343
+ return None
344
+ return self._agent.estimate()
345
+
346
+ def context_ratio(self) -> float | None:
347
+ estimate = self.context_estimate()
348
+ return None if estimate is None else estimate / self.budget.effective
349
+
350
+ async def compact(self, instructions: str = ""):
351
+ """Manual /compact while idle; events report progress and the outcome."""
352
+ async with aclosing(self._agent.compact(instructions)) as stream:
353
+ async for event in stream:
354
+ self._observe(event)
355
+ yield event
356
+ for notice in self._drain():
357
+ yield notice
358
+
359
+ def set_mode(self, mode: str):
360
+ self._agent.set_mode(mode)
361
+
362
+ def queue(self, text: str):
363
+ """Steer a running task; the text joins at the next iteration boundary."""
364
+ if text.strip():
365
+ self._agent.pending.append(text)
366
+
367
+ def take_queued(self) -> tuple[str, ...]:
368
+ texts, self._agent.pending = tuple(self._agent.pending), []
369
+ return texts
370
+
371
+ @property
372
+ def queued(self) -> tuple[str, ...]:
373
+ return tuple(self._agent.pending)
374
+
375
+ def _next_session(self):
376
+ """/clear ends this session: the next message starts a new ID and directory."""
377
+ if self.session is None or self.session.transcript_path is None:
378
+ return
379
+ root = self.context_root or Path.cwd()
380
+ self._use_session(Session.new(root, home=self.sessions_home))
381
+
382
+ async def clear(self) -> tuple[str, ...]:
383
+ """New session: empty model context and usage; permissions and MCP stay."""
384
+ if self._busy:
385
+ raise ProviderError("当前任务正在运行,不能清空会话。")
386
+ await self.tasks.reset() # Tasks belong to the session.
387
+ await self._session_end("clear")
388
+ self._agent.clear() # Active skills too.
389
+ self._drop_skills()
390
+ self._next_session()
391
+ self._rewind()
392
+ if self.hooks is not None:
393
+ self.hooks.new_session(self.session) # Once resets before the new session_start.
394
+ notices = []
395
+ if self.context_root is not None:
396
+ async with self._start_lock:
397
+ self._context_loaded = True
398
+ notices = await self._load_context()
399
+ await self._session_start("clear")
400
+ self._team_context_due = self.teams is not None # The team keeps running.
401
+ return tuple(notices)
402
+
403
+ async def set_provider(
404
+ self, provider: Provider, budget: ContextBudget | None = None, info: tuple[str, str, str] | None = None
405
+ ):
406
+ old = self.provider
407
+ self._agent.set_provider(provider, budget)
408
+ self.provider = provider
409
+ if info is not None:
410
+ self.provider_info = info
411
+ if self._agent.history:
412
+ self._record(provider_record(*info))
413
+ if old is not provider:
414
+ await old.close()
415
+
416
+ def set_permission_mode(self, mode: str):
417
+ if self._busy:
418
+ raise ProviderError("当前任务正在运行,不能切换权限模式。")
419
+ if self.registry is None:
420
+ raise ProviderError("当前会话未启用工具。")
421
+ self.registry.permissions.set_mode(mode)
422
+
423
+ # Skills ---------------------------------------------------------------------------------
424
+
425
+ @property
426
+ def active_skills(self) -> tuple[str, ...]:
427
+ return self._agent.active_skills.names
428
+
429
+ def _offer_skills(self) -> list[str]:
430
+ """The catalog section and load_skill, when tools are on and the model may load a skill."""
431
+ if self.registry is None:
432
+ return []
433
+ section, notice = self.skills.catalog_section()
434
+ self._agent.skill_catalog = section
435
+ if section and self.registry.get(LOAD_SKILL) is None:
436
+ self.registry.register(LoadSkill(self.registry.permissions.root, self._load_skill))
437
+ elif not section and self.registry.get(LOAD_SKILL) is not None:
438
+ self.registry.unregister(LOAD_SKILL)
439
+ return [notice] if notice else []
440
+
441
+ def _missing(self, skill: SkillDef) -> list[str]:
442
+ """The tools it lists must be registered now."""
443
+ return missing_tools(skill, self.registry.names() if self.registry is not None else ())
444
+
445
+ def _use_skill_dir(self, skill: SkillDef) -> None:
446
+ """Its files can be read without approval once it is in use."""
447
+ if self.registry is None:
448
+ return
449
+ policy = self.registry.permissions
450
+ directory = skill.directory.resolve()
451
+ if directory not in policy.skill_dirs:
452
+ policy.skill_dirs += (directory,)
453
+
454
+ def _register_skill_tools(self, skill: SkillDef) -> None:
455
+ """The version just read replaces whatever this skill registered before."""
456
+ self._remove_skill_tools(skill.name)
457
+ if self.registry is None or not skill.tools:
458
+ return
459
+ root = self.registry.permissions.root
460
+ for spec in skill.tools:
461
+ self.registry.unregister(spec.full_name)
462
+ self.registry.register(SkillTool(root, skill, spec))
463
+ self._skill_tools[skill.name] = tuple(spec.full_name for spec in skill.tools)
464
+
465
+ def _drop_skills(self) -> None:
466
+ """/clear and resume: no skill is in use any more."""
467
+ if self.registry is not None:
468
+ self.registry.permissions.skill_dirs = ()
469
+ for name in list(self._skill_tools):
470
+ self._remove_skill_tools(name)
471
+
472
+ def _remove_skill_tools(self, name: str) -> None:
473
+ for full_name in self._skill_tools.pop(name, ()):
474
+ if self.registry is not None:
475
+ self.registry.unregister(full_name)
476
+
477
+ def prepare_skill(self, name: str, arguments: str = "") -> tuple[SkillDef, str, tuple[str, ...]]:
478
+ """Read the skill again and check its tools; returns the skill, the request and notices.
479
+
480
+ A fork skill's tools go to its sub-agent only; the caller sends a ForkRequest.
481
+ """
482
+ if self.skills.get(name) is None:
483
+ raise ProviderError(f"未找到 Skill {name}。输入 /skills 查看。")
484
+ skill, notice = self.skills.fresh(name)
485
+ missing = self._missing(skill)
486
+ if missing:
487
+ raise ProviderError(
488
+ f"Skill {name} 未运行:工具不可用:{'、'.join(missing)}。"
489
+ "MCP 工具需要对应的 Server 已连接(/mcp 查看)。"
490
+ )
491
+ self._use_skill_dir(skill)
492
+ if skill.mode == "fork":
493
+ if skill.model and self._fork_provider_config(skill.model) is None and not self._is_current(skill.model):
494
+ names = "、".join(config.name for config in self.provider_configs) or "无"
495
+ raise ProviderError(f"Skill {name} 未运行:没有名为 {skill.model} 的提供商(可用:{names})。")
496
+ return skill, skill.render(arguments, fork=True), (notice,) if notice else ()
497
+ self._register_skill_tools(skill)
498
+ self._record(skill_record(name, arguments, False))
499
+ return skill, skill.render(arguments), (notice,) if notice else ()
500
+
501
+ def _load_skill(self, name: str, arguments: str) -> tuple[str, dict]:
502
+ """The load_skill tool; failures are tool errors and the run goes on."""
503
+ visible = [item.name for item in self.skills.skills.values() if item.model_invocable]
504
+ if self.skills.get(name) is None:
505
+ close = difflib.get_close_matches(name, visible, n=3, cutoff=0.5)
506
+ hint = f"相近的名称:{'、'.join(close)}。" if close else f"可加载:{'、'.join(visible) or '无'}。"
507
+ raise ToolFailure("skill_unknown", f"没有名为 {name} 的 Skill。{hint}")
508
+ skill, notice = self.skills.fresh(name)
509
+ if not skill.model_invocable:
510
+ raise ToolFailure("skill_unavailable", f"Skill {name} 只能由用户输入 /{name} 调用,不能由模型加载。")
511
+ missing = self._missing(skill)
512
+ if missing:
513
+ raise ToolFailure("skill_unavailable", f"Skill {name} 需要的工具当前不可用:{'、'.join(missing)}。")
514
+ text = skill.render(arguments)
515
+ active = self._agent.active_skills
516
+ if active.size(without=name) + len(text) > ACTIVE_LIMIT:
517
+ raise ToolFailure(
518
+ "skill_too_large",
519
+ f"已加载的 Skill 规程合计将超过 {ACTIVE_LIMIT} 个字符,未加载 {name}。"
520
+ "可以请用户 /clear 后再加载,或直接按已加载的规程继续。",
521
+ )
522
+ refreshed = name in active
523
+ active.activate(skill, arguments, text)
524
+ self._use_skill_dir(skill)
525
+ self._register_skill_tools(skill)
526
+ self._record(skill_record(name, arguments, True))
527
+ if notice:
528
+ self._raised.append(notice)
529
+ lines = [f"已{'刷新' if refreshed else '加载'} Skill {name},规程已放入 <active-skills>,下一次请求起生效。"]
530
+ if skill.tools:
531
+ lines.append("新增专属工具:" + "、".join(tool.full_name for tool in skill.tools))
532
+ if skill.mode == "fork":
533
+ lines.append(f"此 Skill 设计为独立运行,需要隔离上下文时请用户输入 /{name}。")
534
+ if notice:
535
+ lines.append(notice)
536
+ return "\n".join(lines), {"skill": name, "arguments": arguments, "refreshed": refreshed}
537
+
538
+ def use_skills(self, catalog: SkillCatalog) -> tuple[str, ...]:
539
+ """The catalog after /skills reload; removed skills leave the active list and take their tools."""
540
+ self.skills = catalog
541
+ active = self._agent.active_skills
542
+ for name in active.names:
543
+ if catalog.get(name) is None:
544
+ active.remove(name)
545
+ for name in list(self._skill_tools):
546
+ if (skill := catalog.get(name)) is None:
547
+ self._remove_skill_tools(name)
548
+ else:
549
+ self._register_skill_tools(skill)
550
+ return tuple(self._offer_skills())
551
+
552
+ def _restore_skills(self, records: tuple[tuple[str, str, bool], ...]) -> list[str]:
553
+ """Tools of every skill the session used, SOPs of the pinned ones; the rest is reported."""
554
+ used, pinned = {}, {}
555
+ for name, arguments, is_pinned in records:
556
+ used[name] = None
557
+ if is_pinned:
558
+ pinned[name] = arguments # The latest arguments; the first load keeps its place.
559
+ notices, lost = [], []
560
+ active = self._agent.active_skills
561
+ for name in used:
562
+ if self.skills.get(name) is None:
563
+ lost.append(f"{name}(已不存在)")
564
+ continue
565
+ skill, notice = self.skills.fresh(name)
566
+ if notice:
567
+ notices.append(notice)
568
+ if name in pinned:
569
+ text = skill.render(pinned[name])
570
+ if not skill.model_invocable:
571
+ lost.append(f"{name}(已不能由模型加载,规程未放回)")
572
+ elif active.size(without=name) + len(text) > ACTIVE_LIMIT:
573
+ lost.append(f"{name}(规程合计超过 {ACTIVE_LIMIT} 个字符,未放回)")
574
+ else:
575
+ active.activate(skill, pinned[name], text)
576
+ self._use_skill_dir(skill)
577
+ self._register_skill_tools(skill)
578
+ if lost:
579
+ notices.append("未恢复的 Skill:" + "、".join(lost) + "。")
580
+ return notices
581
+
582
+ # Sub-agents --------------------------------------------------------------------------
583
+
584
+ @property
585
+ def agents(self) -> AgentCatalog:
586
+ return self.subagents.catalog
587
+
588
+ @property
589
+ def tasks(self):
590
+ return self.subagents.tasks
591
+
592
+ def _offer_agents(self) -> list[str]:
593
+ """The catalog section and the three tools, when tools are on."""
594
+ if self.registry is None or not self.agents_enabled:
595
+ return []
596
+ self._agent.agent_catalog = self.agents.catalog_section()
597
+ root = self.registry.permissions.root
598
+ for tool in (AgentTool(root, self.subagents), TaskOutput(root, self.subagents), TaskStop(root, self.subagents)):
599
+ if self.registry.get(tool.name) is None:
600
+ self.registry.register(tool)
601
+ problems = self.agents.problems
602
+ return [f"{len(problems)} 个 Agent 定义有问题,已跳过;输入 /agents 查看。"] if problems else []
603
+
604
+ def use_agents(self, catalog: AgentCatalog) -> tuple[str, ...]:
605
+ """/agents reload; the next request sees the new catalog."""
606
+ self.subagents.catalog = catalog
607
+ return tuple(self._offer_agents())
608
+
609
+ def _tasks_ready(self) -> None:
610
+ """A background result waits in notifications; an idle main agent starts a turn for it."""
611
+ if not self._busy and self.on_tasks is not None:
612
+ self.on_tasks()
613
+
614
+ def take_notifications(self) -> tuple[str, ...]:
615
+ texts, self._agent.notifications = tuple(self._agent.notifications), []
616
+ return texts
617
+
618
+ @property
619
+ def notifications(self) -> tuple[str, ...]:
620
+ return tuple(self._agent.notifications)
621
+
622
+ async def notify(self):
623
+ """A turn whose first message is the waiting notifications, merged into one."""
624
+ texts = self.take_notifications()
625
+ if not texts:
626
+ return
627
+ async with aclosing(self.reply("\n\n".join(texts), synthetic=NOTIFICATION)) as stream:
628
+ async for event in stream:
629
+ yield event
630
+
631
+ async def cancel_tasks(self, wait: float | None = None) -> int:
632
+ """Cancel every running task; their notifications are never sent."""
633
+ from .agents.tasks import CANCEL_WAIT
634
+
635
+ count = await self.tasks.cancel_all(CANCEL_WAIT if wait is None else wait)
636
+ self._agent.notifications = []
637
+ return count
638
+
639
+ # Resuming -------------------------------------------------------------------------------
640
+
641
+ def _sessions_root(self) -> Path:
642
+ return self.context_root or Path.cwd()
643
+
644
+ def sessions(self, limit: int = 20):
645
+ return list_sessions(self._sessions_root(), home=self.sessions_home, limit=limit) if self.persist else []
646
+
647
+ def unavailable(self) -> str | None:
648
+ """Why /resume cannot work in this run, or None."""
649
+ if not self.persist:
650
+ return "会话存档已关闭(persist_sessions: false),不能恢复会话。"
651
+ return None
652
+
653
+ def resolve_session(self, session_id: str | None) -> str:
654
+ """'' or None means the most recent session of this project."""
655
+ reason = self.unavailable()
656
+ if reason:
657
+ raise ProviderError(reason)
658
+ if session_id:
659
+ if find_session(self._sessions_root(), session_id, home=self.sessions_home) is None:
660
+ recent = "、".join(s.id for s in self.sessions(5)) or "无"
661
+ raise ProviderError(f"未找到会话 {session_id}。最近的会话:{recent}")
662
+ return session_id
663
+ recent = [s for s in self.sessions() if s.id != self.session_id]
664
+ if not recent:
665
+ raise ProviderError("本项目没有可恢复的会话。")
666
+ return recent[0].id
667
+
668
+ async def resume(self, session_id: str):
669
+ """Restore a saved session, then compact it if it no longer fits. Yields Resumed first."""
670
+ if self._busy:
671
+ raise ProviderError("当前任务正在运行,不能恢复会话。")
672
+ reason = self.unavailable()
673
+ if reason:
674
+ raise ProviderError(reason)
675
+ if session_id == self.session_id and self._agent.history:
676
+ raise ProviderError("这已是当前会话。")
677
+ path = find_session(self._sessions_root(), session_id, home=self.sessions_home)
678
+ if path is None:
679
+ raise ProviderError(f"未找到会话 {session_id}。")
680
+ session = Session.from_transcript(path)
681
+ transcript = Transcript(session, self._sessions_root().resolve())
682
+ try:
683
+ transcript.open_existing() # Lock the new one before letting go of the current one.
684
+ except SessionLocked as exc:
685
+ raise ProviderError(str(exc)) from None
686
+ except OSError as exc:
687
+ raise ProviderError(f"无法打开会话存档:{exc.strerror or exc}") from None
688
+ try:
689
+ provider = self.provider_info[:2] if self.provider_info else None
690
+ restored = await asyncio.to_thread(load_session, path, provider)
691
+ except OSError as exc:
692
+ transcript.close()
693
+ raise ProviderError(f"无法读取会话存档:{exc.strerror or exc}") from None
694
+ await self.tasks.reset() # Their end records go to the session being left.
695
+ await self._session_end("resume")
696
+ if self._transcript is not None:
697
+ self._transcript.close()
698
+ self.session, self._agent.session = session, session
699
+ if self.hooks is not None:
700
+ self.hooks.new_session(session)
701
+ if self.registry is not None:
702
+ self.registry.use_session(session)
703
+ # Approvals belong to the run that granted them, never to a restored transcript.
704
+ self.registry.permissions.session.clear()
705
+ self._transcript = transcript
706
+ self._agent.restore(restored.history, gap_reminder(restored.last_time))
707
+ self._drop_skills()
708
+ self._rewind()
709
+ notices = []
710
+ if self.context_root is not None:
711
+ async with self._start_lock:
712
+ self._context_loaded = True
713
+ notices = await self._load_context()
714
+ notices.extend(self._restore_skills(restored.skills))
715
+ if restored.skipped:
716
+ notices.append(f"跳过了 {restored.skipped} 行无法读取的记录。")
717
+ if restored.interrupted:
718
+ notices.append(f"{restored.interrupted} 个工具调用在中断时没有结果,已标记为“会话中断”;模型会先检查状态。")
719
+ if restored.unanswered:
720
+ notices.append(f"最后 {len(restored.unanswered)} 条消息没有得到回复,已放回输入框,可以修改后重新发送。")
721
+ await self._session_start("resume")
722
+ self._team_context_due = self.teams is not None
723
+ notices.extend(self._raised)
724
+ self._raised = []
725
+ yield Resumed(restored, sum(1 for m in restored.history if m.role != "tool"), tuple(notices))
726
+ async with aclosing(self._agent.compact_restored()) as events:
727
+ async for event in events:
728
+ self._observe(event)
729
+ yield event
730
+ for notice in self._drain():
731
+ yield notice
732
+
733
+ # Context ----------------------------------------------------------------------------------
734
+
735
+ async def _load_context(self) -> list[str]:
736
+ root = self.context_root.resolve()
737
+ files, problems = await asyncio.to_thread(load_instructions, root, self.instructions_home)
738
+ sections = [instructions_section(files)]
739
+ self.project_instructions = sections[0]
740
+ if self.memory is not None:
741
+ sections.append(await asyncio.to_thread(self._memory_section))
742
+ self._agent.instructions = "\n\n".join(part for part in sections if part)
743
+ self._agent.git = await asyncio.to_thread(git_snapshot, root)
744
+
745
+ def shown(item):
746
+ notes = [f"引用 {'、'.join(display_path(root, path) for path in item.includes)}"] if item.includes else []
747
+ notes += ["已截断"] if item.truncated or item.overflow else []
748
+ return display_path(root, item.path) + "".join(f"({note})" for note in notes)
749
+
750
+ # Listed by /memory rather than announced at startup; only problems are announced.
751
+ self.instruction_files = tuple(shown(f) for f in files)
752
+ cut = [display_path(root, f.path) for f in files if f.truncated or f.overflow]
753
+ return problems + ([f"项目指令过长,已截断:{'、'.join(cut)};可用 /memory 查看已加载的文件。"] if cut else [])
754
+
755
+ def _memory_section(self) -> str:
756
+ """Indexes regenerated from the notes, then frozen for the session."""
757
+ try:
758
+ self.memory.refresh()
759
+ except OSError:
760
+ pass # A read-only home still gets the notes it can read.
761
+ # The limit bounds the whole injected section, the fixed explanation included.
762
+ limit = INDEX_TOKENS - estimate_text(memory_section("-"))
763
+ return memory_section(self.memory.index_section(limit))
764
+
765
+ async def start(self, *, wait_mcp: bool = True, on_mcp=None):
766
+ """Idempotent; only the first call reports context notices.
767
+
768
+ wait_mcp=False (the TUI) starts MCP discovery in the background: each server
769
+ registers its tools when ready and reports through on_mcp(status). Once that
770
+ has happened, later calls (every reply) no longer wait for MCP either.
771
+ """
772
+ notices, self._notices = self._notices, []
773
+ async with self._start_lock:
774
+ if self.context_root is not None and not self._context_loaded:
775
+ self._context_loaded = True
776
+ notices = notices + await self._load_context()
777
+ if not self._session_started:
778
+ await self._session_start("new")
779
+ if self.mcp_manager is not None:
780
+ if wait_mcp and not self._mcp_background:
781
+ notices.extend(await self.mcp_manager.start(self.registry, problems_only=True))
782
+ elif not self._mcp_background:
783
+ self._mcp_background = True
784
+ notices.extend(self.mcp_manager.notices) # Configuration problems are known now.
785
+ self.mcp_manager.begin(self.registry, on_mcp)
786
+ return tuple(notices)
787
+
788
+ def worktree_manager(self):
789
+ """The git repository of the project; WorktreeError outside one."""
790
+ if self._worktrees is None:
791
+ from .worktree import WorktreeManager
792
+
793
+ root = self.registry.permissions.root if self.registry is not None else (self.context_root or Path.cwd())
794
+ self._worktrees = WorktreeManager.discover(
795
+ Path(root), links=self.worktree_links, stale_hours=self.worktree_stale_hours
796
+ )
797
+ return self._worktrees
798
+
799
+ def start_worktree_cleanup(self, on_report) -> bool:
800
+ """In the background; nothing outside a repository or when turned off."""
801
+ if self._worktree_cleanup is not None or self.worktree_stale_hours <= 0:
802
+ return False
803
+
804
+ async def run() -> None:
805
+ from .worktree import WorktreeError
806
+
807
+ try:
808
+ manager = await asyncio.to_thread(self.worktree_manager)
809
+ except WorktreeError:
810
+ return
811
+ await manager.keep_clean(on_report)
812
+
813
+ self._worktree_cleanup = asyncio.ensure_future(run())
814
+ return True
815
+
816
+ async def close(self):
817
+ try:
818
+ if self._worktree_cleanup is not None:
819
+ self._worktree_cleanup.cancel()
820
+ await asyncio.gather(self._worktree_cleanup, return_exceptions=True)
821
+ if self.teams is not None:
822
+ await self.teams.close()
823
+ await self.tasks.cancel_all()
824
+ if self.hooks is not None:
825
+ try:
826
+ # session_end's synchronous hooks get 10 seconds, background ones 3 more.
827
+ await asyncio.wait_for(self._session_end("exit"), 10)
828
+ except TimeoutError:
829
+ pass
830
+ await self.hooks.drain(3)
831
+ if self.extractor is not None:
832
+ await self.extractor.close()
833
+ if self.mcp_manager is not None:
834
+ await self.mcp_manager.close()
835
+ finally:
836
+ if self._transcript is not None:
837
+ self._transcript.close()
838
+ await self.provider.close()
839
+ if self.memory_provider is not None and self.memory_provider is not self.provider:
840
+ await self.memory_provider.close()
841
+
842
+ async def reply(
843
+ self, prompt: str, *, command: str | None = None, fork: ForkRequest | None = None, synthetic: str | None = None
844
+ ):
845
+ try:
846
+ async with aclosing(self._reply(prompt, command, fork, synthetic)) as stream:
847
+ async for event in stream:
848
+ yield event
849
+ finally:
850
+ if self._agent.notifications: # Arrived after the last iteration boundary.
851
+ self._tasks_ready()
852
+
853
+ async def _team_context(self, prompt: str, command: str | None) -> tuple[str, str | None]:
854
+ """The lead's next message after /clear or /resume carries the team it still leads; shown as typed."""
855
+ self._team_context_due = False
856
+ try:
857
+ context = await asyncio.to_thread(self.teams.context)
858
+ except Exception: # A broken team record must not block the message.
859
+ return prompt, command
860
+ if not context:
861
+ return prompt, command
862
+ return f"{context}\n\n{prompt}", command or prompt
863
+
864
+ async def _reply(self, prompt: str, command: str | None, fork: ForkRequest | None, synthetic: str | None):
865
+ await self.start()
866
+ command = command or (f"/{fork.skill.name}" if fork else None)
867
+ if self.hooks is not None:
868
+ if self._busy:
869
+ raise ProviderError("已有请求正在运行,请等待或取消。")
870
+ self.hooks.begin_turn()
871
+ # With every request of this turn; a fork's sub-agent never sees it.
872
+ self._agent.turn_notes = await self._fire("turn_start", {"prompt": command or prompt})
873
+ for notice in self._drain():
874
+ yield notice
875
+ if self._team_context_due and not fork and synthetic is None:
876
+ prompt, command = await self._team_context(prompt, command)
877
+ run = self._fork(fork, command) if fork else self._agent.run(prompt, command=command, synthetic=synthetic)
878
+ stopped = None
879
+ try:
880
+ async with aclosing(run) as stream:
881
+ async for event in stream:
882
+ if isinstance(event, RunStopped):
883
+ stopped = event
884
+ self._observe(event)
885
+ yield event
886
+ for notice in self._drain():
887
+ yield notice
888
+ except ProviderError as exc:
889
+ self._agent.turn_notes = ()
890
+ await self._turn_end("error", str(exc))
891
+ raise
892
+ except BaseException: # Cancelled, or closed by the consumer.
893
+ self._agent.turn_notes = ()
894
+ self._turn_end_later("cancelled")
895
+ raise
896
+ self._agent.turn_notes = ()
897
+ if self.hooks is not None:
898
+ await self._turn_end(stopped.reason if stopped else "completed", stopped.notice if stopped else "")
899
+ for notice in self._drain():
900
+ yield notice
901
+
902
+ # Fork skills ------------------------------------------------------------------------
903
+
904
+ def _is_current(self, name: str) -> bool:
905
+ return self.provider_info is not None and self.provider_info[0] == name
906
+
907
+ def _fork_provider_config(self, name: str):
908
+ return next((config for config in self.provider_configs if config.name == name), None)
909
+
910
+ def _fork_history(self, context: str) -> list[Message]:
911
+ """None, the last 3 turns (from a real user message, calls kept with their results), or all."""
912
+ history = deepcopy(self._agent.history)
913
+ if context == "full":
914
+ return history
915
+ if context == "recent":
916
+ starts = [i for i, m in enumerate(history) if m.role == "user" and m.synthetic is None]
917
+ return history[starts[-3] :] if starts else []
918
+ return []
919
+
920
+ def _fork_registry(self, skill: SkillDef, task) -> ToolRegistry | None:
921
+ """The main tools and its own, or only those it lists; never load_skill or another skill tool.
922
+
923
+ Its own file tools (a copy of the main reads when it sees some history) and permission view.
924
+ """
925
+ if self.registry is None:
926
+ return None
927
+ policy = self.subagents.policy(None, task)
928
+ root = policy.root
929
+ own = [SkillTool(root, skill, spec) for spec in skill.tools]
930
+ listed = set(skill.allowed_tools) - GLOBAL_BAN if skill.allowed_tools else None
931
+ state = self.registry.file_state()
932
+ state = state.copy() if state is not None and skill.fork_context != "none" else None
933
+ return self.registry.for_agent(
934
+ agent="fork",
935
+ agent_type=task.agent_type,
936
+ permissions=policy,
937
+ state=state,
938
+ keep=lambda name: name not in GLOBAL_BAN and (listed is None or name in listed),
939
+ extra=[tool for tool in own if listed is None or tool.name in listed],
940
+ )
941
+
942
+ def _fork_provider(self, skill: SkillDef):
943
+ """(provider, budget, owned); owned providers are closed after the run."""
944
+ if not skill.model or self._is_current(skill.model):
945
+ return self.provider, self._agent.budget, False
946
+ config = self._fork_provider_config(skill.model)
947
+ if config is None:
948
+ raise ProviderError(f"Skill {skill.name} 未运行:没有名为 {skill.model} 的提供商。")
949
+ factory = self.provider_factory
950
+ if factory is None:
951
+ from .providers import create_provider as factory
952
+ try:
953
+ return factory(config), config.budget(), True
954
+ except Exception as exc:
955
+ raise ProviderError(f"Skill {skill.name} 未运行:无法创建提供商 {skill.model}:{exc}") from None
956
+
957
+ def _fork_result(self, skill: SkillDef, command: str, text: str) -> None:
958
+ """A user message (the command) and the assistant's answer, as one exchange."""
959
+ note = f"{command}\n\n(以上命令在独立上下文中运行了 Skill {skill.name},结果见下一条回复。)"
960
+ self._agent._append(Message("user", (ContentBlock("text", note),), command=command))
961
+ self._agent._append(Message("assistant", (ContentBlock("text", text),)))
962
+
963
+ async def _fork(self, fork: ForkRequest, command: str):
964
+ """Run the SOP in a sub-agent with its own history; only the result joins this one."""
965
+ main, skill = self._agent, fork.skill
966
+ if main.busy:
967
+ raise ProviderError("已有请求正在运行,请等待或取消。")
968
+ provider, budget, owned = self._fork_provider(skill)
969
+ tasks = self.subagents.tasks
970
+ task = tasks.new(
971
+ origin="skill", agent="fork", agent_type=f"skill:{skill.name}", description=command, prompt=fork.prompt
972
+ )
973
+ child = AgentLoop(
974
+ provider,
975
+ "",
976
+ self._fork_registry(skill, task),
977
+ max_iterations=main.max_iterations,
978
+ unknown_limit=main.unknown_limit,
979
+ )
980
+ child.system, child.instructions, child.git = main.system, main.instructions, main.git # No catalog.
981
+ child.budget, child.auto_compact, child.session = budget, main.auto_compact, self.session
982
+ child.history = self._fork_history(skill.fork_context)
983
+ self.subagents.attach(task, child) # Usage to the task and this conversation; the task keeps a transcript.
984
+ main.busy = True
985
+ stopped, final, ended = None, None, False
986
+
987
+ def end(text: str) -> None:
988
+ nonlocal ended
989
+ self._fork_result(skill, command, text)
990
+ ended = True
991
+ if task.status == "running" and not (
992
+ stopped is not None and stopped.reason in {"completed", "max_tokens"} and final is not None
993
+ ):
994
+ task.status = "failed"
995
+ tasks.settle(task, text)
996
+
997
+ try:
998
+ try:
999
+ prompt = await self.subagents.start_hooks(task, fork.prompt)
1000
+ async with aclosing(child.run(prompt)) as events:
1001
+ async for event in events:
1002
+ if isinstance(event, RunStopped):
1003
+ stopped = event # The one RunStopped comes after the exchange is added.
1004
+ continue
1005
+ if isinstance(event, Completed):
1006
+ final = event.message.text
1007
+ if isinstance(event, UsageUpdate):
1008
+ event = replace(event, total=main.total_usage) # The session total, as before.
1009
+ elif isinstance(event, ToolProgress) and event.phase == "running":
1010
+ task.tool_calls += 1
1011
+ yield event
1012
+ except asyncio.CancelledError:
1013
+ task.status = "cancelled"
1014
+ end(f"[Skill {skill.name} 未完成:已取消]")
1015
+ yield RunStopped(
1016
+ "cancelled",
1017
+ f"已取消 Skill {skill.name};主对话可以继续。",
1018
+ stopped.iterations if stopped else 0,
1019
+ stopped.results if stopped else (),
1020
+ )
1021
+ raise
1022
+ except ProviderError as exc:
1023
+ end(f"[Skill {skill.name} 未完成:{exc}]")
1024
+ raise
1025
+ if stopped is not None and stopped.reason in {"completed", "max_tokens"} and final is not None:
1026
+ end(final or "(Skill 没有输出正文。)")
1027
+ else:
1028
+ end(f"[Skill {skill.name} 未完成:{stopped.notice if stopped is not None else '没有完整回复。'}]")
1029
+ if stopped is not None:
1030
+ yield stopped
1031
+ finally:
1032
+ if not ended: # Closed from outside, e.g. the consumer was cancelled between events.
1033
+ task.status = "cancelled"
1034
+ end(f"[Skill {skill.name} 未完成:已取消]")
1035
+ main.busy = False
1036
+ if owned:
1037
+ await provider.close()
1038
+
1039
+ async def ask(self, prompt: str) -> str:
1040
+ async with aclosing(self.reply(prompt)) as stream:
1041
+ async for event in stream:
1042
+ if isinstance(event, Completed):
1043
+ return event.message.text
1044
+ if isinstance(event, RunStopped) and event.reason in {"unknown_tools", "max_iterations", "max_tokens"}:
1045
+ return event.notice
1046
+ raise ProviderError("没有完整回复。")