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,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from windcode.config import AppConfig, PermissionMode
|
|
10
|
+
from windcode.context import TokenEstimator
|
|
11
|
+
from windcode.domain.subagents import SubagentRecord, SubagentTaskKind
|
|
12
|
+
from windcode.domain.tools import ToolContext
|
|
13
|
+
from windcode.extensions import ExtensionSnapshot
|
|
14
|
+
from windcode.extensions.events import extension_event
|
|
15
|
+
from windcode.extensions.skills.loader import SkillLoader
|
|
16
|
+
from windcode.extensions.skills.tools import (
|
|
17
|
+
SkillActivationResult,
|
|
18
|
+
SkillCatalog,
|
|
19
|
+
SkillRuntime,
|
|
20
|
+
register_skill_tools,
|
|
21
|
+
)
|
|
22
|
+
from windcode.instructions import load_instructions
|
|
23
|
+
from windcode.observability import TraceStore
|
|
24
|
+
from windcode.policy import ApprovalChoice, PolicyDecision, PolicyEngine, PolicyRequest
|
|
25
|
+
from windcode.providers import ModelTarget
|
|
26
|
+
from windcode.runtime.control import BudgetExceeded, RunBudgets, RunControl
|
|
27
|
+
from windcode.runtime.event_bus import EventBus
|
|
28
|
+
from windcode.runtime.loop import AgentBlocked, AgentLoop
|
|
29
|
+
from windcode.runtime.prompts import build_system_prompt
|
|
30
|
+
from windcode.runtime.scheduler import ScheduledCall, ScheduledResult, ToolScheduler
|
|
31
|
+
from windcode.runtime.subagents.approvals import ApprovalRouter
|
|
32
|
+
from windcode.runtime.subagents.budgets import AggregateBudget, AggregateBudgetExceeded
|
|
33
|
+
from windcode.runtime.subagents.roles import ROLE_POLICIES, resolve_role_tools
|
|
34
|
+
from windcode.sandbox import BubblewrapSandbox, detect_bubblewrap
|
|
35
|
+
from windcode.sessions import ArtifactStore, SessionStore
|
|
36
|
+
from windcode.tools import ToolRegistry
|
|
37
|
+
from windcode.tools.shell import ShellTool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git_common_directory(workspace: Path) -> Path | None:
|
|
41
|
+
marker = workspace / ".git"
|
|
42
|
+
if not marker.is_file():
|
|
43
|
+
return marker.resolve() if marker.is_dir() else None
|
|
44
|
+
content = marker.read_text(encoding="utf-8").strip()
|
|
45
|
+
if not content.startswith("gitdir: "):
|
|
46
|
+
return None
|
|
47
|
+
git_directory = Path(content.removeprefix("gitdir: "))
|
|
48
|
+
if not git_directory.is_absolute():
|
|
49
|
+
git_directory = workspace / git_directory
|
|
50
|
+
git_directory = git_directory.resolve()
|
|
51
|
+
common_marker = git_directory / "commondir"
|
|
52
|
+
if not common_marker.is_file():
|
|
53
|
+
return git_directory
|
|
54
|
+
common = Path(common_marker.read_text(encoding="utf-8").strip())
|
|
55
|
+
return (git_directory / common).resolve()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AggregateRunControl(RunControl):
|
|
59
|
+
def __init__(self, budgets: RunBudgets, aggregate: AggregateBudget) -> None:
|
|
60
|
+
super().__init__(budgets)
|
|
61
|
+
self.aggregate = aggregate
|
|
62
|
+
|
|
63
|
+
def check(self) -> None:
|
|
64
|
+
super().check()
|
|
65
|
+
try:
|
|
66
|
+
self.aggregate.check_runtime_nowait()
|
|
67
|
+
except AggregateBudgetExceeded as exc:
|
|
68
|
+
raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
|
|
69
|
+
|
|
70
|
+
def start_model_step(self) -> int:
|
|
71
|
+
try:
|
|
72
|
+
self.aggregate.consume_model_step_nowait()
|
|
73
|
+
except AggregateBudgetExceeded as exc:
|
|
74
|
+
raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
|
|
75
|
+
return super().start_model_step()
|
|
76
|
+
|
|
77
|
+
def reserve_tool_calls(self, count: int) -> None:
|
|
78
|
+
try:
|
|
79
|
+
self.aggregate.consume_tool_calls_nowait(count)
|
|
80
|
+
except AggregateBudgetExceeded as exc:
|
|
81
|
+
raise BudgetExceeded(f"aggregate_{exc.budget}") from exc
|
|
82
|
+
super().reserve_tool_calls(count)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ChildToolScheduler(ToolScheduler):
|
|
86
|
+
async def execute(
|
|
87
|
+
self,
|
|
88
|
+
calls: tuple[ScheduledCall, ...],
|
|
89
|
+
context: ToolContext,
|
|
90
|
+
) -> tuple[ScheduledResult, ...]:
|
|
91
|
+
if any(call.tool_name == "ask_user" for call in calls):
|
|
92
|
+
raise AgentBlocked("subagents cannot ask the user directly; clarification is required")
|
|
93
|
+
return await super().execute(calls, context)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ChildAgentLoop(AgentLoop):
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
record: SubagentRecord,
|
|
101
|
+
approval_router: ApprovalRouter,
|
|
102
|
+
**kwargs: Any,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.subagent_record = record
|
|
105
|
+
self.approval_router = approval_router
|
|
106
|
+
super().__init__(**kwargs)
|
|
107
|
+
|
|
108
|
+
async def _approval_handler(
|
|
109
|
+
self,
|
|
110
|
+
request: PolicyRequest,
|
|
111
|
+
decision: PolicyDecision,
|
|
112
|
+
) -> ApprovalChoice:
|
|
113
|
+
return await self.approval_router.request(
|
|
114
|
+
self.subagent_record.subagent_id,
|
|
115
|
+
self.subagent_record.spec.role,
|
|
116
|
+
request,
|
|
117
|
+
decision,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def _request_user(self, payload: object) -> object:
|
|
121
|
+
del payload
|
|
122
|
+
raise AgentBlocked("subagents cannot ask the user directly; clarification is required")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(slots=True)
|
|
126
|
+
class ChildRuntime:
|
|
127
|
+
record: SubagentRecord
|
|
128
|
+
control: RunControl
|
|
129
|
+
event_bus: EventBus
|
|
130
|
+
loop: AgentLoop
|
|
131
|
+
workspace: Path
|
|
132
|
+
prompt: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_child_prompt(record: SubagentRecord) -> str:
|
|
136
|
+
spec = record.spec
|
|
137
|
+
verification = "\n".join(f"- {item}" for item in spec.verification)
|
|
138
|
+
return (
|
|
139
|
+
f"Task: {spec.task_name}\n"
|
|
140
|
+
f"Goal: {spec.goal}\n\n"
|
|
141
|
+
f"Context:\n{spec.context}\n\n"
|
|
142
|
+
f"Expected output:\n{spec.expected_output}\n\n"
|
|
143
|
+
f"Verification requirements:\n{verification}\n\n"
|
|
144
|
+
"Complete only this task. Do not delegate and do not ask the user questions."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ChildRuntimeFactory:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
config: AppConfig,
|
|
153
|
+
state_root: Path,
|
|
154
|
+
parent_tools: ToolRegistry,
|
|
155
|
+
model_chain: Callable[[str | None], tuple[ModelTarget, ...]],
|
|
156
|
+
extension_snapshot: ExtensionSnapshot | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self.config = config
|
|
159
|
+
self.state_root = state_root
|
|
160
|
+
self.parent_tools = parent_tools
|
|
161
|
+
self.model_chain = model_chain
|
|
162
|
+
self.extension_snapshot = extension_snapshot or ExtensionSnapshot(0, "empty")
|
|
163
|
+
|
|
164
|
+
def create(
|
|
165
|
+
self,
|
|
166
|
+
record: SubagentRecord,
|
|
167
|
+
*,
|
|
168
|
+
workspace: Path,
|
|
169
|
+
parent_permission: PermissionMode,
|
|
170
|
+
aggregate_budget: AggregateBudget,
|
|
171
|
+
approval_router: ApprovalRouter,
|
|
172
|
+
) -> ChildRuntime:
|
|
173
|
+
spec = record.spec
|
|
174
|
+
policy = ROLE_POLICIES[spec.role]
|
|
175
|
+
names = resolve_role_tools(
|
|
176
|
+
spec.role,
|
|
177
|
+
spec.kind,
|
|
178
|
+
frozenset(self.parent_tools.names()),
|
|
179
|
+
spec.allowed_tools,
|
|
180
|
+
)
|
|
181
|
+
registry = ToolRegistry()
|
|
182
|
+
for name in self.parent_tools.names():
|
|
183
|
+
if name in names and name != "ask_user" and not name.endswith("_subagent"):
|
|
184
|
+
registry.register(self.parent_tools.get(name))
|
|
185
|
+
|
|
186
|
+
sandbox_status = detect_bubblewrap()
|
|
187
|
+
git_common = (
|
|
188
|
+
_git_common_directory(workspace) if spec.kind is SubagentTaskKind.WRITE else None
|
|
189
|
+
)
|
|
190
|
+
sandbox = (
|
|
191
|
+
BubblewrapSandbox(
|
|
192
|
+
workspace,
|
|
193
|
+
sandbox_status,
|
|
194
|
+
read_only_workspace=spec.kind is SubagentTaskKind.READ,
|
|
195
|
+
writable_paths=() if git_common is None else (git_common,),
|
|
196
|
+
)
|
|
197
|
+
if self.config.sandbox.enabled and sandbox_status.available
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
if "shell" in registry.names():
|
|
201
|
+
registry.register(
|
|
202
|
+
ShellTool(
|
|
203
|
+
sandbox=sandbox,
|
|
204
|
+
default_timeout=self.config.budgets.shell_timeout_seconds,
|
|
205
|
+
),
|
|
206
|
+
replace=True,
|
|
207
|
+
)
|
|
208
|
+
effective_permission = parent_permission
|
|
209
|
+
if spec.kind is SubagentTaskKind.READ and sandbox is None:
|
|
210
|
+
effective_permission = PermissionMode.PLAN
|
|
211
|
+
|
|
212
|
+
child_session_id = record.child_session_id or uuid4().hex
|
|
213
|
+
child_record = replace(record, child_session_id=child_session_id)
|
|
214
|
+
session = SessionStore.create(self.state_root / "sessions", child_session_id)
|
|
215
|
+
child_run_id = uuid4().hex
|
|
216
|
+
event_bus = EventBus(
|
|
217
|
+
session,
|
|
218
|
+
TraceStore(
|
|
219
|
+
child_run_id,
|
|
220
|
+
root=self.state_root / "traces",
|
|
221
|
+
enabled=self.config.trace.enabled,
|
|
222
|
+
include_tool_arguments=self.config.trace.include_tool_arguments,
|
|
223
|
+
include_transient_events=self.config.trace.include_transient_events,
|
|
224
|
+
retention_days=self.config.trace.retention_days,
|
|
225
|
+
max_total_mb=self.config.trace.max_total_mb,
|
|
226
|
+
),
|
|
227
|
+
)
|
|
228
|
+
skill_runtime = SkillRuntime(
|
|
229
|
+
SkillCatalog(
|
|
230
|
+
self.extension_snapshot,
|
|
231
|
+
SkillLoader(max_content_bytes=self.config.extensions.max_content_bytes),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def activate_skill(selector: str) -> SkillActivationResult:
|
|
236
|
+
result = skill_runtime.activate(selector)
|
|
237
|
+
await event_bus.publish(
|
|
238
|
+
extension_event(
|
|
239
|
+
event_id=uuid4().hex,
|
|
240
|
+
session_id=child_session_id,
|
|
241
|
+
run_id=child_run_id,
|
|
242
|
+
turn=0,
|
|
243
|
+
action="skill_loaded",
|
|
244
|
+
snapshot_generation=self.extension_snapshot.generation,
|
|
245
|
+
extension_id=result.name,
|
|
246
|
+
source_id=result.source_id,
|
|
247
|
+
status="loaded" if result.loaded else "already_loaded",
|
|
248
|
+
),
|
|
249
|
+
durable=True,
|
|
250
|
+
)
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
if {"search_skills", "load_skill"} <= names:
|
|
254
|
+
register_skill_tools(registry, skill_runtime, activate_skill, replace=True)
|
|
255
|
+
scheduler = ChildToolScheduler(
|
|
256
|
+
registry,
|
|
257
|
+
PolicyEngine(
|
|
258
|
+
effective_permission,
|
|
259
|
+
sandbox_enabled=self.config.sandbox.enabled,
|
|
260
|
+
sandbox_available=sandbox_status.available,
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
budgets = RunBudgets(
|
|
264
|
+
max_model_steps=self.config.subagents.max_model_steps,
|
|
265
|
+
max_tool_calls=self.config.subagents.max_tool_calls,
|
|
266
|
+
max_runtime_seconds=self.config.subagents.max_runtime_seconds,
|
|
267
|
+
)
|
|
268
|
+
control = AggregateRunControl(budgets, aggregate_budget)
|
|
269
|
+
instructions = load_instructions(workspace, workspace_root=workspace)
|
|
270
|
+
system_prompt = build_system_prompt(
|
|
271
|
+
workspace=workspace,
|
|
272
|
+
permission_mode=effective_permission,
|
|
273
|
+
instructions=instructions,
|
|
274
|
+
tools=registry,
|
|
275
|
+
is_subagent=True,
|
|
276
|
+
skills=(skill_runtime.search() if "load_skill" in registry.names() else ()),
|
|
277
|
+
mcp_direct_servers=tuple(
|
|
278
|
+
name.split("__", 1)[0] for name in registry.names() if "__" in name
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
system_prompt += (
|
|
282
|
+
f"\n\n## Temporary subagent role\n{policy.system_instructions}\n"
|
|
283
|
+
"You are a temporary child agent. You cannot delegate or directly ask the user."
|
|
284
|
+
)
|
|
285
|
+
loop = ChildAgentLoop(
|
|
286
|
+
record=child_record,
|
|
287
|
+
approval_router=approval_router,
|
|
288
|
+
session_id=child_session_id,
|
|
289
|
+
run_id=child_run_id,
|
|
290
|
+
model_chain=self.model_chain(spec.model),
|
|
291
|
+
scheduler=scheduler,
|
|
292
|
+
control=control,
|
|
293
|
+
event_bus=event_bus,
|
|
294
|
+
system_prompt=system_prompt,
|
|
295
|
+
token_estimator=TokenEstimator(
|
|
296
|
+
self.config.context.window_tokens,
|
|
297
|
+
compaction_threshold=self.config.context.compaction_threshold,
|
|
298
|
+
),
|
|
299
|
+
artifact_store=ArtifactStore(session.session_dir),
|
|
300
|
+
preserve_recent_turns=self.config.context.preserve_recent_turns,
|
|
301
|
+
max_tool_result_chars=self.config.context.max_tool_result_chars,
|
|
302
|
+
sourced_context_provider=skill_runtime.drain_context,
|
|
303
|
+
)
|
|
304
|
+
return ChildRuntime(
|
|
305
|
+
child_record,
|
|
306
|
+
control,
|
|
307
|
+
event_bus,
|
|
308
|
+
loop,
|
|
309
|
+
workspace,
|
|
310
|
+
build_child_prompt(child_record),
|
|
311
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from windcode.domain.subagents import SubagentRole, SubagentTaskKind
|
|
6
|
+
|
|
7
|
+
_READ_TOOLS = frozenset({"read_file", "glob", "grep", "shell", "search_skills", "load_skill"})
|
|
8
|
+
_WRITE_TOOLS = frozenset(
|
|
9
|
+
{
|
|
10
|
+
"read_file",
|
|
11
|
+
"write_file",
|
|
12
|
+
"edit_file",
|
|
13
|
+
"apply_patch",
|
|
14
|
+
"glob",
|
|
15
|
+
"grep",
|
|
16
|
+
"shell",
|
|
17
|
+
"search_skills",
|
|
18
|
+
"load_skill",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RolePolicy:
|
|
25
|
+
role: SubagentRole
|
|
26
|
+
default_tools: frozenset[str]
|
|
27
|
+
allowed_kinds: frozenset[SubagentTaskKind]
|
|
28
|
+
system_instructions: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ROLE_POLICIES: dict[SubagentRole, RolePolicy] = {
|
|
32
|
+
SubagentRole.RESEARCHER: RolePolicy(
|
|
33
|
+
role=SubagentRole.RESEARCHER,
|
|
34
|
+
default_tools=_READ_TOOLS,
|
|
35
|
+
allowed_kinds=frozenset({SubagentTaskKind.READ}),
|
|
36
|
+
system_instructions="Explore the workspace and return evidence without modifying files.",
|
|
37
|
+
),
|
|
38
|
+
SubagentRole.WORKER: RolePolicy(
|
|
39
|
+
role=SubagentRole.WORKER,
|
|
40
|
+
default_tools=_WRITE_TOOLS,
|
|
41
|
+
allowed_kinds=frozenset({SubagentTaskKind.READ, SubagentTaskKind.WRITE}),
|
|
42
|
+
system_instructions="Complete the assigned task and run the requested verification.",
|
|
43
|
+
),
|
|
44
|
+
SubagentRole.VERIFIER: RolePolicy(
|
|
45
|
+
role=SubagentRole.VERIFIER,
|
|
46
|
+
default_tools=_READ_TOOLS,
|
|
47
|
+
allowed_kinds=frozenset({SubagentTaskKind.READ}),
|
|
48
|
+
system_instructions="Independently verify the requested behavior without modifying files.",
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_role_tools(
|
|
54
|
+
role: SubagentRole,
|
|
55
|
+
kind: SubagentTaskKind,
|
|
56
|
+
parent_tools: frozenset[str],
|
|
57
|
+
requested_tools: frozenset[str] | None = None,
|
|
58
|
+
) -> frozenset[str]:
|
|
59
|
+
policy = ROLE_POLICIES[role]
|
|
60
|
+
if kind not in policy.allowed_kinds:
|
|
61
|
+
raise ValueError(f"role {role.value} does not allow {kind.value} tasks")
|
|
62
|
+
network_tools = frozenset(
|
|
63
|
+
name
|
|
64
|
+
for name in parent_tools
|
|
65
|
+
if "__" in name
|
|
66
|
+
or name in {"list_mcp_servers", "search_mcp_tools", "read_mcp_resource", "get_mcp_prompt"}
|
|
67
|
+
)
|
|
68
|
+
allowed_tools = policy.default_tools | network_tools
|
|
69
|
+
if requested_tools is not None:
|
|
70
|
+
unknown = requested_tools - allowed_tools
|
|
71
|
+
if unknown:
|
|
72
|
+
raise ValueError(f"requested tools exceed role policy: {', '.join(sorted(unknown))}")
|
|
73
|
+
selected = allowed_tools if requested_tools is None else requested_tools
|
|
74
|
+
return selected & parent_tools
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import Callable, Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from windcode.domain.subagents import VerificationResult
|
|
8
|
+
from windcode.domain.tools import ToolContext
|
|
9
|
+
from windcode.sandbox import BubblewrapSandbox
|
|
10
|
+
from windcode.tools.shell import ShellInput, ShellTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve(path: Path) -> Path:
|
|
14
|
+
return path.expanduser().resolve()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VerificationRunner:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
sandbox: BubblewrapSandbox | None = None,
|
|
22
|
+
timeout_seconds: float = 120.0,
|
|
23
|
+
output_limit: int = 20_000,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.shell = ShellTool(
|
|
26
|
+
sandbox=sandbox,
|
|
27
|
+
default_timeout=timeout_seconds,
|
|
28
|
+
output_limit=output_limit,
|
|
29
|
+
)
|
|
30
|
+
self.output_limit = output_limit
|
|
31
|
+
|
|
32
|
+
async def run(
|
|
33
|
+
self,
|
|
34
|
+
commands: Sequence[str],
|
|
35
|
+
*,
|
|
36
|
+
workspace: Path,
|
|
37
|
+
run_id: str,
|
|
38
|
+
cancelled: Callable[[], bool] = lambda: False,
|
|
39
|
+
) -> tuple[VerificationResult, ...]:
|
|
40
|
+
results: list[VerificationResult] = []
|
|
41
|
+
context = ToolContext(_resolve(workspace), run_id, cancelled)
|
|
42
|
+
for command in commands:
|
|
43
|
+
if cancelled():
|
|
44
|
+
raise asyncio.CancelledError
|
|
45
|
+
tool_result = await self.shell.execute(context, ShellInput(command=command))
|
|
46
|
+
exit_code_value = tool_result.data.get("exit_code")
|
|
47
|
+
exit_code = exit_code_value if isinstance(exit_code_value, int) else None
|
|
48
|
+
summary = tool_result.output[: self.output_limit]
|
|
49
|
+
result = VerificationResult(command, exit_code, summary, not tool_result.is_error)
|
|
50
|
+
results.append(result)
|
|
51
|
+
if not result.passed:
|
|
52
|
+
break
|
|
53
|
+
return tuple(results)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class SandboxStatus:
|
|
10
|
+
available: bool
|
|
11
|
+
executable: Path | None
|
|
12
|
+
warning: str | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_bubblewrap(executable: str = "bwrap") -> SandboxStatus:
|
|
16
|
+
located = shutil.which(executable)
|
|
17
|
+
if located is None:
|
|
18
|
+
return SandboxStatus(
|
|
19
|
+
available=False,
|
|
20
|
+
executable=None,
|
|
21
|
+
warning="bubblewrap is unavailable; shell commands require elevated approval",
|
|
22
|
+
)
|
|
23
|
+
return SandboxStatus(available=True, executable=Path(located).resolve())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BubblewrapSandbox:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
workspace: Path,
|
|
30
|
+
status: SandboxStatus | None = None,
|
|
31
|
+
*,
|
|
32
|
+
read_only_workspace: bool = False,
|
|
33
|
+
writable_paths: tuple[Path, ...] = (),
|
|
34
|
+
) -> None:
|
|
35
|
+
self.workspace = workspace.expanduser().resolve()
|
|
36
|
+
self.status = status or detect_bubblewrap()
|
|
37
|
+
self.read_only_workspace = read_only_workspace
|
|
38
|
+
self.writable_paths = tuple(path.expanduser().resolve() for path in writable_paths)
|
|
39
|
+
|
|
40
|
+
def wrap(
|
|
41
|
+
self,
|
|
42
|
+
command: tuple[str, ...],
|
|
43
|
+
*,
|
|
44
|
+
cwd: Path | None = None,
|
|
45
|
+
allow_network: bool = False,
|
|
46
|
+
) -> tuple[str, ...]:
|
|
47
|
+
if not self.status.available or self.status.executable is None:
|
|
48
|
+
raise RuntimeError(self.status.warning or "bubblewrap is unavailable")
|
|
49
|
+
working_directory = (cwd or self.workspace).resolve()
|
|
50
|
+
if not working_directory.is_relative_to(self.workspace):
|
|
51
|
+
raise ValueError("sandbox working directory must be inside the workspace")
|
|
52
|
+
temporary_mount = [] if self.workspace.is_relative_to(Path("/tmp")) else ["--tmpfs", "/tmp"]
|
|
53
|
+
workspace_mount = (
|
|
54
|
+
[] if self.read_only_workspace else ["--bind", str(self.workspace), str(self.workspace)]
|
|
55
|
+
)
|
|
56
|
+
additional_mounts = [
|
|
57
|
+
item for path in self.writable_paths for item in ("--bind", str(path), str(path))
|
|
58
|
+
]
|
|
59
|
+
arguments = [
|
|
60
|
+
str(self.status.executable),
|
|
61
|
+
"--die-with-parent",
|
|
62
|
+
"--new-session",
|
|
63
|
+
"--ro-bind",
|
|
64
|
+
"/",
|
|
65
|
+
"/",
|
|
66
|
+
*workspace_mount,
|
|
67
|
+
*additional_mounts,
|
|
68
|
+
*temporary_mount,
|
|
69
|
+
"--proc",
|
|
70
|
+
"/proc",
|
|
71
|
+
"--dev",
|
|
72
|
+
"/dev",
|
|
73
|
+
"--chdir",
|
|
74
|
+
str(working_directory),
|
|
75
|
+
]
|
|
76
|
+
if not allow_network:
|
|
77
|
+
arguments.append("--unshare-net")
|
|
78
|
+
arguments.extend(("--", *command))
|
|
79
|
+
return tuple(arguments)
|