mosscode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/__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
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
|