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
mosscode/tools/base.py ADDED
@@ -0,0 +1,384 @@
1
+ """Protocol-independent tool contract and a single validation/execution boundary."""
2
+
3
+ import asyncio
4
+ import json
5
+ from abc import ABC, abstractmethod
6
+ from contextvars import ContextVar
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, ConfigDict, ValidationError
12
+
13
+ from ..messages import ToolCall, ToolDefinition, ToolResult
14
+ from ..permissions import Decision, PermissionPolicy
15
+ from ..security import SecurityError, project_path, resolve_path
16
+
17
+ # The call a tool is executing, for tools that need its id (the agent tool's row in the UI).
18
+ CURRENT_CALL: ContextVar[ToolCall | None] = ContextVar("mosscode_current_call", default=None)
19
+
20
+
21
+ class Params(BaseModel):
22
+ model_config = ConfigDict(strict=True, extra="forbid")
23
+
24
+
25
+ class ToolFailure(Exception):
26
+ def __init__(self, code: str, message: str, **details: Any):
27
+ super().__init__(message)
28
+ self.code = code
29
+ self.details = details
30
+
31
+
32
+ def bounded_output(output, details):
33
+ encoded = output.encode("utf-8")
34
+ if len(encoded) > 65536:
35
+ return (encoded[:65536].decode("utf-8", errors="ignore") + "\n(结果已截断)", {**details, "truncated": True})
36
+ return output, details
37
+
38
+
39
+ HOOK_NOTE_LIMIT = 8000 # Hook text added to one tool result.
40
+
41
+
42
+ def with_hook_notes(result: ToolResult, notes, modified_by: str | None) -> ToolResult:
43
+ """Hook notes and the reformatting notice go after the result, into history and the transcript."""
44
+ if not notes and not modified_by:
45
+ return result
46
+ blocks, room = [], HOOK_NOTE_LIMIT
47
+ for hook_id, note in notes:
48
+ if room <= 0:
49
+ break
50
+ text = note if len(note) <= room else note[:room] + "\n(Hook 内容超出 8000 个字符,已截断)"
51
+ room -= len(note)
52
+ blocks.append(f'<hook-notification hook="{hook_id}">\n{text}\n</hook-notification>')
53
+ details = result.details
54
+ if modified_by:
55
+ blocks.append(f"文件已被 Hook {modified_by} 修改(如格式化);再次编辑前请重新读取。")
56
+ details = {**details, "hook_modified": modified_by} # The tool row shows it; the output may be cut there.
57
+ return replace(result, output=result.output + "\n\n" + "\n\n".join(blocks), details=details)
58
+
59
+
60
+ def _stamp(path: Path | None):
61
+ try:
62
+ status = path.stat() if path is not None else None
63
+ except OSError:
64
+ return None
65
+ return (status.st_mtime_ns, status.st_size) if status is not None else None
66
+
67
+
68
+ def reject_json_constant(value):
69
+ raise ValueError("非有限数字不是 JSON 参数。")
70
+
71
+
72
+ class ExecutionCancelled(asyncio.CancelledError):
73
+ def __init__(self, result: ToolResult):
74
+ super().__init__()
75
+ self.result = result
76
+
77
+
78
+ class Tool(ABC):
79
+ name: str
80
+ description: str
81
+ params_model: type[Params]
82
+ prompt_summary = ""
83
+ prompt_guidelines: tuple[str, ...] = ()
84
+ timeout = 30.0
85
+ read_only = False
86
+ plan_only = False # Offered only in plan mode (exit_plan_mode).
87
+ plan_commands = False # Offered in plan mode; the policy admits only read-only commands.
88
+ plan_allowed = False # Offered and allowed in plan mode although not read-only (memory).
89
+
90
+ def validate_arguments(self, raw):
91
+ return self.params_model.model_validate(raw)
92
+
93
+ def __init__(self, root: Path):
94
+ self.root = root.resolve()
95
+
96
+ def path(self, value: str) -> Path:
97
+ try:
98
+ return project_path(self.root, value)
99
+ except SecurityError as exc:
100
+ raise ToolFailure(exc.code, str(exc)) from None
101
+
102
+ def read_path(self, value: str) -> Path:
103
+ """Read tools may leave the project; the permission policy asks first."""
104
+ try:
105
+ return resolve_path(self.root, value)
106
+ except SecurityError as exc:
107
+ raise ToolFailure(exc.code, str(exc)) from None
108
+
109
+ def display(self, path: Path) -> str:
110
+ return path.relative_to(self.root).as_posix() if path.is_relative_to(self.root) else str(path)
111
+
112
+ def definition(self) -> ToolDefinition:
113
+ guidelines = tuple(dict.fromkeys(rule.strip() for rule in self.prompt_guidelines if rule.strip()))
114
+ description = "\n".join((self.description, *(f"- {rule}" for rule in guidelines)))
115
+ return ToolDefinition(
116
+ self.name, description, self.params_model.model_json_schema(), self.prompt_summary, guidelines
117
+ )
118
+
119
+ @abstractmethod
120
+ async def execute(self, params: Params) -> tuple[str, dict[str, Any]]: ...
121
+
122
+
123
+ async def file_io(function, *args):
124
+ # A cancelled Python thread cannot roll back filesystem changes. Settle it
125
+ # before returning so the runtime can retain the actual outcome in history.
126
+ worker = asyncio.create_task(asyncio.to_thread(function, *args))
127
+ try:
128
+ return await asyncio.shield(worker)
129
+ except asyncio.CancelledError:
130
+ return await asyncio.shield(worker)
131
+
132
+
133
+ class ToolRegistry:
134
+ def __init__(self, tools=(), *, permissions: PermissionPolicy | None = None):
135
+ self._tools: dict[str, Tool] = {}
136
+ for tool in tools:
137
+ self.register(tool)
138
+ first = next(iter(self._tools.values()), None)
139
+ self.permissions = permissions or PermissionPolicy(first.root if first else Path.cwd())
140
+ # Set by the application; without it oversized results are cut to a preview, not stored.
141
+ self.session = None
142
+ self.hooks = None # The HookEngine, set by the application.
143
+ self.agent = "main" # "fork" or "subagent" in a sub-agent's view.
144
+ self.agent_type = "" # The definition name, "fork" or "skill:<name>".
145
+ self.forbidden: frozenset[str] = frozenset() # Offered (fork cache) but refused when called.
146
+ self.nested = False # Started by a hook's agent action; its tool events run no agent action.
147
+ self.worktree = "" # The worktree a sub-agent works in, relative to the repository.
148
+ # withheld(tool) -> True keeps a tool for sub-agents and members but neither offers nor runs it here.
149
+ self.withheld = None
150
+ if any(tool.root != self.permissions.root for tool in self._tools.values()):
151
+ raise ValueError("工具与权限策略必须使用同一个项目根目录。")
152
+
153
+ def use_session(self, session) -> None:
154
+ """Store oversized results in this run's directory and let read_file open them without approval."""
155
+ self.session = session
156
+ self.permissions.session_dir = session.directory if session is not None else None
157
+
158
+ def forget_reads(self) -> None:
159
+ """After compaction the model no longer holds file contents: edits need a fresh read."""
160
+ for tool in self._tools.values():
161
+ state = getattr(tool, "state", None)
162
+ if state is not None and hasattr(state, "clear"):
163
+ state.clear()
164
+
165
+ def register(self, tool: Tool) -> None:
166
+ if hasattr(self, "permissions") and tool.root != self.permissions.root:
167
+ raise ValueError("工具与权限策略必须使用同一个项目根目录。")
168
+ if tool.name in self._tools:
169
+ raise ValueError(f"工具名称重复:{tool.name}")
170
+ self._tools[tool.name] = tool
171
+
172
+ def get(self, name: str) -> Tool | None:
173
+ return self._tools.get(name)
174
+
175
+ def names(self) -> tuple[str, ...]:
176
+ return tuple(name for name, tool in self._tools.items() if not self._withheld(tool))
177
+
178
+ def _withheld(self, tool: Tool) -> bool:
179
+ return self.withheld is not None and self.withheld(tool)
180
+
181
+ def unregister(self, name: str) -> None:
182
+ self._tools.pop(name, None)
183
+
184
+ def for_agent(
185
+ self,
186
+ *,
187
+ agent: str,
188
+ agent_type: str,
189
+ permissions: PermissionPolicy,
190
+ state=None,
191
+ keep=None,
192
+ extra=(),
193
+ forbidden=(),
194
+ ) -> "ToolRegistry":
195
+ """A sub-agent's registry.
196
+
197
+ Tools holding a FileState are copied and share `state` (a new, empty one when None);
198
+ keep(name) filters the rest; extra tools are added; forbidden names stay offered but are refused.
199
+ """
200
+ from copy import copy
201
+
202
+ from .files import FileState
203
+
204
+ state = state if state is not None else FileState()
205
+ view = ToolRegistry(permissions=permissions)
206
+ view.session, view.hooks, view.agent, view.agent_type = self.session, self.hooks, agent, agent_type
207
+ view.forbidden = frozenset(forbidden)
208
+ for name, tool in self._tools.items():
209
+ if keep is not None and not keep(name):
210
+ continue
211
+ if getattr(tool, "state", None) is not None:
212
+ tool = copy(tool)
213
+ tool.state = state
214
+ view._tools[name] = tool
215
+ for tool in extra: # Replace same-named ones.
216
+ if tool.root != permissions.root:
217
+ raise ValueError("工具与权限策略必须使用同一个项目根目录。")
218
+ view._tools[tool.name] = tool
219
+ return view
220
+
221
+ def file_state(self):
222
+ """The FileState the file tools share, if any."""
223
+ return next((tool.state for tool in self._tools.values() if getattr(tool, "state", None) is not None), None)
224
+
225
+ def definitions(self, *, read_only: bool = False, plan: bool = False) -> tuple[ToolDefinition, ...]:
226
+ # Plan mode offers read-only tools, read-only commands and the tool that submits the plan.
227
+ def offered(tool):
228
+ if plan:
229
+ return tool.read_only or tool.plan_only or tool.plan_commands or tool.plan_allowed
230
+ return not tool.plan_only and (not read_only or tool.read_only)
231
+
232
+ return tuple(tool.definition() for tool in self._tools.values() if offered(tool) and not self._withheld(tool))
233
+
234
+ def is_read_only(self, name: str) -> bool:
235
+ tool = self.get(name)
236
+ return tool is not None and tool.read_only
237
+
238
+ def _bound(self, call: ToolCall, output: str, details: dict[str, Any]) -> tuple[str, dict[str, Any]]:
239
+ from ..tool_budget import EXEMPT, bound_result
240
+
241
+ if call.name in EXEMPT:
242
+ return bounded_output(output, details)
243
+ return bound_result(call.name, call.id, output, details, self.session)
244
+
245
+ async def _invoke(self, call: ToolCall, tool: Tool, params: Params) -> ToolResult:
246
+ CURRENT_CALL.set(call) # Runs in its own task: the value never leaks to the caller.
247
+ try:
248
+ output, details = await tool.execute(params)
249
+ output, details = self._bound(call, output, details)
250
+ return ToolResult(call.id, call.name, output, details=details)
251
+ except ToolFailure as exc:
252
+ output, details = self._bound(call, str(exc), exc.details)
253
+ return ToolResult(call.id, call.name, output, True, exc.code, details)
254
+ except FileNotFoundError:
255
+ return ToolResult(call.id, call.name, "文件或目录不存在。", True, "not_found")
256
+ except IsADirectoryError:
257
+ return ToolResult(call.id, call.name, "目标是目录,不能作为文本文件操作。", True, "is_directory")
258
+ except UnicodeError:
259
+ return ToolResult(call.id, call.name, "文件不是可读取的 UTF-8 文本。", True, "encoding_error")
260
+ except PermissionError:
261
+ return ToolResult(call.id, call.name, "系统拒绝访问目标。", True, "permission_denied")
262
+ except OSError:
263
+ return ToolResult(call.id, call.name, "文件系统或进程操作失败。", True, "os_error")
264
+ except Exception:
265
+ return ToolResult(call.id, call.name, "工具内部错误。", True, "internal_error")
266
+
267
+ async def execute(self, call: ToolCall, *, read_only: bool = False) -> ToolResult:
268
+ tool = self.get(call.name)
269
+ if tool is None:
270
+ return ToolResult(call.id, call.name, "未知工具。", True, "unknown_tool")
271
+ if self._withheld(tool):
272
+ from ..coordinator import WITHHELD
273
+
274
+ return ToolResult(
275
+ call.id, call.name, WITHHELD.format(name=call.name), True, "coordinator_withheld", {"executed": False}
276
+ )
277
+ if call.name in self.forbidden:
278
+ return ToolResult(
279
+ call.id,
280
+ call.name,
281
+ f"你是子 Agent,不能使用 {call.name};请在自己的范围内完成任务并在报告中说明。",
282
+ True,
283
+ "subagent_forbidden",
284
+ {"executed": False},
285
+ )
286
+ if read_only and not tool.read_only:
287
+ # Explicit caller restriction, separate from the approval-based plan mode.
288
+ return ToolResult(call.id, call.name, "本次调用限定只读工具;此调用未执行。", True, "plan_blocked")
289
+ try:
290
+ raw = json.loads(call.arguments, parse_constant=reject_json_constant)
291
+ except (ValueError, TypeError):
292
+ return ToolResult(call.id, call.name, "工具参数不是完整、合法的 JSON。", True, "invalid_json")
293
+ try:
294
+ params = tool.validate_arguments(raw)
295
+ except ToolFailure as exc:
296
+ return ToolResult(call.id, call.name, str(exc), True, exc.code, exc.details)
297
+ except ValidationError as exc:
298
+ issues = [
299
+ {"field": ".".join(map(str, e["loc"])), "message": e["msg"]}
300
+ for e in exc.errors(include_input=False, include_context=False, include_url=False)
301
+ ]
302
+ listed = "\n".join(f"- {issue['field'] or '(参数)'}:{issue['message']}" for issue in issues[:20])
303
+ return ToolResult(
304
+ call.id, call.name, "工具参数校验失败:\n" + listed, True, "invalid_arguments", {"issues": issues}
305
+ )
306
+ hooks, state = self.hooks, {}
307
+ gate = notify = None
308
+ if hooks is not None:
309
+
310
+ async def gate(operation):
311
+ state["operation"] = operation
312
+ if not hooks.wants("pre_tool_use"):
313
+ return None
314
+ outcome = await hooks.fire(self._event("pre_tool_use", call.name, raw, operation, tool.root))
315
+ state["notes"] = list(outcome.notes)
316
+ if outcome.rejection is None:
317
+ return None
318
+ state["hook"] = outcome.rejection[0]
319
+ return Decision("deny", "hook_rejected", outcome.rejection[1])
320
+
321
+ async def notify(request):
322
+ if hooks.wants("permission_request"):
323
+ operation = request.operation if request.operation.tool == call.name else None
324
+ await hooks.fire(self._event("permission_request", call.name, raw, operation, tool.root))
325
+
326
+ decision = await self.permissions.authorize(call.id, tool, params, gate=gate, notify=notify)
327
+ if decision.action != "allow":
328
+ details = {"executed": False, **({"hook": state["hook"]} if decision.code == "hook_rejected" else {})}
329
+ return ToolResult(call.id, call.name, decision.reason, True, decision.code, details)
330
+ result = await self._run(call, tool, params)
331
+ if hooks is None:
332
+ return result
333
+ return await self._after(call, tool, raw, state, result)
334
+
335
+ async def _after(self, call: ToolCall, tool: Tool, raw, state, result: ToolResult) -> ToolResult:
336
+ """post_tool_use; a cancelled hook keeps the tool result."""
337
+ hooks, notes, modified = self.hooks, list(state.get("notes", ())), None
338
+ if hooks.wants("post_tool_use"):
339
+ event = self._event("post_tool_use", call.name, raw, state.get("operation"), tool.root, result)
340
+ watched = event.file_path if call.name in {"write_file", "edit_file"} and not result.is_error else None
341
+ before = _stamp(watched)
342
+ try:
343
+ outcome = await hooks.fire(event)
344
+ except asyncio.CancelledError:
345
+ raise ExecutionCancelled(with_hook_notes(result, notes, None)) from None
346
+ notes.extend(outcome.notes)
347
+ if before is not None and outcome.ran and _stamp(watched) != before:
348
+ modified = outcome.ran[-1] # FileState keeps the tool's own stamp: the next edit must reread.
349
+ return with_hook_notes(result, notes, modified)
350
+
351
+ def _event(self, name, tool_name, raw, operation, root, result=None):
352
+ from ..hooks import tool_event
353
+
354
+ event = tool_event(name, self.agent, tool_name, raw, operation, root, result, agent_type=self.agent_type)
355
+ if self.worktree:
356
+ event = replace(
357
+ event,
358
+ fields={**event.fields, "worktree": self.worktree},
359
+ payload={**event.payload, "worktree": self.worktree},
360
+ )
361
+ return replace(event, nested=True) if self.nested else event
362
+
363
+ async def _run(self, call: ToolCall, tool: Tool, params: Params) -> ToolResult:
364
+ task = asyncio.create_task(self._invoke(call, tool, params))
365
+ limit = getattr(params, "timeout", tool.timeout)
366
+ try:
367
+ return await asyncio.wait_for(asyncio.shield(task), timeout=limit)
368
+ except (TimeoutError, asyncio.CancelledError) as exc:
369
+ task.cancel()
370
+ try:
371
+ outcome = await task
372
+ except asyncio.CancelledError:
373
+ code = "timeout" if isinstance(exc, TimeoutError) else "cancelled"
374
+ outcome = ToolResult(
375
+ call.id,
376
+ call.name,
377
+ "工具执行超时。" if code == "timeout" else "工具已取消,可能已有部分外部效果。",
378
+ True,
379
+ code,
380
+ )
381
+ if isinstance(exc, asyncio.CancelledError):
382
+ raise ExecutionCancelled(outcome) from None
383
+ # Local file operations may finish during cancellation cleanup.
384
+ return replace(outcome, details={**outcome.details, "deadline_exceeded": True})
@@ -0,0 +1,16 @@
1
+ """Unified diffs for people (approval and transcript); the model gets a summary."""
2
+
3
+ import difflib
4
+
5
+ DIFF_LIMIT = 200
6
+ DIFF_INPUT_LIMIT = 20000 # Lines per side; larger files skip the quadratic matcher.
7
+
8
+
9
+ def unified_diff(before: str, after: str, name: str, *, limit: int = DIFF_LIMIT) -> str:
10
+ old, new = before.splitlines(), after.splitlines()
11
+ if max(len(old), len(new)) > DIFF_INPUT_LIMIT:
12
+ return f"(文件超过 {DIFF_INPUT_LIMIT} 行,未生成 diff)"
13
+ lines = list(difflib.unified_diff(old, new, f"a/{name}", f"b/{name}", lineterm="", n=3))
14
+ if len(lines) > limit:
15
+ lines = [*lines[:limit], f"…(diff 共 {len(lines)} 行,仅显示前 {limit} 行)"]
16
+ return "\n".join(lines)
@@ -0,0 +1,99 @@
1
+ """Parallelize adjacent reads, preserve ordering across side-effect barriers."""
2
+
3
+ import asyncio
4
+ from copy import deepcopy
5
+ from time import monotonic
6
+
7
+ from ..messages import Message, ResponseBoundary, ToolProgress, ToolResult
8
+ from ..tool_budget import bound_message
9
+ from .base import ExecutionCancelled, ToolRegistry
10
+
11
+ PARALLEL_AGENTS = "agent" # Adjacent agent calls run together like adjacent reads.
12
+
13
+
14
+ def _group(call, registry: ToolRegistry) -> str | None:
15
+ if call.name == PARALLEL_AGENTS and registry.get(call.name) is not None:
16
+ return "agent"
17
+ return "read" if registry.is_read_only(call.name) else None
18
+
19
+
20
+ def partition_calls(calls, registry: ToolRegistry, concurrency: int = 4):
21
+ batch, kind = [], None
22
+ for index, call in enumerate(calls):
23
+ group = _group(call, registry)
24
+ if batch and (group != kind or len(batch) == concurrency):
25
+ yield batch
26
+ batch = []
27
+ if group is None:
28
+ yield [index]
29
+ continue
30
+ batch.append(index)
31
+ kind = group
32
+ if batch:
33
+ yield batch
34
+
35
+
36
+ async def execute_batch(message: Message, registry: ToolRegistry, history: list[Message], *, read_only: bool = False):
37
+ calls = message.tool_calls
38
+ results = [None] * len(calls)
39
+ jobs: dict[int, asyncio.Task] = {}
40
+ started = {}
41
+ approval_job = None
42
+ position = len(history)
43
+ history.append(Message("tool", ()))
44
+
45
+ def save():
46
+ history[position] = Message("tool", (), tool_results=tuple(deepcopy(r) for r in results if r is not None))
47
+
48
+ try:
49
+ yield ResponseBoundary(message)
50
+ for batch in partition_calls(calls, registry):
51
+ for index in batch:
52
+ yield ToolProgress(calls[index], "running")
53
+ for index in batch:
54
+ started[index] = monotonic()
55
+ jobs[index] = asyncio.create_task(registry.execute(calls[index], read_only=read_only))
56
+ while jobs:
57
+ if approval_job is None:
58
+ approval_job = asyncio.create_task(registry.permissions.requests.get())
59
+ done, _ = await asyncio.wait([*jobs.values(), approval_job], return_when=asyncio.FIRST_COMPLETED)
60
+ if approval_job in done:
61
+ request = approval_job.result()
62
+ approval_job = None
63
+ if request.id in registry.permissions.pending:
64
+ yield request
65
+ for index in sorted(i for i, task in jobs.items() if task in done):
66
+ result = await jobs[index]
67
+ del jobs[index]
68
+ results[index] = result
69
+ save()
70
+ yield ToolProgress(calls[index], "finished", result, monotonic() - started[index])
71
+ finally:
72
+ if approval_job is not None:
73
+ approval_job.cancel()
74
+ try:
75
+ await approval_job
76
+ except asyncio.CancelledError:
77
+ pass
78
+ for task in jobs.values():
79
+ if not task.done():
80
+ task.cancel()
81
+ # Await individually to preserve ExecutionCancelled.result.
82
+ for index, task in jobs.items():
83
+ try:
84
+ results[index] = await task
85
+ except ExecutionCancelled as exc:
86
+ results[index] = exc.result
87
+ except asyncio.CancelledError:
88
+ results[index] = ToolResult(calls[index].id, calls[index].name, "调用已取消。", True, "cancelled")
89
+ except Exception:
90
+ results[index] = ToolResult(
91
+ calls[index].id, calls[index].name, "工具执行出现内部错误。", True, "internal_error"
92
+ )
93
+ for index, result in enumerate(results):
94
+ if result is None:
95
+ results[index] = ToolResult(
96
+ calls[index].id, calls[index].name, "本轮已停止;此调用未执行。", True, "not_executed"
97
+ )
98
+ save()
99
+ history[position] = bound_message(history[position], registry.session)