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.
- windcode/__init__.py +6 -0
- windcode/__main__.py +4 -0
- windcode/auth/__init__.py +11 -0
- windcode/auth/store.py +90 -0
- windcode/cli.py +181 -0
- windcode/config/__init__.py +41 -0
- windcode/config/loader.py +117 -0
- windcode/config/models.py +203 -0
- windcode/config/writer.py +74 -0
- windcode/context/__init__.py +18 -0
- windcode/context/compactor.py +72 -0
- windcode/context/estimator.py +89 -0
- windcode/context/truncation.py +87 -0
- windcode/domain/__init__.py +1 -0
- windcode/domain/errors.py +33 -0
- windcode/domain/events.py +597 -0
- windcode/domain/messages.py +226 -0
- windcode/domain/models.py +73 -0
- windcode/domain/subagents.py +263 -0
- windcode/domain/tools.py +55 -0
- windcode/extensions/__init__.py +27 -0
- windcode/extensions/commands.py +25 -0
- windcode/extensions/discovery.py +139 -0
- windcode/extensions/events.py +58 -0
- windcode/extensions/hooks/__init__.py +4 -0
- windcode/extensions/hooks/dispatcher.py +107 -0
- windcode/extensions/hooks/executor.py +49 -0
- windcode/extensions/hooks/loader.py +101 -0
- windcode/extensions/hooks/models.py +109 -0
- windcode/extensions/mcp/__init__.py +10 -0
- windcode/extensions/mcp/adapter.py +101 -0
- windcode/extensions/mcp/catalog.py +153 -0
- windcode/extensions/mcp/client.py +273 -0
- windcode/extensions/mcp/runtime.py +144 -0
- windcode/extensions/mcp/tools.py +570 -0
- windcode/extensions/models.py +168 -0
- windcode/extensions/paths.py +71 -0
- windcode/extensions/plugins/__init__.py +3 -0
- windcode/extensions/plugins/installer.py +93 -0
- windcode/extensions/plugins/manifest.py +166 -0
- windcode/extensions/runtime.py +366 -0
- windcode/extensions/service.py +437 -0
- windcode/extensions/skills/__init__.py +3 -0
- windcode/extensions/skills/loader.py +67 -0
- windcode/extensions/skills/parser.py +57 -0
- windcode/extensions/skills/tools.py +213 -0
- windcode/extensions/snapshot.py +57 -0
- windcode/extensions/state.py +158 -0
- windcode/instructions/__init__.py +8 -0
- windcode/instructions/loader.py +50 -0
- windcode/memory/__init__.py +57 -0
- windcode/memory/extraction.py +83 -0
- windcode/memory/models.py +218 -0
- windcode/memory/refiner.py +189 -0
- windcode/memory/security.py +23 -0
- windcode/memory/service.py +179 -0
- windcode/memory/store.py +362 -0
- windcode/observability/__init__.py +4 -0
- windcode/observability/redaction.py +95 -0
- windcode/observability/trace.py +115 -0
- windcode/policy/__init__.py +22 -0
- windcode/policy/engine.py +137 -0
- windcode/policy/models.py +69 -0
- windcode/providers/__init__.py +29 -0
- windcode/providers/_utils.py +19 -0
- windcode/providers/anthropic.py +215 -0
- windcode/providers/base.py +46 -0
- windcode/providers/catalog.py +119 -0
- windcode/providers/errors.py +96 -0
- windcode/providers/openai_compat.py +231 -0
- windcode/providers/openai_responses.py +189 -0
- windcode/providers/registry.py +105 -0
- windcode/runtime/__init__.py +15 -0
- windcode/runtime/control.py +89 -0
- windcode/runtime/event_bus.py +67 -0
- windcode/runtime/loop.py +510 -0
- windcode/runtime/prompts.py +132 -0
- windcode/runtime/report.py +64 -0
- windcode/runtime/retry.py +73 -0
- windcode/runtime/scheduler.py +194 -0
- windcode/runtime/subagents/__init__.py +28 -0
- windcode/runtime/subagents/approvals.py +88 -0
- windcode/runtime/subagents/budgets.py +79 -0
- windcode/runtime/subagents/coordinator.py +714 -0
- windcode/runtime/subagents/factory.py +311 -0
- windcode/runtime/subagents/roles.py +74 -0
- windcode/runtime/subagents/verification.py +53 -0
- windcode/sandbox/__init__.py +3 -0
- windcode/sandbox/bwrap.py +79 -0
- windcode/sdk.py +1259 -0
- windcode/sessions/__init__.py +23 -0
- windcode/sessions/artifacts.py +69 -0
- windcode/sessions/models.py +104 -0
- windcode/sessions/store.py +167 -0
- windcode/sessions/tree.py +35 -0
- windcode/tools/__init__.py +10 -0
- windcode/tools/apply_patch.py +191 -0
- windcode/tools/ask_user.py +58 -0
- windcode/tools/builtins.py +44 -0
- windcode/tools/edit_file.py +54 -0
- windcode/tools/filesystem.py +77 -0
- windcode/tools/glob.py +35 -0
- windcode/tools/grep.py +66 -0
- windcode/tools/memory.py +400 -0
- windcode/tools/read_file.py +54 -0
- windcode/tools/registry.py +120 -0
- windcode/tools/shell.py +159 -0
- windcode/tools/subagents/__init__.py +31 -0
- windcode/tools/subagents/cancel.py +35 -0
- windcode/tools/subagents/integrate.py +54 -0
- windcode/tools/subagents/list.py +46 -0
- windcode/tools/subagents/spawn.py +86 -0
- windcode/tools/subagents/wait.py +74 -0
- windcode/tools/write_file.py +61 -0
- windcode/tui/__init__.py +3 -0
- windcode/tui/app.py +1015 -0
- windcode/tui/commands.py +90 -0
- windcode/tui/permission_display.py +24 -0
- windcode/tui/styles.tcss +591 -0
- windcode/tui/widgets/__init__.py +31 -0
- windcode/tui/widgets/approval.py +98 -0
- windcode/tui/widgets/command_menu.py +90 -0
- windcode/tui/widgets/extensions.py +48 -0
- windcode/tui/widgets/input.py +110 -0
- windcode/tui/widgets/memory.py +143 -0
- windcode/tui/widgets/messages.py +299 -0
- windcode/tui/widgets/models.py +565 -0
- windcode/tui/widgets/question.py +46 -0
- windcode/tui/widgets/sessions.py +68 -0
- windcode/tui/widgets/status.py +46 -0
- windcode/tui/widgets/subagents.py +195 -0
- windcode/tui/widgets/tools.py +66 -0
- windcode/tui/widgets/welcome.py +149 -0
- windcode/types.py +80 -0
- windcode/worktrees/__init__.py +24 -0
- windcode/worktrees/git.py +92 -0
- windcode/worktrees/manager.py +265 -0
- windcode/worktrees/models.py +62 -0
- windcode-0.1.0.dist-info/METADATA +207 -0
- windcode-0.1.0.dist-info/RECORD +143 -0
- windcode-0.1.0.dist-info/WHEEL +4 -0
- windcode-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|
windcode/runtime/loop.py
ADDED
|
@@ -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()
|