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/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """MossCode: a coding agent in the terminal."""
2
+
3
+
4
+ def __getattr__(name: str):
5
+ """`mosscode.__version__` from the installed metadata, read only when asked for."""
6
+ if name == "__version__":
7
+ from .launcher import version
8
+
9
+ return version()
10
+ raise AttributeError(f"module 'mosscode' has no attribute {name!r}")
mosscode/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .launcher import main
2
+
3
+ main()
mosscode/agent.py ADDED
@@ -0,0 +1,486 @@
1
+ """UI- and SDK-independent bounded ReAct runtime and event stream."""
2
+
3
+ import asyncio
4
+ import json
5
+ import time
6
+ from collections.abc import AsyncIterator, Sequence
7
+ from contextlib import aclosing
8
+ from copy import deepcopy
9
+ from dataclasses import asdict, replace
10
+ from pathlib import Path
11
+ from typing import Protocol
12
+
13
+ from .compaction import NOTHING, CompactionError, compact_history, cut_point
14
+ from .context import ContextBudget, ContextMeter
15
+ from .messages import (
16
+ NOTIFICATION,
17
+ CompactFailed,
18
+ CompactFinished,
19
+ CompactStarted,
20
+ Completed,
21
+ ContentBlock,
22
+ IterationProgress,
23
+ Message,
24
+ ProviderError,
25
+ ResponseBoundary,
26
+ RunStopped,
27
+ StreamEvent,
28
+ TasksNotified,
29
+ ToolDefinition,
30
+ ToolProgress,
31
+ ToolResult,
32
+ Usage,
33
+ UsageTotals,
34
+ UsageUpdate,
35
+ UserInput,
36
+ )
37
+ from .prompts import (
38
+ PromptRequest,
39
+ Supplement,
40
+ active_skills,
41
+ build_system_prompt,
42
+ environment,
43
+ hook_notification,
44
+ mode_reminder,
45
+ stable_prompt,
46
+ )
47
+ from .session import compact_record, message_record
48
+ from .skills import ActiveSkills
49
+ from .tools import shell_label
50
+ from .tools.base import ToolRegistry
51
+ from .tools.execution import execute_batch
52
+
53
+
54
+ class Provider(Protocol):
55
+ def stream(
56
+ self,
57
+ messages: Sequence[Message],
58
+ system: str | PromptRequest,
59
+ tools: Sequence[ToolDefinition] = (),
60
+ *,
61
+ allow_tools: bool = True,
62
+ ) -> AsyncIterator[StreamEvent]: ...
63
+ async def close(self) -> None: ...
64
+
65
+
66
+ class ResponseCollector:
67
+ def __init__(self):
68
+ self.response: Completed | None = None
69
+
70
+ async def collect(self, stream):
71
+ async with aclosing(stream) as events:
72
+ async for event in events:
73
+ if isinstance(event, Completed):
74
+ self.response = deepcopy(event)
75
+ return
76
+ yield event
77
+ raise ProviderError("响应在完成信号前中断;未执行不完整的工具调用。")
78
+
79
+
80
+ DEFAULT_MAX_ITERATIONS = 200
81
+
82
+
83
+ def add_usage(total: UsageTotals, usage: Usage) -> UsageTotals:
84
+ return UsageTotals(
85
+ input_tokens=total.input_tokens + (usage.input_tokens or 0),
86
+ output_tokens=total.output_tokens + (usage.output_tokens or 0),
87
+ missing_input=total.missing_input + (usage.input_tokens is None),
88
+ missing_output=total.missing_output + (usage.output_tokens is None),
89
+ requests=total.requests + 1,
90
+ cached_input_tokens=total.cached_input_tokens + (usage.cached_input_tokens or 0),
91
+ cache_write_tokens=total.cache_write_tokens + (usage.cache_write_tokens or 0),
92
+ missing_cached=total.missing_cached + (usage.cached_input_tokens is None),
93
+ missing_cache_write=total.missing_cache_write + (usage.cache_write_tokens is None),
94
+ )
95
+
96
+
97
+ BREAKER = 3 # Consecutive automatic compaction failures before automatic compaction pauses.
98
+
99
+
100
+ class AgentLoop:
101
+ def __init__(
102
+ self,
103
+ provider: Provider,
104
+ system: str,
105
+ registry: ToolRegistry | None,
106
+ *,
107
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
108
+ unknown_limit: int = 3,
109
+ ):
110
+ if type(max_iterations) is not int or max_iterations < 1 or type(unknown_limit) is not int or unknown_limit < 1:
111
+ raise ValueError("循环上限与未知工具阈值必须是正整数。")
112
+ self.provider, self.system, self.registry = provider, system, registry
113
+ self.system = stable_prompt(system)
114
+ self.max_iterations, self.unknown_limit = max_iterations, unknown_limit
115
+ # Assembled by the application layer (project files, git); the loop never reads them itself.
116
+ self.instructions = ""
117
+ self.git: dict | None = None
118
+ self.skill_catalog = "" # The model-visible list of skills, after the instructions.
119
+ self.agent_catalog = "" # The sub-agent types, after the skills.
120
+ self.team_guide = "" # The lead's team section; never copied to forks or members.
121
+ self.active_skills = ActiveSkills() # Pinned SOPs, sent in the cached prefix.
122
+ self._announced = "normal"
123
+ self.history: list[Message] = []
124
+ self.busy = False
125
+ self.total_usage = UsageTotals()
126
+ # Steering: text queued while running, merged before the next model request.
127
+ self.pending: list[str] = []
128
+ # Background task notifications, merged the same way as their own message.
129
+ self.notifications: list[str] = []
130
+ self.on_usage = None # A sub-agent reports each request's usage to its task and the session.
131
+ # Context management: thresholds of the current provider, set by the application.
132
+ self.budget = ContextBudget.for_provider(None, 0)
133
+ self.session = None
134
+ self.auto_compact = True
135
+ self.meter = ContextMeter()
136
+ self.compact_failures = 0
137
+ self.compactions = 0
138
+ self._reannounce = False
139
+ # Set by the application (the session transcript): receives each record as history changes.
140
+ self.on_commit = None
141
+ # Set by the memory extractor: messages a compaction is about to replace with a summary.
142
+ self.on_compact = None
143
+ self._gap: Supplement | None = None # One-time reminder after resuming an old session.
144
+ # The main conversation's HookEngine (a fork sub-agent has none).
145
+ self.hooks = None
146
+ self.hook_context: Supplement | None = None # session_start output, cached prefix.
147
+ self.turn_notes: tuple[tuple[str, str], ...] = () # turn_start output, every request this turn.
148
+
149
+ def _commit(self, record: dict) -> None:
150
+ if self.on_commit is not None:
151
+ self.on_commit(record)
152
+
153
+ def _append(self, message: Message) -> None:
154
+ self.history.append(message)
155
+ self._commit(message_record(message))
156
+
157
+ def restore(self, history: list[Message], gap: Supplement | None = None):
158
+ """Resume a saved session: its messages only; usage, reads and the mode announcement start afresh."""
159
+ if self.busy:
160
+ raise ProviderError("当前任务正在运行,不能恢复会话。")
161
+ self.clear()
162
+ self.history = list(history)
163
+ if self.registry is not None:
164
+ self.registry.forget_reads()
165
+ self._gap = gap
166
+
167
+ @property
168
+ def mode(self):
169
+ # Plan is one of the permission modes; the policy holds the only copy.
170
+ return "plan" if self.registry is not None and self.registry.permissions.mode == "plan" else "normal"
171
+
172
+ def set_mode(self, mode: str):
173
+ if mode not in {"normal", "plan"}:
174
+ raise ValueError("模式必须为 normal 或 plan。")
175
+ if self.busy:
176
+ raise ProviderError("当前任务正在运行,不能切换模式。")
177
+ if self.registry is None:
178
+ if mode == "plan":
179
+ raise ProviderError("当前会话未启用工具,不能进入计划模式。")
180
+ return
181
+ policy = self.registry.permissions
182
+ if mode == "plan":
183
+ policy.set_mode("plan")
184
+ elif policy.mode == "plan":
185
+ policy.set_mode(policy.resume_mode)
186
+
187
+ def clear(self):
188
+ if self.busy:
189
+ raise ProviderError("当前任务正在运行,不能清空会话。")
190
+ self.history, self.pending, self.notifications = [], [], []
191
+ self.total_usage = UsageTotals()
192
+ # A fresh context has not seen the mode rules yet.
193
+ self._announced = "normal"
194
+ self.meter.invalidate()
195
+ self.compact_failures = 0
196
+ self._gap = None
197
+ self.active_skills.clear()
198
+ self.hook_context, self.turn_notes = None, () # The next session_start regenerates it.
199
+
200
+ def set_provider(self, provider: Provider, budget: ContextBudget | None = None):
201
+ if self.busy:
202
+ raise ProviderError("当前任务正在运行,不能切换模型。")
203
+ self.provider = provider
204
+ if budget is not None:
205
+ self.budget = budget
206
+ # Another model reports usage differently; start from a full estimate and a fresh breaker.
207
+ self.meter.invalidate()
208
+ self.compact_failures = 0
209
+ # Signed/opaque blocks are valid only for the model that produced them.
210
+ self.history = [replace(m, protocol=None, native=()) for m in self.history]
211
+
212
+ def _definitions(self):
213
+ return self.registry.definitions(plan=self.mode == "plan") if self.registry else ()
214
+
215
+ def _prompt(self, definitions):
216
+ instructions = "\n\n".join(
217
+ part for part in (self.instructions, self.skill_catalog, self.agent_catalog, self.team_guide) if part
218
+ )
219
+ return build_system_prompt(definitions, override=self.system, instructions=instructions)
220
+
221
+ def _active(self) -> Supplement | None:
222
+ return active_skills(self.active_skills.texts())
223
+
224
+ def _model(self, messages, iteration, env, notes=()):
225
+ reminder = mode_reminder(self.mode, self._announced) if iteration == 1 or self._reannounce else None
226
+ self._announced, self._reannounce = self.mode, False
227
+ definitions = self._definitions()
228
+ extra = tuple(item for item in (reminder, self._gap, hook_notification((*self.turn_notes, *notes))) if item)
229
+ self._gap = None # Never part of the history or the transcript; sent with the first request only.
230
+ prefix = tuple(item for item in (env, self._active(), self.hook_context) if item)
231
+ prompt = PromptRequest(self._prompt(definitions), prefix + extra)
232
+ if self.registry is None:
233
+ return self.provider.stream(messages, prompt)
234
+ return self.provider.stream(messages, prompt, definitions)
235
+
236
+ def _fixed_text(self) -> str:
237
+ """System prompt and tool definitions, for a full estimate when no usage anchors it."""
238
+ definitions = self._definitions()
239
+ schemas = json.dumps([asdict(d) for d in definitions], ensure_ascii=False)
240
+ return self._prompt(definitions) + schemas
241
+
242
+ def estimate(self, extra: Sequence[Message] = (), env_text: str = "") -> int:
243
+ active = self._active()
244
+ env_text += active.render() if active else ""
245
+ env_text += self.hook_context.render() if self.hook_context else ""
246
+ return self.meter.estimate(list(self.history) + list(extra), env_text, self._fixed_text)
247
+
248
+ async def _compact(self, instructions: str = "", *, manual: bool = False, failure: str | None = None):
249
+ """One compaction attempt; events report the outcome, the history changes only on success."""
250
+ if not cut_point(self.history, self.budget.auto_threshold, manual=manual):
251
+ # Nothing older than the kept tail: automatic attempts skip silently and never trip the breaker.
252
+ if manual:
253
+ yield CompactFailed(NOTHING, self.compact_failures, self.auto_paused, "nothing", True)
254
+ return
255
+ yield CompactStarted(manual)
256
+ started = time.monotonic()
257
+ if self.on_compact is not None:
258
+ # Messages about to be summarized away; the memory extractor still gets to read them.
259
+ self.on_compact(self.history[: cut_point(self.history, self.budget.auto_threshold, manual=manual)])
260
+ try:
261
+ done = await compact_history(
262
+ self.provider, self.history, self.budget, session=self.session, instructions=instructions, manual=manual
263
+ )
264
+ except CompactionError as exc:
265
+ if failure is not None: # Resuming: its own message, and the breaker is untouched.
266
+ yield CompactFailed(failure.format(exc), self.compact_failures, self.auto_paused, "restore")
267
+ return
268
+ if not manual and exc.code != "nothing":
269
+ self.compact_failures += 1
270
+ yield CompactFailed(str(exc), self.compact_failures, self.auto_paused, exc.code, manual)
271
+ return
272
+ self.history = done.history
273
+ self._commit(compact_record(done.history[0], len(done.history) - 1, str(done.record) if done.record else None))
274
+ if self.registry is not None:
275
+ self.registry.forget_reads()
276
+ # The mode rules may have been summarized away; the next request repeats them (as after /clear).
277
+ self._announced, self._reannounce = "normal", True
278
+ self.meter.invalidate()
279
+ self.compact_failures = 0
280
+ self.compactions += 1
281
+ yield CompactFinished(
282
+ done.removed, done.summary_tokens, time.monotonic() - started, str(done.record) if done.record else None
283
+ )
284
+ if self.hooks is not None and self.hooks.wants("compact"):
285
+ from .hooks import HookEvent
286
+
287
+ trigger = "restore" if failure is not None else "manual" if manual else "auto"
288
+ await self.hooks.fire(HookEvent("compact", fields={"trigger": trigger}))
289
+
290
+ async def compact(self, instructions: str = ""):
291
+ """Manual /compact: idle only, regardless of the threshold; resets the breaker on success."""
292
+ if self.busy:
293
+ raise ProviderError("当前任务正在运行,不能压缩上下文。")
294
+ self.busy = True
295
+ try:
296
+ async with aclosing(self._compact(instructions, manual=True)) as events:
297
+ async for event in events:
298
+ yield event
299
+ finally:
300
+ self.busy = False
301
+
302
+ async def compact_restored(self):
303
+ """A resumed session over the threshold is compacted before the first input, even with
304
+ auto_compact off; like /compact it never trips the breaker."""
305
+ if self.busy:
306
+ raise ProviderError("当前任务正在运行,不能压缩上下文。")
307
+ if self.estimate() < self.budget.auto_threshold:
308
+ return
309
+ self.busy = True
310
+ try:
311
+ failure = "恢复的会话超出上下文窗口,压缩失败:{};可以 /compact 重试或 /clear"
312
+ async with aclosing(self._compact(failure=failure)) as events:
313
+ async for event in events:
314
+ yield event
315
+ finally:
316
+ self.busy = False
317
+
318
+ @property
319
+ def auto_paused(self) -> bool:
320
+ return self.compact_failures >= BREAKER
321
+
322
+ def _may_compact(self) -> bool:
323
+ return self.auto_compact and not self.auto_paused
324
+
325
+ def _usage(self, usage):
326
+ self.total_usage = add_usage(self.total_usage, usage)
327
+ if self.on_usage is not None:
328
+ self.on_usage(usage)
329
+
330
+ async def _hook(self, name: str, fields: dict) -> tuple[tuple[str, str], ...]:
331
+ if self.hooks is None or not self.hooks.wants(name):
332
+ return ()
333
+ from .hooks import HookEvent
334
+
335
+ return (await self.hooks.fire(HookEvent(name, fields=fields))).notes
336
+
337
+ async def run(self, prompt: str, *, command: str | None = None, synthetic: str | None = None):
338
+ if self.busy:
339
+ raise ProviderError("已有请求正在运行,请等待或取消。")
340
+ if not prompt.strip():
341
+ raise ProviderError("请输入非空消息。")
342
+ self.busy = True
343
+ iteration, unknown_run = 0, 0
344
+ user = Message("user", (ContentBlock("text", prompt),), command=command, synthetic=synthetic)
345
+ committed = False
346
+
347
+ def outcomes():
348
+ return self.history[-1].tool_results if committed and self.history[-1].role == "tool" else ()
349
+
350
+ try:
351
+ definitions = self.registry.definitions() if self.registry else ()
352
+ root = self.registry.get(definitions[0].name).root if definitions else Path.cwd()
353
+ for iteration in range(1, self.max_iterations + 1):
354
+ # Rebuilt each round: approving a plan changes the mode mid-task.
355
+ env = environment(
356
+ root,
357
+ tools_enabled=self.registry is not None,
358
+ permission_mode=self.registry.permissions.mode if self.registry else None,
359
+ git=self.git,
360
+ shell=shell_label(self.registry),
361
+ )
362
+ yield IterationProgress(iteration, self.max_iterations, self.mode)
363
+ if committed and self.pending:
364
+ texts, self.pending = tuple(self.pending), []
365
+ self._append(Message("user", (ContentBlock("text", "\n\n".join(texts)),)))
366
+ yield UserInput(texts)
367
+ if committed and self.notifications:
368
+ texts, self.notifications = tuple(self.notifications), []
369
+ self._append(Message("user", (ContentBlock("text", "\n\n".join(texts)),), synthetic=NOTIFICATION))
370
+ yield TasksNotified(texts)
371
+ fresh = [] if committed else [user]
372
+ if self._may_compact() and self.estimate(fresh, env.text) >= self.budget.auto_threshold:
373
+ async with aclosing(self._compact()) as events:
374
+ async for event in events:
375
+ yield event
376
+ notes = await self._hook("pre_send", {"iteration": str(iteration)})
377
+ rescued = False
378
+ while True:
379
+ request = deepcopy(self.history) + fresh
380
+ collector = ResponseCollector()
381
+ try:
382
+ async with aclosing(collector.collect(self._model(request, iteration, env, notes))) as events:
383
+ async for event in events:
384
+ yield event
385
+ except ProviderError as exc:
386
+ # Over the window despite the estimate. Compact once, retry once, never loop.
387
+ if exc.code != "context_overflow" or rescued or not self._may_compact():
388
+ raise
389
+ rescued, compacted = True, False
390
+ async with aclosing(self._compact()) as events:
391
+ async for event in events:
392
+ compacted = compacted or isinstance(event, CompactFinished)
393
+ yield event
394
+ if not compacted:
395
+ raise exc
396
+ continue
397
+ break
398
+ final = collector.response
399
+ if final is None or final.message.role != "assistant":
400
+ raise ProviderError("服务返回了不合法的回复。")
401
+ calls = final.message.tool_calls
402
+ ids = [call.id for call in calls]
403
+ if any(not call.id or not call.name for call in calls) or len(ids) != len(set(ids)):
404
+ raise ProviderError("工具调用 ID 或名称不合法;未执行工具。")
405
+ if calls and self.registry is None:
406
+ raise ProviderError("当前会话未启用工具。")
407
+ await self._hook("post_receive", {"iteration": str(iteration), "tool_calls": str(len(calls))})
408
+ if not committed:
409
+ self._append(deepcopy(user))
410
+ committed = True
411
+ self._append(deepcopy(final.message))
412
+ self._usage(final.usage)
413
+ self.meter.record(final.usage, len(self.history))
414
+ truncated = final.stop_reason == "max_tokens"
415
+ if not calls:
416
+ yield UsageUpdate(iteration, final.usage, self.total_usage)
417
+ yield final
418
+ if truncated:
419
+ yield RunStopped(
420
+ "max_tokens",
421
+ "回复达到输出上限(max_output_tokens),已截断;可以输入“继续”或调高该配置。",
422
+ iteration,
423
+ )
424
+ else:
425
+ yield RunStopped("completed", "", iteration)
426
+ return
427
+ if truncated:
428
+ # Arguments may be cut mid-JSON: never execute them, let the model retry smaller.
429
+ results = tuple(
430
+ ToolResult(
431
+ c.id,
432
+ c.name,
433
+ "回复达到输出上限,此调用的参数可能不完整,未执行。"
434
+ "请缩小单次输出后重试,例如分多次写入或编辑。",
435
+ True,
436
+ "output_truncated",
437
+ {"executed": False},
438
+ )
439
+ for c in calls
440
+ )
441
+ self._append(Message("tool", (), tool_results=results))
442
+ yield UsageUpdate(iteration, final.usage, self.total_usage)
443
+ yield ResponseBoundary(final.message)
444
+ for call, result in zip(calls, results):
445
+ yield ToolProgress(call, "finished", result)
446
+ continue
447
+ # Enter paired-results cleanup before yielding a committed call.
448
+ position = len(self.history)
449
+ try:
450
+ async with aclosing(execute_batch(final.message, self.registry, self.history)) as batch:
451
+ boundary = await anext(batch)
452
+ yield UsageUpdate(iteration, final.usage, self.total_usage)
453
+ yield boundary
454
+ async for event in batch:
455
+ yield event
456
+ finally:
457
+ # The batch's final results, once, also when cancelled: never its intermediate states.
458
+ if len(self.history) > position:
459
+ self._commit(message_record(self.history[position]))
460
+ unknown_run = unknown_run + 1 if all(self.registry.get(c.name) is None for c in calls) else 0
461
+ if unknown_run >= self.unknown_limit:
462
+ yield RunStopped(
463
+ "unknown_tools",
464
+ f"连续 {self.unknown_limit} 轮仅请求未知工具,已停止;可以修改任务后继续。",
465
+ iteration,
466
+ outcomes(),
467
+ )
468
+ return
469
+ yield RunStopped(
470
+ "max_iterations",
471
+ f"已达最大迭代轮数 {self.max_iterations},已停止;可以继续提问推进任务。",
472
+ iteration,
473
+ outcomes(),
474
+ )
475
+ except asyncio.CancelledError:
476
+ yield RunStopped("cancelled", "已取消;已执行工具及其结果保留。", iteration, outcomes())
477
+ raise
478
+ except ProviderError as exc:
479
+ yield RunStopped("error", str(exc), iteration, outcomes())
480
+ raise
481
+ except Exception:
482
+ notice = "Agent 运行出现内部错误;已执行工具及其结果保留。"
483
+ yield RunStopped("error", notice, iteration, outcomes())
484
+ raise ProviderError(notice) from None
485
+ finally:
486
+ self.busy = False