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
|
@@ -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 [])
|