windcode 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 (143) hide show
  1. windcode/__init__.py +6 -0
  2. windcode/__main__.py +4 -0
  3. windcode/auth/__init__.py +11 -0
  4. windcode/auth/store.py +90 -0
  5. windcode/cli.py +181 -0
  6. windcode/config/__init__.py +41 -0
  7. windcode/config/loader.py +117 -0
  8. windcode/config/models.py +203 -0
  9. windcode/config/writer.py +74 -0
  10. windcode/context/__init__.py +18 -0
  11. windcode/context/compactor.py +72 -0
  12. windcode/context/estimator.py +89 -0
  13. windcode/context/truncation.py +87 -0
  14. windcode/domain/__init__.py +1 -0
  15. windcode/domain/errors.py +33 -0
  16. windcode/domain/events.py +597 -0
  17. windcode/domain/messages.py +226 -0
  18. windcode/domain/models.py +73 -0
  19. windcode/domain/subagents.py +263 -0
  20. windcode/domain/tools.py +55 -0
  21. windcode/extensions/__init__.py +27 -0
  22. windcode/extensions/commands.py +25 -0
  23. windcode/extensions/discovery.py +139 -0
  24. windcode/extensions/events.py +58 -0
  25. windcode/extensions/hooks/__init__.py +4 -0
  26. windcode/extensions/hooks/dispatcher.py +107 -0
  27. windcode/extensions/hooks/executor.py +49 -0
  28. windcode/extensions/hooks/loader.py +101 -0
  29. windcode/extensions/hooks/models.py +109 -0
  30. windcode/extensions/mcp/__init__.py +10 -0
  31. windcode/extensions/mcp/adapter.py +101 -0
  32. windcode/extensions/mcp/catalog.py +153 -0
  33. windcode/extensions/mcp/client.py +273 -0
  34. windcode/extensions/mcp/runtime.py +144 -0
  35. windcode/extensions/mcp/tools.py +570 -0
  36. windcode/extensions/models.py +168 -0
  37. windcode/extensions/paths.py +71 -0
  38. windcode/extensions/plugins/__init__.py +3 -0
  39. windcode/extensions/plugins/installer.py +93 -0
  40. windcode/extensions/plugins/manifest.py +166 -0
  41. windcode/extensions/runtime.py +366 -0
  42. windcode/extensions/service.py +437 -0
  43. windcode/extensions/skills/__init__.py +3 -0
  44. windcode/extensions/skills/loader.py +67 -0
  45. windcode/extensions/skills/parser.py +57 -0
  46. windcode/extensions/skills/tools.py +213 -0
  47. windcode/extensions/snapshot.py +57 -0
  48. windcode/extensions/state.py +158 -0
  49. windcode/instructions/__init__.py +8 -0
  50. windcode/instructions/loader.py +50 -0
  51. windcode/memory/__init__.py +57 -0
  52. windcode/memory/extraction.py +83 -0
  53. windcode/memory/models.py +218 -0
  54. windcode/memory/refiner.py +189 -0
  55. windcode/memory/security.py +23 -0
  56. windcode/memory/service.py +179 -0
  57. windcode/memory/store.py +362 -0
  58. windcode/observability/__init__.py +4 -0
  59. windcode/observability/redaction.py +95 -0
  60. windcode/observability/trace.py +115 -0
  61. windcode/policy/__init__.py +22 -0
  62. windcode/policy/engine.py +137 -0
  63. windcode/policy/models.py +69 -0
  64. windcode/providers/__init__.py +29 -0
  65. windcode/providers/_utils.py +19 -0
  66. windcode/providers/anthropic.py +215 -0
  67. windcode/providers/base.py +46 -0
  68. windcode/providers/catalog.py +119 -0
  69. windcode/providers/errors.py +96 -0
  70. windcode/providers/openai_compat.py +231 -0
  71. windcode/providers/openai_responses.py +189 -0
  72. windcode/providers/registry.py +105 -0
  73. windcode/runtime/__init__.py +15 -0
  74. windcode/runtime/control.py +89 -0
  75. windcode/runtime/event_bus.py +67 -0
  76. windcode/runtime/loop.py +510 -0
  77. windcode/runtime/prompts.py +132 -0
  78. windcode/runtime/report.py +64 -0
  79. windcode/runtime/retry.py +73 -0
  80. windcode/runtime/scheduler.py +194 -0
  81. windcode/runtime/subagents/__init__.py +28 -0
  82. windcode/runtime/subagents/approvals.py +88 -0
  83. windcode/runtime/subagents/budgets.py +79 -0
  84. windcode/runtime/subagents/coordinator.py +714 -0
  85. windcode/runtime/subagents/factory.py +311 -0
  86. windcode/runtime/subagents/roles.py +74 -0
  87. windcode/runtime/subagents/verification.py +53 -0
  88. windcode/sandbox/__init__.py +3 -0
  89. windcode/sandbox/bwrap.py +79 -0
  90. windcode/sdk.py +1259 -0
  91. windcode/sessions/__init__.py +23 -0
  92. windcode/sessions/artifacts.py +69 -0
  93. windcode/sessions/models.py +104 -0
  94. windcode/sessions/store.py +167 -0
  95. windcode/sessions/tree.py +35 -0
  96. windcode/tools/__init__.py +10 -0
  97. windcode/tools/apply_patch.py +191 -0
  98. windcode/tools/ask_user.py +58 -0
  99. windcode/tools/builtins.py +44 -0
  100. windcode/tools/edit_file.py +54 -0
  101. windcode/tools/filesystem.py +77 -0
  102. windcode/tools/glob.py +35 -0
  103. windcode/tools/grep.py +66 -0
  104. windcode/tools/memory.py +400 -0
  105. windcode/tools/read_file.py +54 -0
  106. windcode/tools/registry.py +120 -0
  107. windcode/tools/shell.py +159 -0
  108. windcode/tools/subagents/__init__.py +31 -0
  109. windcode/tools/subagents/cancel.py +35 -0
  110. windcode/tools/subagents/integrate.py +54 -0
  111. windcode/tools/subagents/list.py +46 -0
  112. windcode/tools/subagents/spawn.py +86 -0
  113. windcode/tools/subagents/wait.py +74 -0
  114. windcode/tools/write_file.py +61 -0
  115. windcode/tui/__init__.py +3 -0
  116. windcode/tui/app.py +1015 -0
  117. windcode/tui/commands.py +90 -0
  118. windcode/tui/permission_display.py +24 -0
  119. windcode/tui/styles.tcss +591 -0
  120. windcode/tui/widgets/__init__.py +31 -0
  121. windcode/tui/widgets/approval.py +98 -0
  122. windcode/tui/widgets/command_menu.py +90 -0
  123. windcode/tui/widgets/extensions.py +48 -0
  124. windcode/tui/widgets/input.py +110 -0
  125. windcode/tui/widgets/memory.py +143 -0
  126. windcode/tui/widgets/messages.py +299 -0
  127. windcode/tui/widgets/models.py +565 -0
  128. windcode/tui/widgets/question.py +46 -0
  129. windcode/tui/widgets/sessions.py +68 -0
  130. windcode/tui/widgets/status.py +46 -0
  131. windcode/tui/widgets/subagents.py +195 -0
  132. windcode/tui/widgets/tools.py +66 -0
  133. windcode/tui/widgets/welcome.py +149 -0
  134. windcode/types.py +80 -0
  135. windcode/worktrees/__init__.py +24 -0
  136. windcode/worktrees/git.py +92 -0
  137. windcode/worktrees/manager.py +265 -0
  138. windcode/worktrees/models.py +62 -0
  139. windcode-0.1.0.dist-info/METADATA +207 -0
  140. windcode-0.1.0.dist-info/RECORD +143 -0
  141. windcode-0.1.0.dist-info/WHEEL +4 -0
  142. windcode-0.1.0.dist-info/entry_points.txt +2 -0
  143. windcode-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from time import monotonic
6
+
7
+ from windcode.domain.events import RunResponse
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class RunBudgets:
12
+ max_model_steps: int = 40
13
+ max_tool_calls: int = 100
14
+ max_runtime_seconds: float = 1800.0
15
+
16
+
17
+ class BudgetExceeded(RuntimeError):
18
+ def __init__(self, budget: str) -> None:
19
+ self.budget = budget
20
+ super().__init__(f"run budget exhausted: {budget}")
21
+
22
+
23
+ class RunControl:
24
+ def __init__(self, budgets: RunBudgets | None = None) -> None:
25
+ self.budgets = budgets or RunBudgets()
26
+ self.started_at = monotonic()
27
+ self.model_steps = 0
28
+ self.tool_calls = 0
29
+ self._cancelled = asyncio.Event()
30
+ self._compaction_requested = False
31
+ self._pending: dict[str, asyncio.Future[RunResponse]] = {}
32
+
33
+ @property
34
+ def cancelled(self) -> bool:
35
+ return self._cancelled.is_set()
36
+
37
+ def check(self) -> None:
38
+ if self.cancelled:
39
+ raise asyncio.CancelledError
40
+ if monotonic() - self.started_at >= self.budgets.max_runtime_seconds:
41
+ raise BudgetExceeded("runtime_seconds")
42
+
43
+ def start_model_step(self) -> int:
44
+ self.check()
45
+ if self.model_steps >= self.budgets.max_model_steps:
46
+ raise BudgetExceeded("model_steps")
47
+ self.model_steps += 1
48
+ return self.model_steps
49
+
50
+ def reserve_tool_calls(self, count: int) -> None:
51
+ self.check()
52
+ if self.tool_calls + count > self.budgets.max_tool_calls:
53
+ raise BudgetExceeded("tool_calls")
54
+ self.tool_calls += count
55
+
56
+ async def wait_for_response(self, request_id: str) -> RunResponse:
57
+ self.check()
58
+ if request_id in self._pending:
59
+ raise ValueError(f"duplicate pending request: {request_id}")
60
+ future = asyncio.get_running_loop().create_future()
61
+ self._pending[request_id] = future
62
+ try:
63
+ return await future
64
+ finally:
65
+ self._pending.pop(request_id, None)
66
+
67
+ def respond(self, response: RunResponse) -> None:
68
+ try:
69
+ future = self._pending[response.request_id]
70
+ except KeyError as exc:
71
+ raise ValueError(f"no pending request: {response.request_id}") from exc
72
+ if not future.done():
73
+ future.set_result(response)
74
+
75
+ def cancel(self) -> None:
76
+ if self.cancelled:
77
+ return
78
+ self._cancelled.set()
79
+ for future in tuple(self._pending.values()):
80
+ if not future.done():
81
+ future.cancel()
82
+
83
+ def request_compaction(self) -> None:
84
+ self._compaction_requested = True
85
+
86
+ def consume_compaction_request(self) -> bool:
87
+ requested = self._compaction_requested
88
+ self._compaction_requested = False
89
+ return requested
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+ from dataclasses import replace
6
+
7
+ from windcode.domain.events import AgentEventType, event_from_dict, event_to_dict
8
+ from windcode.observability import TraceStore
9
+ from windcode.sessions import SessionStore
10
+
11
+ _TRANSIENT_EVENT_KINDS = frozenset(
12
+ {"text_delta", "reasoning_status", "tool_progress", "subagent_progress"}
13
+ )
14
+
15
+
16
+ class EventBus:
17
+ def __init__(self, session_store: SessionStore, trace_store: TraceStore) -> None:
18
+ self.session_store = session_store
19
+ self.trace_store = trace_store
20
+ self._queue: asyncio.Queue[AgentEventType | None] = asyncio.Queue()
21
+ self._closed = False
22
+
23
+ async def publish(self, event: AgentEventType, *, durable: bool = False) -> AgentEventType:
24
+ if self._closed:
25
+ raise RuntimeError("event bus is closed")
26
+ if event.kind in _TRANSIENT_EVENT_KINDS:
27
+ self.trace_store.write(event, durable=durable)
28
+ await self._queue.put(event)
29
+ return event
30
+ record = self.session_store.append("agent_event", event_to_dict(event), durable=durable)
31
+ persisted = replace(event, sequence=record.sequence)
32
+ self.trace_store.write(persisted, durable=durable)
33
+ await self._queue.put(persisted)
34
+ return persisted
35
+
36
+ def _replay(self, after_sequence: int) -> tuple[AgentEventType, ...]:
37
+ replayed: list[AgentEventType] = []
38
+ for record in self.session_store.load_records():
39
+ if record.record_type != "agent_event" or record.sequence <= after_sequence:
40
+ continue
41
+ event = event_from_dict(record.payload)
42
+ replayed.append(replace(event, sequence=record.sequence))
43
+ return tuple(replayed)
44
+
45
+ async def subscribe(self, *, after_sequence: int = 0) -> AsyncIterator[AgentEventType]:
46
+ replayed = () if not self._queue.empty() else self._replay(after_sequence)
47
+ highest = after_sequence
48
+ for event in replayed:
49
+ highest = max(highest, event.sequence or 0)
50
+ yield event
51
+ while True:
52
+ event = await self._queue.get()
53
+ if event is None:
54
+ return
55
+ if event.sequence is None:
56
+ yield event
57
+ continue
58
+ if event.sequence <= highest:
59
+ continue
60
+ highest = event.sequence
61
+ yield event
62
+
63
+ async def close(self) -> None:
64
+ if self._closed:
65
+ return
66
+ self._closed = True
67
+ await self._queue.put(None)
@@ -0,0 +1,510 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+ from uuid import uuid4
9
+
10
+ from windcode.context import TokenEstimator, compact_context, truncate_context
11
+ from windcode.domain.errors import WindcodeError
12
+ from windcode.domain.events import (
13
+ ApprovalRequested,
14
+ ApprovalResponse,
15
+ ContextCompacted,
16
+ ModelFallback,
17
+ ModelRetrying,
18
+ ModelStarted,
19
+ ReasoningStatus,
20
+ RunCancelled,
21
+ RunCompleted,
22
+ RunFailed,
23
+ RunResult,
24
+ RunStarted,
25
+ TextDeltaEvent,
26
+ ToolFinished,
27
+ ToolStarted,
28
+ UsageUpdated,
29
+ UserInputRequested,
30
+ UserResponse,
31
+ )
32
+ from windcode.domain.messages import (
33
+ Message,
34
+ Role,
35
+ SourcedContextMessage,
36
+ TextBlock,
37
+ ToolCallBlock,
38
+ ToolResultBlock,
39
+ message_to_dict,
40
+ )
41
+ from windcode.domain.models import (
42
+ ModelRequest,
43
+ ModelUsage,
44
+ ReasoningDelta,
45
+ TextDelta,
46
+ ToolCallDelta,
47
+ Usage,
48
+ )
49
+ from windcode.domain.tools import ToolContext, ToolEffect
50
+ from windcode.policy import (
51
+ ApprovalChoice,
52
+ PolicyDecision,
53
+ PolicyRequest,
54
+ summarize_policy_arguments,
55
+ )
56
+ from windcode.providers import ModelTarget
57
+ from windcode.runtime.control import BudgetExceeded, RunControl
58
+ from windcode.runtime.event_bus import EventBus
59
+ from windcode.runtime.report import ToolExecutionRecord, build_run_result
60
+ from windcode.runtime.retry import stream_with_retry
61
+ from windcode.runtime.scheduler import ScheduledCall, ToolScheduler
62
+ from windcode.sessions import ArtifactStore, SessionStatus
63
+
64
+
65
+ class AgentBlocked(RuntimeError):
66
+ pass
67
+
68
+
69
+ def _add_usage(left: Usage, right: Usage) -> Usage:
70
+ return Usage(
71
+ input_tokens=left.input_tokens + right.input_tokens,
72
+ output_tokens=left.output_tokens + right.output_tokens,
73
+ cache_read_tokens=left.cache_read_tokens + right.cache_read_tokens,
74
+ cache_write_tokens=left.cache_write_tokens + right.cache_write_tokens,
75
+ )
76
+
77
+
78
+ class AgentLoop:
79
+ def __init__(
80
+ self,
81
+ *,
82
+ session_id: str,
83
+ run_id: str,
84
+ model_chain: tuple[ModelTarget, ...],
85
+ scheduler: ToolScheduler,
86
+ control: RunControl,
87
+ event_bus: EventBus,
88
+ system_prompt: str,
89
+ max_output_tokens: int | None = None,
90
+ token_estimator: TokenEstimator | None = None,
91
+ artifact_store: ArtifactStore | None = None,
92
+ preserve_recent_turns: int = 8,
93
+ max_tool_result_chars: int = 20_000,
94
+ close_event_bus: bool = True,
95
+ sourced_context_provider: Callable[[], tuple[SourcedContextMessage, ...]] | None = None,
96
+ compact_observer: Callable[[str], Awaitable[None]] | None = None,
97
+ completion_observer: Callable[[RunResult], Awaitable[None]] | None = None,
98
+ ) -> None:
99
+ if not model_chain:
100
+ raise ValueError("model_chain cannot be empty")
101
+ self.session_id = session_id
102
+ self.run_id = run_id
103
+ self.model_chain = model_chain
104
+ self.scheduler = scheduler
105
+ self.control = control
106
+ self.event_bus = event_bus
107
+ self.system_prompt = system_prompt
108
+ self.max_output_tokens = max_output_tokens
109
+ self.token_estimator = token_estimator
110
+ self.artifact_store = artifact_store
111
+ self.preserve_recent_turns = preserve_recent_turns
112
+ self.max_tool_result_chars = max_tool_result_chars
113
+ self.close_event_bus = close_event_bus
114
+ self.sourced_context_provider = sourced_context_provider
115
+ self.compact_observer = compact_observer
116
+ self.completion_observer = completion_observer
117
+ self._turn = 0
118
+ self.scheduler.approval_handler = self._approval_handler
119
+ self.scheduler.before_execute = self._before_tool_execute
120
+
121
+ def _common(self, turn: int | None = None) -> dict[str, Any]:
122
+ return {
123
+ "event_id": uuid4().hex,
124
+ "session_id": self.session_id,
125
+ "run_id": self.run_id,
126
+ "turn": self._turn if turn is None else turn,
127
+ }
128
+
129
+ async def _approval_handler(
130
+ self,
131
+ request: PolicyRequest,
132
+ decision: PolicyDecision,
133
+ ) -> ApprovalChoice:
134
+ await self.event_bus.publish(
135
+ ApprovalRequested(
136
+ **self._common(),
137
+ request_id=request.request_id,
138
+ summary=request.summary,
139
+ risk=decision.risk.value,
140
+ choices=tuple(choice.value for choice in decision.choices),
141
+ tool_name=request.tool_name,
142
+ arguments_summary=summarize_policy_arguments(request),
143
+ ),
144
+ durable=True,
145
+ )
146
+ response = await self.control.wait_for_response(request.request_id)
147
+ if not isinstance(response, ApprovalResponse):
148
+ raise ValueError("approval request received a user-question response")
149
+ try:
150
+ return ApprovalChoice(response.decision)
151
+ except ValueError:
152
+ return ApprovalChoice.DENY
153
+
154
+ async def _request_user(self, payload: object) -> object:
155
+ request_id = uuid4().hex
156
+ questions = cast(tuple[dict[str, Any], ...], payload)
157
+ await self.event_bus.publish(
158
+ UserInputRequested(**self._common(), request_id=request_id, questions=questions),
159
+ durable=True,
160
+ )
161
+ response = await self.control.wait_for_response(request_id)
162
+ if not isinstance(response, UserResponse):
163
+ raise ValueError("user question received an approval response")
164
+ return response.answers
165
+
166
+ async def _before_tool_execute(
167
+ self,
168
+ call: ScheduledCall,
169
+ request: PolicyRequest,
170
+ ) -> None:
171
+ await self.event_bus.publish(
172
+ ToolStarted(
173
+ **self._common(),
174
+ call_id=call.call_id,
175
+ tool_name=call.tool_name,
176
+ arguments=dict(call.arguments),
177
+ ),
178
+ durable=True,
179
+ )
180
+ side_effect = bool(
181
+ request.effects
182
+ & {
183
+ ToolEffect.WORKSPACE_WRITE,
184
+ ToolEffect.PROCESS,
185
+ ToolEffect.NETWORK,
186
+ ToolEffect.OUTSIDE_WORKSPACE,
187
+ }
188
+ )
189
+ self.event_bus.session_store.append(
190
+ "tool_started",
191
+ {
192
+ "call_id": call.call_id,
193
+ "tool_name": call.tool_name,
194
+ "side_effect": side_effect,
195
+ },
196
+ durable=side_effect,
197
+ )
198
+
199
+ def _settle_pending_tool_calls(self, pending: tuple[ScheduledCall, ...]) -> None:
200
+ """Persist cancelled results for tool calls left unanswered on exit.
201
+
202
+ The assistant tool_calls message is persisted before the tools run, so
203
+ bailing out mid-execution (cancellation, budget, error) would otherwise
204
+ leave a dangling tool call that providers reject on the next run.
205
+ """
206
+
207
+ if not pending:
208
+ return
209
+ tool_message = Message(
210
+ Role.TOOL,
211
+ tuple(
212
+ ToolResultBlock(
213
+ call.call_id,
214
+ call.tool_name,
215
+ "Tool call was interrupted before it produced a result.",
216
+ is_error=True,
217
+ )
218
+ for call in pending
219
+ ),
220
+ )
221
+ self.event_bus.session_store.append(
222
+ "conversation_message",
223
+ message_to_dict(tool_message),
224
+ durable=True,
225
+ )
226
+
227
+ async def _on_retry(self, target: ModelTarget, attempt: int, error: WindcodeError) -> None:
228
+ await self.event_bus.publish(
229
+ ModelRetrying(
230
+ **self._common(),
231
+ model=target.model,
232
+ attempt=attempt,
233
+ reason=str(error),
234
+ )
235
+ )
236
+
237
+ async def _on_fallback(
238
+ self,
239
+ source: ModelTarget,
240
+ target: ModelTarget,
241
+ error: WindcodeError,
242
+ ) -> None:
243
+ await self.event_bus.publish(
244
+ ModelFallback(
245
+ **self._common(),
246
+ from_model=source.model,
247
+ to_model=target.model,
248
+ reason=str(error),
249
+ ),
250
+ durable=True,
251
+ )
252
+ await self.event_bus.publish(ModelStarted(**self._common(), model=target.model))
253
+
254
+ async def _terminal_failure(
255
+ self,
256
+ message: str,
257
+ category: str,
258
+ *,
259
+ usage: Usage | None = None,
260
+ ) -> RunResult:
261
+ result = RunResult(status="failed", final_text=message, usage=usage or Usage())
262
+ await self.event_bus.publish(
263
+ RunFailed(**self._common(), message=message, category=category),
264
+ durable=True,
265
+ )
266
+ self.event_bus.session_store.set_status(SessionStatus.FAILED)
267
+ return result
268
+
269
+ async def run(
270
+ self,
271
+ prompt: str,
272
+ workspace: Path,
273
+ initial_messages: tuple[Message, ...] = (),
274
+ ) -> RunResult:
275
+ user_message = Message(Role.USER, (TextBlock(prompt),))
276
+ messages = (*initial_messages, user_message)
277
+ self.event_bus.session_store.append(
278
+ "conversation_message",
279
+ message_to_dict(user_message),
280
+ durable=True,
281
+ )
282
+ records: list[ToolExecutionRecord] = []
283
+ total_usage = Usage()
284
+ final_text = ""
285
+ pending_calls: tuple[ScheduledCall, ...] = ()
286
+ await self.event_bus.publish(RunStarted(**self._common(0), prompt=prompt), durable=True)
287
+ try:
288
+ while True:
289
+ self._turn = self.control.start_model_step()
290
+ primary = self.model_chain[0]
291
+ await self.event_bus.publish(ModelStarted(**self._common(), model=primary.model))
292
+ sourced = (
293
+ () if self.sourced_context_provider is None else self.sourced_context_provider()
294
+ )
295
+ request_messages = (
296
+ *messages,
297
+ *(
298
+ Message(
299
+ Role.SYSTEM,
300
+ (TextBlock(f"[extension source: {item.source_id}]\n{item.content}"),),
301
+ provider_metadata={"extension_source": item.source_id},
302
+ )
303
+ for item in sourced
304
+ ),
305
+ )
306
+ request = ModelRequest(
307
+ model=primary.model,
308
+ messages=request_messages,
309
+ system_prompt=self.system_prompt,
310
+ tools=self.scheduler.registry.schemas(),
311
+ max_output_tokens=self.max_output_tokens,
312
+ )
313
+ if self.token_estimator is not None:
314
+ before = self.token_estimator.estimate(request)
315
+ if before.should_compact or self.control.consume_compaction_request():
316
+ if self.compact_observer is not None:
317
+ await self.compact_observer("before")
318
+ candidate = messages
319
+ if self.artifact_store is not None:
320
+ candidate = truncate_context(
321
+ messages,
322
+ self.artifact_store,
323
+ max_tool_result_chars=self.max_tool_result_chars,
324
+ preserve_recent_turns=self.preserve_recent_turns,
325
+ ).messages
326
+ compacted = await compact_context(
327
+ candidate,
328
+ primary.transport,
329
+ model=primary.model,
330
+ system_prompt=self.system_prompt,
331
+ preserve_recent_turns=self.preserve_recent_turns,
332
+ )
333
+ if compacted.compacted:
334
+ messages = compacted.messages
335
+ request = ModelRequest(
336
+ model=primary.model,
337
+ messages=(*messages, *request_messages[len(messages) :]),
338
+ system_prompt=self.system_prompt,
339
+ tools=self.scheduler.registry.schemas(),
340
+ max_output_tokens=self.max_output_tokens,
341
+ )
342
+ after = self.token_estimator.estimate(request)
343
+ await self.event_bus.publish(
344
+ ContextCompacted(
345
+ **self._common(),
346
+ before_tokens=before.estimated_tokens,
347
+ after_tokens=after.estimated_tokens,
348
+ ),
349
+ durable=True,
350
+ )
351
+ if self.compact_observer is not None:
352
+ await self.compact_observer("after")
353
+ elif self.compact_observer is not None:
354
+ await self.compact_observer("error")
355
+ text_parts: list[str] = []
356
+ call_order: list[str] = []
357
+ calls: dict[str, dict[str, str]] = {}
358
+ last_call_id = ""
359
+ step_usage = Usage()
360
+ async for _target, event in stream_with_retry(
361
+ self.model_chain,
362
+ request,
363
+ on_retry=self._on_retry,
364
+ on_fallback=self._on_fallback,
365
+ ):
366
+ self.control.check()
367
+ if isinstance(event, TextDelta):
368
+ text_parts.append(event.text)
369
+ await self.event_bus.publish(
370
+ TextDeltaEvent(**self._common(), text=event.text)
371
+ )
372
+ elif isinstance(event, ReasoningDelta):
373
+ await self.event_bus.publish(
374
+ ReasoningStatus(**self._common(), status=event.summary)
375
+ )
376
+ elif isinstance(event, ToolCallDelta):
377
+ call_id = event.call_id or last_call_id
378
+ if not call_id:
379
+ call_id = uuid4().hex
380
+ if call_id not in calls:
381
+ calls[call_id] = {"name": event.name, "arguments": ""}
382
+ call_order.append(call_id)
383
+ calls[call_id]["name"] = event.name or calls[call_id]["name"]
384
+ calls[call_id]["arguments"] += event.arguments_delta
385
+ last_call_id = call_id
386
+ elif isinstance(event, ModelUsage):
387
+ step_usage = event.usage
388
+ await self.event_bus.publish(
389
+ UsageUpdated(
390
+ **self._common(), usage=_add_usage(total_usage, step_usage)
391
+ )
392
+ )
393
+ else:
394
+ step_usage = event.usage
395
+
396
+ total_usage = _add_usage(total_usage, step_usage)
397
+ text = "".join(text_parts)
398
+ assistant_content: list[TextBlock | ToolCallBlock] = []
399
+ if text:
400
+ assistant_content.append(TextBlock(text))
401
+ final_text = text
402
+
403
+ scheduled: list[ScheduledCall] = []
404
+ raw_arguments: dict[str, dict[str, Any]] = {}
405
+ for call_id in call_order:
406
+ state = calls[call_id]
407
+ try:
408
+ decoded = json.loads(state["arguments"] or "{}")
409
+ if not isinstance(decoded, Mapping):
410
+ raise ValueError("tool arguments must be an object")
411
+ mapping = cast(Mapping[object, object], decoded)
412
+ arguments = {str(key): value for key, value in mapping.items()}
413
+ except (json.JSONDecodeError, ValueError) as exc:
414
+ arguments = {"_invalid_json": state["arguments"], "_error": str(exc)}
415
+ raw_arguments[call_id] = arguments
416
+ assistant_content.append(ToolCallBlock(call_id, state["name"], arguments))
417
+ scheduled.append(ScheduledCall(call_id, state["name"], arguments))
418
+ assistant_message = Message(Role.ASSISTANT, tuple(assistant_content))
419
+ messages = (*messages, assistant_message)
420
+ self.event_bus.session_store.append(
421
+ "conversation_message",
422
+ message_to_dict(assistant_message),
423
+ durable=True,
424
+ )
425
+
426
+ pending_calls = tuple(scheduled)
427
+
428
+ if not scheduled:
429
+ result = build_run_result(final_text, tuple(records), usage=total_usage)
430
+ if self.completion_observer is not None:
431
+ try:
432
+ await self.completion_observer(result)
433
+ except Exception:
434
+ # Learning is best-effort and must never change task success.
435
+ pass
436
+ await self.event_bus.publish(
437
+ RunCompleted(**self._common(), result=result), durable=True
438
+ )
439
+ self.event_bus.session_store.set_status(SessionStatus.COMPLETED)
440
+ return result
441
+
442
+ self.control.reserve_tool_calls(len(scheduled))
443
+ context = ToolContext(
444
+ workspace=workspace,
445
+ run_id=self.run_id,
446
+ cancelled=lambda: self.control.cancelled,
447
+ request_user=self._request_user,
448
+ )
449
+ results = await self.scheduler.execute(tuple(scheduled), context)
450
+ tool_blocks: list[ToolResultBlock] = []
451
+ for call, scheduled_result in zip(scheduled, results, strict=True):
452
+ result = scheduled_result.result
453
+ self.event_bus.session_store.append(
454
+ "tool_finished",
455
+ {
456
+ "call_id": call.call_id,
457
+ "is_error": result.is_error,
458
+ },
459
+ durable=True,
460
+ )
461
+ await self.event_bus.publish(
462
+ ToolFinished(**self._common(), call_id=call.call_id, result=result),
463
+ durable=True,
464
+ )
465
+ tool_blocks.append(
466
+ ToolResultBlock(
467
+ call.call_id,
468
+ call.tool_name,
469
+ result.output,
470
+ is_error=result.is_error,
471
+ artifact_ref=result.artifact_ref,
472
+ )
473
+ )
474
+ records.append(
475
+ ToolExecutionRecord(call.tool_name, raw_arguments[call.call_id], result)
476
+ )
477
+ tool_message = Message(Role.TOOL, tuple(tool_blocks))
478
+ messages = (*messages, tool_message)
479
+ self.event_bus.session_store.append(
480
+ "conversation_message",
481
+ message_to_dict(tool_message),
482
+ durable=True,
483
+ )
484
+ pending_calls = ()
485
+ except asyncio.CancelledError:
486
+ self._settle_pending_tool_calls(pending_calls)
487
+ self.control.cancel()
488
+ await self.event_bus.publish(RunCancelled(**self._common()), durable=True)
489
+ self.event_bus.session_store.set_status(SessionStatus.CANCELLED)
490
+ return RunResult(status="cancelled", final_text=final_text, usage=total_usage)
491
+ except BudgetExceeded as exc:
492
+ self._settle_pending_tool_calls(pending_calls)
493
+ return await self._terminal_failure(str(exc), "budget", usage=total_usage)
494
+ except AgentBlocked as exc:
495
+ self._settle_pending_tool_calls(pending_calls)
496
+ result = RunResult(status="blocked", final_text=str(exc), usage=total_usage)
497
+ await self.event_bus.publish(
498
+ RunFailed(**self._common(), message=str(exc), category="blocked"), durable=True
499
+ )
500
+ self.event_bus.session_store.set_status(SessionStatus.FAILED)
501
+ return result
502
+ except WindcodeError as exc:
503
+ self._settle_pending_tool_calls(pending_calls)
504
+ return await self._terminal_failure(str(exc), exc.category.value, usage=total_usage)
505
+ except Exception as exc:
506
+ self._settle_pending_tool_calls(pending_calls)
507
+ return await self._terminal_failure(str(exc), "internal", usage=total_usage)
508
+ finally:
509
+ if self.close_event_bus:
510
+ await self.event_bus.close()