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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/conversation.py
ADDED
|
@@ -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("没有完整回复。")
|