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,685 @@
1
+ """Build, run and report sub-agents; the agent tool, Skill fork and the hook agent action share it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from contextlib import aclosing
8
+ from copy import deepcopy
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from ..agent import AgentLoop, add_usage
14
+ from ..messages import Completed, Message, ProviderError, RunStopped, ToolProgress, ToolResult
15
+ from ..permissions import ApprovalRequest, stricter
16
+ from ..prompts import git_snapshot, hook_notification
17
+ from ..skills import ActiveSkills
18
+ from ..tool_budget import bound_result
19
+ from . import FORK, AgentCatalog, AgentDef
20
+ from .tasks import STATUS_LABELS, Task, TaskManager
21
+
22
+ if TYPE_CHECKING:
23
+ from ..conversation import Conversation
24
+ from ..worktree import Created, WorktreeManager
25
+
26
+ AGENT_TOOL = "agent"
27
+ # Never for a sub-agent. Removed from a definition's tools; a fork keeps them offered (cache) but refused.
28
+ # The team tools too (tools/team.py TEAM_TOOLS); a member gets its own versions as extra tools.
29
+ GLOBAL_BAN = frozenset(
30
+ {
31
+ AGENT_TOOL,
32
+ "load_skill",
33
+ "memory",
34
+ "exit_plan_mode",
35
+ "task_output",
36
+ "task_stop",
37
+ "team_create",
38
+ "team_spawn",
39
+ "team_merge",
40
+ "team_delete",
41
+ "team_task_create",
42
+ "team_task_get",
43
+ "team_task_list",
44
+ "team_task_update",
45
+ "send_message",
46
+ }
47
+ )
48
+ # What an isolated sub-agent can have (create_default_registry, rooted in its worktree).
49
+ WORKSPACE_TOOLS = frozenset(
50
+ {"read_file", "write_file", "edit_file", "execute_command", "glob", "grep", "exit_plan_mode"}
51
+ )
52
+ AUTO_BACKGROUND = 120.0
53
+ MAX_BACKGROUND = 4
54
+ FORK_PLACEHOLDER = "已在后台分出 Fork 处理。" # The same for every call, so sibling forks share a prefix.
55
+ FORK_DIRECTIVE = """<fork-directive>
56
+ 你是从上面的对话中分出的 Fork 子 Agent,在后台独立完成下面的任务,你的回复不会直接给用户看。
57
+ - 不要再创建子 Agent(agent 工具对你不可用),不要调用 memory、load_skill;
58
+ - 不要向用户提问,遇到需要决定的事在报告中说明;
59
+ - 只做这项任务,不要超出范围;
60
+ - 完成后给出最终报告,以“范围:”开头,依次写:范围、结果、涉及的关键文件、未完成的部分。
61
+ </fork-directive>
62
+
63
+ 任务:"""
64
+
65
+
66
+ def effective_mode(agent: AgentDef | None, session_mode: str) -> str:
67
+ """Plan always wins; a project definition never loosens the session; user and built-in ones may."""
68
+ if session_mode == "plan" or agent is None or agent.permission_mode is None:
69
+ return session_mode
70
+ if agent.source == "project":
71
+ return stricter(agent.permission_mode, session_mode)
72
+ return agent.permission_mode
73
+
74
+
75
+ def _now() -> str:
76
+ return datetime.now().astimezone().isoformat(timespec="seconds")
77
+
78
+
79
+ class SubagentLog:
80
+ """subagents/<task id>.jsonl, the same message records as the main transcript."""
81
+
82
+ def __init__(self, path: Path, header: dict):
83
+ self.path = path
84
+ self.failed = False
85
+ self._write(header)
86
+
87
+ def _write(self, record: dict) -> None:
88
+ if self.failed:
89
+ return
90
+ try:
91
+ self.path.parent.mkdir(parents=True, exist_ok=True)
92
+ with self.path.open("a", encoding="utf-8", newline="\n") as stream:
93
+ stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
94
+ except (OSError, TypeError, ValueError):
95
+ self.failed = True
96
+
97
+ def append(self, record: dict) -> None:
98
+ self._write(record)
99
+
100
+
101
+ def describe_call(call, root: Path | None = None) -> str:
102
+ """The latest tool call of a sub-agent, for its tool row; paths relative to its root."""
103
+ try:
104
+ args = json.loads(call.arguments)
105
+ except (ValueError, TypeError):
106
+ args = {}
107
+ value, key = "", ""
108
+ if isinstance(args, dict):
109
+ keys = ("pattern", "path") if call.name in {"glob", "grep"} else ("path", "pattern", "command", "name")
110
+ key = next((key for key in keys if args.get(key)), "")
111
+ value = str(args[key]) if key else ""
112
+ if key == "path":
113
+ try: # Paths inside the project read as relative, so the file name survives the width limit.
114
+ value = str(Path(value).relative_to(root or Path.cwd()))
115
+ except ValueError:
116
+ pass
117
+ value = " ".join(value.split())
118
+ if len(value) > 60:
119
+ value = "…" + value[-59:] if key == "path" else value[:59] + "…"
120
+ return f"{call.name} {value}".strip()
121
+
122
+
123
+ def stats(task: Task) -> str:
124
+ return f"工具调用 {task.tool_calls} 次,Token {task.tokens},用时 {task.elapsed:.0f} 秒"
125
+
126
+
127
+ PLAN_NO_WORKTREE = "(计划模式下未创建工作树:子 Agent 在主目录只读运行。)"
128
+
129
+
130
+ def worktree_context(manager: WorktreeManager, created: Created, root: Path) -> str:
131
+ """Where the sub-agent is and what that means, before its first message."""
132
+ meta = created.meta
133
+ lines = [
134
+ "你在独立的 git 工作树中工作,与主目录隔离:",
135
+ f"- 你的工作目录:{root}",
136
+ f"- 主项目目录:{manager.project}(不要修改其中的文件,写入会被拒绝)",
137
+ f"- 分支 {meta.branch},基于提交 {meta.base[:12]}",
138
+ "- 父 Agent 给出的主项目中的绝对路径,换成你的工作目录中的同一路径再读写",
139
+ "- 完成时有改动会保留(提交与否都可以),没有改动会自动删除;你不需要删除工作树",
140
+ ]
141
+ if created.dirty:
142
+ lines.append("- 主目录有未提交的改动,它们不在这个工作树中")
143
+ return "<worktree-context>\n" + "\n".join(lines) + "\n</worktree-context>"
144
+
145
+
146
+ def worktree_fields(task: Task, *, stop: bool) -> dict:
147
+ worktree = task.worktree or {}
148
+ fields = {"worktree": worktree.get("relative", "")}
149
+ if stop:
150
+ fields["worktree_kept"] = "" if not worktree else ("true" if worktree.get("outcome") == "已保留" else "false")
151
+ return fields
152
+
153
+
154
+ async def noted(note: str, run) -> str:
155
+ """The plan-mode note first."""
156
+ text = await run
157
+ return f"{note}\n\n{text}" if note else text
158
+
159
+
160
+ def unfinished(reason: str, loop: AgentLoop | None = None) -> str:
161
+ """The reason, then the last reply the sub-agent wrote, if any."""
162
+ text = f"[子 Agent 未完成:{reason.rstrip('。')}]"
163
+ last = (
164
+ next((m.text for m in reversed(loop.history) if m.role == "assistant" and m.text.strip()), "")
165
+ if loop is not None
166
+ else ""
167
+ )
168
+ return text + (f"\n\n{last}" if last else "")
169
+
170
+
171
+ class SubAgents:
172
+ def __init__(
173
+ self,
174
+ conversation: Conversation,
175
+ catalog: AgentCatalog | None = None,
176
+ *,
177
+ auto_background: float = AUTO_BACKGROUND,
178
+ max_background: int = MAX_BACKGROUND,
179
+ ):
180
+ self.conversation = conversation
181
+ self.catalog = catalog if catalog is not None else AgentCatalog()
182
+ self.auto_background = auto_background
183
+ self.tasks = TaskManager(max_background=max_background)
184
+ self.tasks.on_finish = self._finished
185
+ self._logs: dict[str, SubagentLog] = {}
186
+
187
+ @property
188
+ def main(self) -> AgentLoop:
189
+ return self.conversation._agent
190
+
191
+ # Building ------------------------------------------------------------------------------
192
+
193
+ def _provider(self, model: str | None, what: str):
194
+ """(provider, budget, owned); owned providers are closed when the sub-agent ends."""
195
+ conversation = self.conversation
196
+ if not model or conversation._is_current(model):
197
+ return conversation.provider, self.main.budget, False
198
+ config = conversation._fork_provider_config(model)
199
+ if config is None:
200
+ names = "、".join(c.name for c in conversation.provider_configs) or "无"
201
+ raise ProviderError(f"{what}未运行:没有名为 {model} 的提供商(可用:{names})。")
202
+ factory = conversation.provider_factory
203
+ if factory is None:
204
+ from ..providers import create_provider as factory
205
+ try:
206
+ return factory(config), config.budget(), True
207
+ except Exception as exc:
208
+ raise ProviderError(f"{what}未运行:无法创建提供商 {model}:{exc}") from None
209
+
210
+ def _check_provider(self, model: str | None, what: str) -> None:
211
+ """An isolated sub-agent builds its loop inside the task; a wrong model is still a tool error."""
212
+ conversation = self.conversation
213
+ if model and not conversation._is_current(model) and conversation._fork_provider_config(model) is None:
214
+ names = "、".join(c.name for c in conversation.provider_configs) or "无"
215
+ raise ProviderError(f"{what}未运行:没有名为 {model} 的提供商(可用:{names})。")
216
+
217
+ def policy(self, agent: AgentDef | None, task: Task, root: Path | None = None):
218
+ parent = self.main.registry.permissions
219
+ label = f"子 Agent {task.label}"
220
+ policy = parent.view(
221
+ effective_mode(agent, parent.mode),
222
+ interactive=parent.interactive and not task.background,
223
+ label=label,
224
+ root=root,
225
+ )
226
+ task.policy = policy
227
+ return policy
228
+
229
+ def attach(self, task: Task, loop: AgentLoop) -> None:
230
+ """Usage goes to the task and the session; messages to the sub-agent's own transcript."""
231
+ main = self.main
232
+
233
+ def counted(usage) -> None:
234
+ task.usage = add_usage(task.usage, usage)
235
+ main.total_usage = add_usage(main.total_usage, usage)
236
+ self.tasks.changed(task)
237
+
238
+ loop.on_usage = counted
239
+ conversation = self.conversation
240
+ session = conversation.session
241
+ if conversation.persist and session is not None:
242
+ task.transcript = session.directory / "subagents" / f"{task.id}.jsonl"
243
+ log = SubagentLog(
244
+ task.transcript,
245
+ {
246
+ "type": "subagent",
247
+ "v": 1,
248
+ "id": task.id,
249
+ "agent": task.agent,
250
+ "agent_type": task.agent_type,
251
+ "description": task.description,
252
+ "created": _now(),
253
+ },
254
+ )
255
+ self._logs[task.id] = log
256
+ loop.on_commit = log.append
257
+ conversation._record(
258
+ {
259
+ "type": "task",
260
+ "time": _now(),
261
+ "id": task.id,
262
+ "event": "start",
263
+ "origin": task.origin,
264
+ "agent": task.agent,
265
+ "agent_type": task.agent_type,
266
+ "description": task.description,
267
+ "background": task.background,
268
+ "transcript": str(task.transcript) if task.transcript else None,
269
+ }
270
+ )
271
+
272
+ def definition_loop(
273
+ self,
274
+ agent: AgentDef,
275
+ task: Task,
276
+ *,
277
+ nested: bool = False,
278
+ root: Path | None = None,
279
+ git: dict | None = None,
280
+ worktree: str = "",
281
+ ) -> tuple[AgentLoop, bool]:
282
+ """A blank conversation with the role's prompt; returns the loop and whether it owns its provider.
283
+
284
+ With root (a worktree), fresh workspace tools and a policy view rooted there; no MCP or
285
+ Skill script tools, which would act on the main directory.
286
+ """
287
+ main = self.main
288
+ provider, budget, owned = self._provider(agent.model, f"子 Agent {agent.name} ")
289
+ policy = self.policy(agent, task, root)
290
+ keep = lambda name: name not in GLOBAL_BAN and agent.allows(name) # noqa: E731
291
+ if root is None:
292
+ registry = main.registry.for_agent(agent="subagent", agent_type=agent.name, permissions=policy, keep=keep)
293
+ else:
294
+ from ..tools import create_default_registry
295
+
296
+ shell = getattr(main.registry.get("execute_command"), "shell", None)
297
+ fresh = create_default_registry(root, permissions=policy, shell=shell if shell is not None else "auto")
298
+ registry = fresh.for_agent(agent="subagent", agent_type=agent.name, permissions=policy, keep=keep)
299
+ registry.session, registry.hooks, registry.worktree = main.registry.session, main.registry.hooks, worktree
300
+ registry.nested = nested
301
+ turns = min(agent.max_turns or main.max_iterations, main.max_iterations)
302
+ loop = AgentLoop(provider, agent.prompt, registry, max_iterations=turns, unknown_limit=main.unknown_limit)
303
+ loop.instructions = self.conversation.project_instructions
304
+ loop.git = git if git is not None else main.git
305
+ loop.budget, loop.auto_compact, loop.session = budget, main.auto_compact, main.session
306
+ self.attach(task, loop)
307
+ return loop, owned
308
+
309
+ def fork_loop(self, task: Task) -> AgentLoop:
310
+ """The parent's prompt, tools and history, up to the assistant message making this call."""
311
+ main = self.main
312
+ policy = self.policy(None, task)
313
+ state = main.registry.file_state()
314
+ registry = main.registry.for_agent(
315
+ agent="fork",
316
+ agent_type=FORK,
317
+ permissions=policy,
318
+ state=state.copy() if state is not None else None,
319
+ forbidden=GLOBAL_BAN & set(main.registry.names()),
320
+ )
321
+ loop = AgentLoop(
322
+ main.provider, "", registry, max_iterations=main.max_iterations, unknown_limit=main.unknown_limit
323
+ )
324
+ loop.system, loop.instructions, loop.git = main.system, main.instructions, main.git
325
+ loop.skill_catalog, loop.agent_catalog, loop.hook_context = (
326
+ main.skill_catalog,
327
+ main.agent_catalog,
328
+ main.hook_context,
329
+ )
330
+ loop.active_skills = ActiveSkills()
331
+ for item in main.active_skills.items():
332
+ loop.active_skills.activate(item.skill, item.arguments, item.text)
333
+ loop._announced = main._announced
334
+ loop.budget, loop.auto_compact, loop.session = main.budget, main.auto_compact, main.session
335
+ loop.history = fork_history(main.history)
336
+ self.attach(task, loop)
337
+ return loop
338
+
339
+ # Running -------------------------------------------------------------------------------
340
+
341
+ async def start_hooks(self, task: Task, prompt: str) -> str:
342
+ """subagent_start; its prompt and feedback go before the sub-agent's first message."""
343
+ hooks = self.conversation.hooks
344
+ if hooks is None or not hooks.wants("subagent_start"):
345
+ return prompt
346
+ from ..hooks import HookEvent
347
+
348
+ outcome = await hooks.fire(
349
+ HookEvent(
350
+ "subagent_start",
351
+ fields={
352
+ "task_id": task.id,
353
+ "agent_type": task.agent_type,
354
+ "description": task.description,
355
+ "background": "true" if task.background else "false",
356
+ **worktree_fields(task, stop=False),
357
+ },
358
+ )
359
+ )
360
+ note = hook_notification(outcome.notes)
361
+ return f"{note.render()}\n\n{prompt}" if note else prompt
362
+
363
+ async def drive(self, task: Task, loop: AgentLoop, prompt: str, *, owned: bool = False) -> str:
364
+ """Run to completion (no user input); approvals of a foreground sub-agent go to the parent's queue."""
365
+ parent = self.main.registry.permissions
366
+ final, stopped = None, None
367
+ try:
368
+ prompt = await self.start_hooks(task, prompt)
369
+ async with aclosing(loop.run(prompt)) as events:
370
+ async for event in events:
371
+ if isinstance(event, ApprovalRequest):
372
+ parent.requests.put_nowait(event) # The main tool batch shows it.
373
+ elif isinstance(event, ToolProgress) and event.phase == "running":
374
+ task.tool_calls += 1
375
+ task.last_tool = describe_call(event.call, task.root)
376
+ self.tasks.changed(task)
377
+ elif isinstance(event, Completed):
378
+ final = event.message.text
379
+ elif isinstance(event, RunStopped):
380
+ stopped = event
381
+ except asyncio.CancelledError:
382
+ task.status = "cancelled"
383
+ task.result = unfinished("已取消", loop)
384
+ raise
385
+ except ProviderError as exc:
386
+ task.status = "failed"
387
+ return unfinished(str(exc), loop)
388
+ finally:
389
+ if owned:
390
+ await loop.provider.close()
391
+ if stopped is not None and stopped.reason in {"completed", "max_tokens"} and final is not None:
392
+ return (
393
+ final.strip() or "(子 Agent 没有输出正文。)"
394
+ ) + f"\n\n(子 Agent {task.agent_type}:{stats(task)})"
395
+ task.status = "failed"
396
+ if stopped is not None and stopped.reason == "max_iterations":
397
+ return unfinished(f"达到最大轮数 {loop.max_iterations}", loop)
398
+ return unfinished(stopped.notice if stopped is not None else "没有完整回复", loop)
399
+
400
+ async def run_isolated(
401
+ self, task: Task, agent: AgentDef, prompt: str, manager: WorktreeManager, *, nested: bool = False
402
+ ) -> str:
403
+ """Create the worktree, run in it, then delete or keep it; the report ends the result."""
404
+ from ..worktree import WorktreeError
405
+ from ..worktree.manager import WORKTREES
406
+
407
+ task.last_tool = "创建工作树…"
408
+ self.tasks.changed(task)
409
+ try:
410
+ created = await manager.ephemeral(task.id)
411
+ except WorktreeError as exc:
412
+ task.status = "failed"
413
+ return unfinished(f"无法创建工作树:{exc}")
414
+ meta = created.meta
415
+ root = manager.root_in(meta)
416
+ task.root = root
417
+ task.worktree = {
418
+ "name": meta.name,
419
+ "path": str(meta.path),
420
+ "branch": meta.branch,
421
+ "base": meta.base,
422
+ "relative": (WORKTREES / meta.flat).as_posix(),
423
+ "outcome": "使用中",
424
+ }
425
+ task.last_tool = ""
426
+ self.tasks.changed(task)
427
+ text = None
428
+ try:
429
+ try:
430
+ snapshot = await asyncio.to_thread(git_snapshot, root)
431
+ loop, owned = self.definition_loop(
432
+ agent, task, nested=nested, root=root, git=snapshot, worktree=task.worktree["relative"]
433
+ )
434
+ except ProviderError as exc:
435
+ task.status = "failed"
436
+ text = unfinished(str(exc))
437
+ else:
438
+ text = await self.drive(
439
+ task, loop, worktree_context(manager, created, root) + "\n\n" + prompt, owned=owned
440
+ )
441
+ finally:
442
+ try:
443
+ finished = await asyncio.shield(manager.finish(meta))
444
+ report = manager.report(meta, finished)
445
+ task.worktree["outcome"] = "已保留" if finished.kept else ("删除未完成" if finished.error else "已删除")
446
+ except asyncio.CancelledError:
447
+ report = f"[工作树] 结束处理被中断;{meta.name} 留在磁盘上,用 /worktree 查看。"
448
+ task.worktree["outcome"] = "已保留"
449
+ except Exception as exc: # A broken repository: keep everything and say so.
450
+ report = f"[工作树] 结束处理失败({exc});{meta.name} 留在磁盘上,用 /worktree 查看。"
451
+ task.worktree["outcome"] = "已保留"
452
+ if created.warnings:
453
+ report = "[工作树] 初始化提示:" + ";".join(created.warnings) + "\n" + report
454
+ task.worktree["report"] = report
455
+ if text is None: # Cancelled: TaskManager keeps task.result.
456
+ task.result = (task.result or "[子 Agent 未完成:已取消]") + "\n\n" + report
457
+ self.tasks.changed(task)
458
+ return text + "\n\n" + report
459
+
460
+ def _finished(self, task: Task) -> None:
461
+ """subagent_stop, the end record, and the notification of a background task."""
462
+ conversation = self.conversation
463
+ conversation._record({"type": "task", "time": _now(), "id": task.id, "event": "end", "status": task.status})
464
+ hooks = conversation.hooks
465
+ if hooks is not None and hooks.wants("subagent_stop"):
466
+ from ..hooks import HookEvent
467
+
468
+ hooks.spawn(
469
+ hooks.fire(
470
+ HookEvent(
471
+ "subagent_stop",
472
+ fields={
473
+ "task_id": task.id,
474
+ "agent_type": task.agent_type,
475
+ "status": task.status,
476
+ "background": "true" if task.background else "false",
477
+ **worktree_fields(task, stop=True),
478
+ },
479
+ )
480
+ )
481
+ )
482
+ if task.origin != "agent" or not task.background or task.notified:
483
+ return
484
+ task.notified = True
485
+ self.main.notifications.append(self.notification(task))
486
+ conversation._tasks_ready()
487
+
488
+ def notification(self, task: Task) -> str:
489
+ result, _ = bound_result(AGENT_TOOL, f"task-{task.id}", task.result or "", {}, self.conversation.session)
490
+ usage = task.usage
491
+ return (
492
+ f"<task-notification>\n任务:{task.id}\n类型:{task.agent_type}\n标签:{task.description}\n"
493
+ f"状态:{STATUS_LABELS[task.status]}\n用量:输入 {usage.input_tokens} Token(缓存命中 "
494
+ f"{usage.cached_input_tokens}),输出 {usage.output_tokens} Token,用时 {task.elapsed:.0f} 秒\n"
495
+ f"结果:\n{result}\n</task-notification>\n以上是后台任务的结果;根据需要继续处理,或向用户报告。"
496
+ )
497
+
498
+ # Entry points --------------------------------------------------------------------------
499
+
500
+ async def call(
501
+ self,
502
+ description: str,
503
+ prompt: str,
504
+ agent_type: str = "",
505
+ background: bool = False,
506
+ call_id: str = "",
507
+ isolation: str = "none",
508
+ ) -> tuple[str, dict]:
509
+ """The agent tool; Isolation."""
510
+ from ..tools.base import ToolFailure
511
+
512
+ kind = agent_type.strip() or FORK
513
+ if kind == FORK and isolation == "worktree":
514
+ raise ToolFailure(
515
+ "isolation_unsupported",
516
+ "Fork 继承当前对话,不能在工作树中运行;需要隔离时请指定 agent_type(例如 general)。",
517
+ )
518
+ if kind == FORK:
519
+ if self.tasks.full():
520
+ raise ToolFailure("too_many_tasks", self._full())
521
+ task = self.tasks.new(
522
+ origin="agent",
523
+ agent="fork",
524
+ agent_type=FORK,
525
+ description=description,
526
+ prompt=prompt,
527
+ call_id=call_id,
528
+ background=True,
529
+ )
530
+ loop = self.fork_loop(task)
531
+ self.tasks.launch(task, lambda: self.drive(task, loop, FORK_DIRECTIVE + prompt))
532
+ return (
533
+ f"已在后台启动 Fork:任务 {task.id}。完成后你会收到 <task-notification>;"
534
+ "在此之前可以继续其他工作,不要重复做同一件事。"
535
+ ), {"task": task.id, "background": True}
536
+ agent = self.catalog.get(kind)
537
+ if agent is None:
538
+ names = "、".join(self.catalog.names()) or "无"
539
+ raise ToolFailure(
540
+ "unknown_agent_type", f"没有名为 {kind} 的子 Agent 类型。可用:{names};省略 agent_type 即为 Fork。"
541
+ )
542
+ background = background or agent.background
543
+ if background and self.tasks.full():
544
+ raise ToolFailure("too_many_tasks", self._full())
545
+ isolate, note = self._isolation(agent, isolation)
546
+ manager = self._manager(ToolFailure) if isolate else None
547
+ task = self.tasks.new(
548
+ origin="agent",
549
+ agent="subagent",
550
+ agent_type=agent.name,
551
+ description=description,
552
+ prompt=prompt,
553
+ call_id=call_id,
554
+ background=background,
555
+ isolated=isolate,
556
+ )
557
+ loop = None
558
+ try:
559
+ if isolate:
560
+ self._check_provider(agent.model, f"子 Agent {agent.name} ")
561
+ else:
562
+ loop, owned = self.definition_loop(agent, task)
563
+ except ProviderError as exc:
564
+ del self.tasks.tasks[task.id]
565
+ raise ToolFailure("agent_unavailable", str(exc)) from None
566
+ if isolate:
567
+ job = self.tasks.launch(task, lambda: self.run_isolated(task, agent, prompt, manager))
568
+ else:
569
+ job = self.tasks.launch(task, lambda: noted(note, self.drive(task, loop, prompt, owned=owned)))
570
+ if background:
571
+ return (
572
+ f"已在后台启动子 Agent {agent.name}:任务 {task.id}。完成后你会收到 <task-notification>;"
573
+ "在此之前可以继续其他工作,不要重复做同一件事。"
574
+ ), {"task": task.id, "background": True}
575
+ detached = asyncio.ensure_future(task.detach.wait())
576
+ try:
577
+ await asyncio.wait(
578
+ {job, detached}, timeout=self.auto_background or None, return_when=asyncio.FIRST_COMPLETED
579
+ )
580
+ except asyncio.CancelledError:
581
+ job.cancel() # Cancelling the turn cancels its foreground sub-agents.
582
+ await asyncio.gather(job, return_exceptions=True)
583
+ return task.result or unfinished("已取消", loop), {"task": task.id, "cancelled": True}
584
+ finally:
585
+ detached.cancel()
586
+ if job.done():
587
+ details = {"task": task.id, "background": False}
588
+ if task.worktree:
589
+ details["worktree"] = task.worktree["outcome"]
590
+ return job.result(), details
591
+ task.to_background() # ctrl+b or the timer.
592
+ self.tasks.changed(task)
593
+ return (
594
+ f"已转入后台:任务 {task.id},子 Agent {agent.name} 继续运行,完成后你会收到 <task-notification>;"
595
+ "在此之前可以继续其他工作,不要重复做同一件事。"
596
+ ), {"task": task.id, "background": True, "detached": True}
597
+
598
+ def _isolation(self, agent: AgentDef, isolation: str) -> tuple[bool, str]:
599
+ """(run in a worktree, note for the result)."""
600
+ if isolation != "worktree" and agent.isolation != "worktree":
601
+ return False, ""
602
+ if effective_mode(agent, self.main.registry.permissions.mode) == "plan":
603
+ return False, PLAN_NO_WORKTREE
604
+ return True, ""
605
+
606
+ def _manager(self, failure):
607
+ from ..worktree import WorktreeError
608
+
609
+ try:
610
+ return self.conversation.worktree_manager()
611
+ except WorktreeError as exc:
612
+ raise failure("worktree_unavailable", str(exc)) from None
613
+
614
+ def _full(self) -> str:
615
+ return (
616
+ f"后台任务已达上限({self.tasks.max_background} 个);等待已有任务完成(task_output)"
617
+ "或停止其中一个(task_stop)后再启动。"
618
+ )
619
+
620
+ async def hook_agent(self, hook, event, fields):
621
+ """A background definition sub-agent for an agent action; its result is only logged."""
622
+ from ..hooks.actions import ActionResult, render, short
623
+
624
+ action = hook.action
625
+ if self.main.registry is None:
626
+ return ActionResult("skipped", "当前会话未启用工具")
627
+ agent = self.catalog.get(action.agent_type)
628
+ if agent is None:
629
+ return ActionResult("failed", f"没有名为 {action.agent_type} 的子 Agent 类型")
630
+ if self.tasks.full():
631
+ return ActionResult("failed", "后台任务已达上限")
632
+ prompt = render(action.prompt, fields)
633
+ isolate, note = self._isolation(agent, "none")
634
+ manager = None
635
+ if isolate:
636
+ from ..worktree import WorktreeError
637
+
638
+ try:
639
+ manager = self.conversation.worktree_manager()
640
+ except WorktreeError as exc:
641
+ return ActionResult("failed", str(exc))
642
+ task = self.tasks.new(
643
+ origin="hook",
644
+ agent="subagent",
645
+ agent_type=agent.name,
646
+ description=f"Hook {hook.id}",
647
+ prompt=prompt,
648
+ background=True,
649
+ isolated=isolate,
650
+ )
651
+ try:
652
+ if isolate:
653
+ self._check_provider(agent.model, f"子 Agent {agent.name} ")
654
+ else:
655
+ loop, owned = self.definition_loop(agent, task, nested=True)
656
+ except ProviderError as exc:
657
+ del self.tasks.tasks[task.id]
658
+ return ActionResult("failed", str(exc))
659
+ if isolate:
660
+ job = self.tasks.launch(task, lambda: self.run_isolated(task, agent, prompt, manager, nested=True))
661
+ else:
662
+ job = self.tasks.launch(task, lambda: noted(note, self.drive(task, loop, prompt, owned=owned)))
663
+ try:
664
+ text = await asyncio.wait_for(asyncio.shield(job), timeout=action.timeout)
665
+ except TimeoutError:
666
+ await self.tasks.stop(task.id)
667
+ return ActionResult("timeout", f"子 Agent {task.id} 超过 {action.timeout:g} 秒,已停止")
668
+ except asyncio.CancelledError:
669
+ await self.tasks.stop(task.id)
670
+ raise
671
+ self.conversation._hook_notice(f"Hook {hook.id} 的子 Agent(任务 {task.id}){STATUS_LABELS[task.status]}。")
672
+ if task.status != "completed":
673
+ return ActionResult("failed", short(text, 300))
674
+ return ActionResult("ok", detail=f"任务 {task.id}:" + short(text, 300))
675
+
676
+
677
+ def fork_history(history: list[Message]) -> list[Message]:
678
+ """The history up to the last assistant message, then the same placeholder for each of its calls."""
679
+ history = deepcopy(history)
680
+ index = next((i for i in range(len(history) - 1, -1, -1) if history[i].role == "assistant"), None)
681
+ if index is None:
682
+ return []
683
+ assistant = history[index]
684
+ results = tuple(ToolResult(call.id, call.name, FORK_PLACEHOLDER) for call in assistant.tool_calls)
685
+ return history[: index + 1] + ([Message("tool", (), tool_results=results)] if results else [])