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,132 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from windcode.config import DelegationMode, PermissionMode
7
+ from windcode.instructions import InstructionBlock
8
+ from windcode.tools import ToolRegistry
9
+
10
+ if TYPE_CHECKING:
11
+ from windcode.extensions.skills.tools import SkillSearchResult
12
+
13
+
14
+ def build_system_prompt(
15
+ *,
16
+ workspace: Path,
17
+ permission_mode: PermissionMode,
18
+ instructions: tuple[InstructionBlock, ...],
19
+ tools: ToolRegistry,
20
+ delegation_mode: DelegationMode | None = None,
21
+ is_subagent: bool = False,
22
+ skills: tuple[SkillSearchResult, ...] = (),
23
+ mcp_direct_servers: tuple[str, ...] = (),
24
+ mcp_search_servers: tuple[str, ...] = (),
25
+ mcp_unavailable_servers: tuple[tuple[str, str], ...] = (),
26
+ memory_enabled: bool = False,
27
+ ) -> str:
28
+ tool_lines = "\n".join(f"- {schema.name}: {schema.description}" for schema in tools.schemas())
29
+ instruction_sections = "\n\n".join(
30
+ f"### {block.path}\n{block.content.rstrip()}" for block in instructions
31
+ )
32
+ extension_sections = ""
33
+ if skills:
34
+ skill_lines = "\n".join(
35
+ f"- ${item.name}: {item.description} [source: {item.source_id}]" for item in skills
36
+ )
37
+ extension_sections += (
38
+ f"\n\n## Agent Skills\n{skill_lines}\n"
39
+ "需要某个明确匹配的 Skill 时直接调用 load_skill, 不确定时最多调用一次 "
40
+ "search_skills 后选择准确名称加载。不得尝试加载列表之外、未启用或未信任的 Skill; "
41
+ "同一 Skill 不得重复加载。用户以 $name 显式选择的 Skill 已由运行时加载, "
42
+ "无需再次调用 load_skill。"
43
+ )
44
+ if mcp_direct_servers:
45
+ server_lines = "\n".join(f"- {server_id}" for server_id in sorted(mcp_direct_servers))
46
+ extension_sections += (
47
+ f"\n\n## MCP 服务器 (工具已直接可用)\n{server_lines}\n"
48
+ "这些服务器的工具已列在上方“可用工具”中, 直接按名称调用即可, "
49
+ "无需再调用 search_mcp_tools 搜索或启用。"
50
+ )
51
+ if mcp_search_servers:
52
+ server_lines = "\n".join(f"- {server_id}" for server_id in sorted(mcp_search_servers))
53
+ extension_sections += (
54
+ f"\n\n## MCP 服务器 (按需启用)\n{server_lines}\n"
55
+ "先检查上方可用工具; 已存在目标 MCP 工具时必须直接调用, 不得重复搜索。"
56
+ "目标尚不可用时调用一次 search_mcp_tools 并传关键词; 唯一匹配会自动启用并返回 "
57
+ "call_name, 随后直接调用它。只有返回多个匹配时, 才再调用一次 "
58
+ "search_mcp_tools(query='select:<id>') 选择目标。已启用工具会在后续运行中复用。"
59
+ )
60
+ if mcp_unavailable_servers:
61
+ server_lines = "\n".join(
62
+ f"- {server_id}: {reason}" for server_id, reason in sorted(mcp_unavailable_servers)
63
+ )
64
+ extension_sections += (
65
+ f"\n\n## MCP 服务器 (本次运行不可用)\n{server_lines}\n"
66
+ "这些服务器已经配置, 因此不得声称 Windcode 没有 MCP 集成。"
67
+ "它们当前不能调用; 可使用 list_mcp_servers 核实状态, 并向用户准确说明原因。"
68
+ )
69
+ delegation = ""
70
+ if is_subagent:
71
+ delegation = (
72
+ "\n\n## 子智能体约束\n"
73
+ "你是单层临时子智能体, 只执行收到的自包含任务; 不得继续委派, 也不得直接询问用户。"
74
+ )
75
+ elif delegation_mode is DelegationMode.EXPLICIT:
76
+ delegation = (
77
+ "\n\n## 委派策略: explicit\n"
78
+ "仅当用户明确要求委派、并行或使用子智能体时, 才可调用子智能体工具。"
79
+ "创建后调用 wait_subagents 一次等待结果; 禁止循环调用 list_subagents。"
80
+ "子智能体可按运行网络策略和权限审批访问外部网络。"
81
+ )
82
+ elif delegation_mode is DelegationMode.PROACTIVE:
83
+ delegation = (
84
+ "\n\n## 委派策略: proactive\n"
85
+ "可在任务确实独立且适合并行时主动委派; 必须保持任务有界、状态可见并统一汇总。"
86
+ "创建后调用 wait_subagents 一次等待结果; 禁止循环调用 list_subagents。"
87
+ "子智能体可按运行网络策略和权限审批访问外部网络。"
88
+ )
89
+ if memory_enabled:
90
+ memory_policy = (
91
+ "\n\n## 长期记忆主动查询\n"
92
+ "当用户明确要求记住、写入长期记忆或表达等价意图时, 必须调用 "
93
+ "memory_write。只有工具返回 stored 或 already_exists 后才能声称已经记住; "
94
+ "工具失败、未调用或仅计划保存时不得声称写入成功。不得擅自保存普通对话、"
95
+ "临时状态、推测或工具输出。"
96
+ "当用户明确要求查看、列出、搜索、核对或回忆长期记忆时, 必须调用 memory_list、"
97
+ "memory_search 或 memory_get, 并基于工具实际结果回答。宽泛的‘看看长期记忆’调用 " # noqa: RUF001
98
+ "memory_list; 带主题的请求调用 memory_search; 查看单条详情调用 memory_get。"
99
+ "memory_list 和 memory_search 默认同时返回 active 与 candidate; 回答时必须明确区分"
100
+ "已生效记忆和待确认候选, 不得因候选未注入自动上下文而声称记录不存在。"
101
+ "不得使用 glob、grep、read_file、shell 或其他工作区工具代替长期记忆查询。"
102
+ "自动注入的记忆只用于当前任务上下文, 不能冒充主动查询结果。"
103
+ "搜索无结果时直接说明没有匹配记忆, 不得转而扫描仓库。"
104
+ )
105
+ else:
106
+ memory_policy = (
107
+ "\n\n## 长期记忆主动查询\n"
108
+ "本次运行未启用长期记忆工具。用户要求查看长期记忆时应准确说明长期记忆已禁用或"
109
+ "不可用, 不得搜索工作区文件来代替记忆查询。"
110
+ )
111
+ return (
112
+ "你是 Windcode, 在终端中帮助用户完成软件工程任务的本地编码 Agent.\n"
113
+ "最终面向用户的回复可使用 GitHub 风格 Markdown。仅在有助于阅读时使用标题、"
114
+ "列表、表格、引用、强调、行内代码和围栏代码块; 保持结构克制且内容简洁。\n"
115
+ "先判断用户是否提出了明确且需要项目上下文的编码任务. "
116
+ "对于问候、闲聊、一般知识问题或不涉及当前项目的问题, 直接回答, "
117
+ "不得读取文件、执行命令、搜索或以任何方式检查工作区.\n"
118
+ "只有在用户明确请求的任务确实需要项目上下文时才使用工具; "
119
+ "不要为了了解项目而主动勘察工作区. 如果任务意图不明确, 先向用户提一个简短问题.\n"
120
+ "明确的编码任务一旦开始, 持续执行到任务完成、取消、预算耗尽或不可恢复错误.\n"
121
+ "所有工具错误都应作为结果处理; 不要虚构文件内容、命令结果或测试通过状态.\n"
122
+ "完成时必须基于实际工具记录汇总文件变化、验证命令、退出码和失败项.\n\n"
123
+ "不要输出任何的emoji、表情符号或非 ASCII 字符"
124
+ f"工作区: {workspace.resolve()}\n"
125
+ f"权限模式: {permission_mode.value}. 模型不得自行扩大权限或切换模式.\n\n"
126
+ f"## 可用工具\n{tool_lines or '- 无'}\n\n"
127
+ f"## 项目指令 (按根目录到当前目录排列, 后者优先)\n"
128
+ f"{instruction_sections or '无项目指令'}"
129
+ f"{extension_sections}"
130
+ f"{memory_policy}"
131
+ f"{delegation}"
132
+ )
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any, cast
7
+
8
+ from windcode.domain.events import RunResult
9
+ from windcode.domain.models import Usage
10
+ from windcode.domain.tools import ToolResult
11
+
12
+ _TEST_COMMAND = re.compile(r"(?:^|\s)(?:pytest|pyright|ruff|npm\s+test|pnpm\s+test|cargo\s+test)\b")
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ToolExecutionRecord:
17
+ tool_name: str
18
+ arguments: Mapping[str, Any]
19
+ result: ToolResult
20
+
21
+
22
+ def build_run_result(
23
+ final_text: str,
24
+ records: tuple[ToolExecutionRecord, ...],
25
+ *,
26
+ usage: Usage | None = None,
27
+ ) -> RunResult:
28
+ changed_files: list[str] = []
29
+ verification: list[str] = []
30
+ failed_verification = False
31
+ for record in records:
32
+ path = record.result.data.get("path")
33
+ action = record.result.data.get("action")
34
+ if isinstance(path, str) and isinstance(action, str) and path not in changed_files:
35
+ changed_files.append(path)
36
+ changes = record.result.data.get("changes")
37
+ if isinstance(changes, list):
38
+ raw_changes = cast(list[object], changes)
39
+ for change in raw_changes:
40
+ if isinstance(change, Mapping):
41
+ raw_change = cast(Mapping[object, object], change)
42
+ changed_path = raw_change.get("path")
43
+ if isinstance(changed_path, str) and changed_path not in changed_files:
44
+ changed_files.append(changed_path)
45
+ if record.tool_name == "shell":
46
+ command = record.arguments.get("command")
47
+ exit_code = record.result.data.get("exit_code")
48
+ if isinstance(command, str) and _TEST_COMMAND.search(command):
49
+ verification.append(f"{command} (exit {exit_code})")
50
+ failed_verification = failed_verification or exit_code != 0
51
+
52
+ if failed_verification:
53
+ status = "failed"
54
+ elif changed_files and not verification:
55
+ status = "unverified"
56
+ else:
57
+ status = "completed"
58
+ return RunResult(
59
+ status=status,
60
+ final_text=final_text,
61
+ changed_files=tuple(changed_files),
62
+ verification=tuple(verification),
63
+ usage=usage or Usage(),
64
+ )
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ from collections.abc import AsyncIterator, Awaitable, Callable
6
+ from dataclasses import replace
7
+ from typing import cast
8
+
9
+ from windcode.domain.errors import WindcodeError
10
+ from windcode.domain.messages import Message, ReasoningBlock
11
+ from windcode.domain.models import ModelEvent, ModelRequest
12
+ from windcode.providers import ModelTarget
13
+ from windcode.providers.errors import map_provider_error
14
+
15
+ RetryCallback = Callable[[ModelTarget, int, WindcodeError], Awaitable[None]]
16
+ FallbackCallback = Callable[[ModelTarget, ModelTarget, WindcodeError], Awaitable[None]]
17
+ SleepCallback = Callable[[float], Awaitable[None]]
18
+
19
+
20
+ def portable_messages(messages: tuple[Message, ...]) -> tuple[Message, ...]:
21
+ portable: list[Message] = []
22
+ for message in messages:
23
+ content = tuple(
24
+ replace(block, opaque={}) if isinstance(block, ReasoningBlock) else block
25
+ for block in message.content
26
+ )
27
+ portable.append(replace(message, content=content, provider_metadata={}))
28
+ return tuple(portable)
29
+
30
+
31
+ async def stream_with_retry(
32
+ chain: tuple[ModelTarget, ...],
33
+ request: ModelRequest,
34
+ *,
35
+ on_retry: RetryCallback,
36
+ on_fallback: FallbackCallback,
37
+ max_retries: int = 2,
38
+ sleep: SleepCallback = asyncio.sleep,
39
+ ) -> AsyncIterator[tuple[ModelTarget, ModelEvent]]:
40
+ if not chain:
41
+ raise ValueError("model chain cannot be empty")
42
+ current_request: ModelRequest = request
43
+ for target_index, target in enumerate(chain):
44
+ attempts = 0
45
+ while True:
46
+ provider_request = replace(current_request, model=target.model)
47
+ try:
48
+ async for event in target.transport.stream(provider_request):
49
+ yield target, event
50
+ return
51
+ except BaseException as exc:
52
+ if isinstance(exc, asyncio.CancelledError):
53
+ raise
54
+ error = map_provider_error(exc)
55
+ if error.retryable and attempts < max_retries:
56
+ attempts += 1
57
+ await on_retry(target, attempts, error)
58
+ delay = min(4.0, 0.25 * (2 ** (attempts - 1)))
59
+ await sleep(delay * random.uniform(0.8, 1.2))
60
+ continue
61
+ has_fallback = target_index + 1 < len(chain)
62
+ if error.fallback_allowed and has_fallback:
63
+ fallback = chain[target_index + 1]
64
+ await on_fallback(target, fallback, error)
65
+ current_request = cast(
66
+ ModelRequest,
67
+ replace(
68
+ current_request,
69
+ messages=portable_messages(current_request.messages),
70
+ ),
71
+ )
72
+ break
73
+ raise error from exc
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
10
+ from windcode.policy import (
11
+ ApprovalChoice,
12
+ PolicyAction,
13
+ PolicyDecision,
14
+ PolicyEngine,
15
+ PolicyRequest,
16
+ )
17
+ from windcode.tools.filesystem import resolve_path
18
+ from windcode.tools.registry import ToolRegistry
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class ScheduledCall:
23
+ call_id: str
24
+ tool_name: str
25
+ arguments: Mapping[str, Any]
26
+ origin: str | None = None
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ScheduledResult:
31
+ call_id: str
32
+ result: ToolResult
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PolicyConstraints:
37
+ additional_effects: frozenset[ToolEffect] = frozenset()
38
+ reject_reason: str | None = None
39
+
40
+
41
+ ApprovalHandler = Callable[[PolicyRequest, PolicyDecision], Awaitable[ApprovalChoice]]
42
+ BeforeExecute = Callable[[ScheduledCall, PolicyRequest], Awaitable[None]]
43
+ BeforePolicy = Callable[[ScheduledCall, ToolContext], Awaitable[PolicyConstraints]]
44
+ PermissionObserver = Callable[[ScheduledCall, PolicyRequest, PolicyDecision], Awaitable[None]]
45
+ AfterExecute = Callable[[ScheduledCall, PolicyRequest, ToolResult], Awaitable[None]]
46
+ SessionApprovalRecorder = Callable[[PolicyRequest], None]
47
+
48
+
49
+ class ToolScheduler:
50
+ def __init__(
51
+ self,
52
+ registry: ToolRegistry,
53
+ policy: PolicyEngine,
54
+ *,
55
+ approval_handler: ApprovalHandler | None = None,
56
+ before_execute: BeforeExecute | None = None,
57
+ before_policy: BeforePolicy | None = None,
58
+ permission_observer: PermissionObserver | None = None,
59
+ after_execute: AfterExecute | None = None,
60
+ session_approval_recorder: SessionApprovalRecorder | None = None,
61
+ ) -> None:
62
+ self.registry = registry
63
+ self.policy = policy
64
+ self.approval_handler = approval_handler
65
+ self.before_execute = before_execute
66
+ self.before_policy = before_policy
67
+ self.permission_observer = permission_observer
68
+ self.after_execute = after_execute
69
+ self.session_approval_recorder = session_approval_recorder
70
+
71
+ def _policy_request(
72
+ self,
73
+ call: ScheduledCall,
74
+ context: ToolContext,
75
+ additional_effects: frozenset[ToolEffect] = frozenset(),
76
+ ) -> PolicyRequest:
77
+ tool = self.registry.get(call.tool_name)
78
+ effects = set(tool.effects)
79
+ effects.update(additional_effects)
80
+ raw_path = call.arguments.get("path")
81
+ path = str(raw_path) if isinstance(raw_path, str) else None
82
+ if path is not None and not resolve_path(context.workspace, path).inside_workspace:
83
+ effects.add(ToolEffect.OUTSIDE_WORKSPACE)
84
+ if call.tool_name == "shell" and call.arguments.get("network") is True:
85
+ effects.add(ToolEffect.NETWORK)
86
+ command = call.arguments.get("command")
87
+ safe_command = command if isinstance(command, str) else None
88
+ return PolicyRequest(
89
+ request_id=uuid4().hex,
90
+ call_id=call.call_id,
91
+ tool_name=call.tool_name,
92
+ effects=frozenset(effects),
93
+ summary=f"执行工具: {call.tool_name}",
94
+ path=path,
95
+ command=safe_command,
96
+ )
97
+
98
+ async def _execute_one(self, call: ScheduledCall, context: ToolContext) -> ScheduledResult:
99
+ try:
100
+ constraints = (
101
+ await self.before_policy(call, context)
102
+ if self.before_policy is not None
103
+ else PolicyConstraints()
104
+ )
105
+ if constraints.reject_reason is not None:
106
+ return ScheduledResult(
107
+ call.call_id,
108
+ ToolResult(
109
+ constraints.reject_reason,
110
+ is_error=True,
111
+ data={"error": "extension_rejected"},
112
+ ),
113
+ )
114
+ request = self._policy_request(call, context, constraints.additional_effects)
115
+ except KeyError as exc:
116
+ return ScheduledResult(
117
+ call.call_id,
118
+ ToolResult(
119
+ output=str(exc),
120
+ is_error=True,
121
+ data={"error": "unknown_tool", "tool": call.tool_name},
122
+ ),
123
+ )
124
+ decision = self.policy.evaluate(request)
125
+ if self.permission_observer is not None:
126
+ await self.permission_observer(call, request, decision)
127
+ if decision.action is PolicyAction.DENY:
128
+ return ScheduledResult(
129
+ call.call_id,
130
+ ToolResult(
131
+ output=decision.reason,
132
+ is_error=True,
133
+ data={"error": "policy_denied", "risk": decision.risk.value},
134
+ ),
135
+ )
136
+ if decision.action is PolicyAction.ASK:
137
+ if self.approval_handler is None:
138
+ return ScheduledResult(
139
+ call.call_id,
140
+ ToolResult(
141
+ output=decision.reason,
142
+ is_error=True,
143
+ data={"error": "approval_required", "risk": decision.risk.value},
144
+ ),
145
+ )
146
+ choice = await self.approval_handler(request, decision)
147
+ if choice is ApprovalChoice.DENY:
148
+ return ScheduledResult(
149
+ call.call_id,
150
+ ToolResult(
151
+ output="user denied the operation",
152
+ is_error=True,
153
+ data={"error": "approval_denied"},
154
+ ),
155
+ )
156
+ if choice is ApprovalChoice.ALLOW_SESSION:
157
+ self.policy.approve_for_session(request)
158
+ if self.session_approval_recorder is not None:
159
+ self.session_approval_recorder(request)
160
+ if self.before_execute is not None:
161
+ await self.before_execute(call, request)
162
+ result = await self.registry.execute(call.tool_name, context, call.arguments)
163
+ if self.after_execute is not None:
164
+ await self.after_execute(call, request, result)
165
+ return ScheduledResult(call.call_id, result)
166
+
167
+ def _is_read_only(self, call: ScheduledCall) -> bool:
168
+ try:
169
+ return self.registry.get(call.tool_name).effects <= {ToolEffect.READ}
170
+ except KeyError:
171
+ return False
172
+
173
+ async def execute(
174
+ self,
175
+ calls: tuple[ScheduledCall, ...],
176
+ context: ToolContext,
177
+ ) -> tuple[ScheduledResult, ...]:
178
+ results: list[ScheduledResult] = []
179
+ index = 0
180
+ while index < len(calls):
181
+ if not self._is_read_only(calls[index]):
182
+ results.append(await self._execute_one(calls[index], context))
183
+ index += 1
184
+ continue
185
+ end = index
186
+ while end < len(calls) and self._is_read_only(calls[end]):
187
+ end += 1
188
+ batch = calls[index:end]
189
+ batch_results = await asyncio.gather(
190
+ *(self._execute_one(call, context) for call in batch)
191
+ )
192
+ results.extend(batch_results)
193
+ index = end
194
+ return tuple(results)
@@ -0,0 +1,28 @@
1
+ from windcode.runtime.subagents.approvals import ApprovalRouter
2
+ from windcode.runtime.subagents.budgets import (
3
+ AggregateBudget,
4
+ AggregateBudgetExceeded,
5
+ AggregateUsage,
6
+ )
7
+ from windcode.runtime.subagents.coordinator import (
8
+ SubagentCoordinator,
9
+ SubagentCoordinatorError,
10
+ )
11
+ from windcode.runtime.subagents.factory import ChildRuntime, ChildRuntimeFactory
12
+ from windcode.runtime.subagents.roles import ROLE_POLICIES, RolePolicy, resolve_role_tools
13
+ from windcode.runtime.subagents.verification import VerificationRunner
14
+
15
+ __all__ = [
16
+ "ROLE_POLICIES",
17
+ "AggregateBudget",
18
+ "AggregateBudgetExceeded",
19
+ "AggregateUsage",
20
+ "ApprovalRouter",
21
+ "ChildRuntime",
22
+ "ChildRuntimeFactory",
23
+ "RolePolicy",
24
+ "SubagentCoordinator",
25
+ "SubagentCoordinatorError",
26
+ "VerificationRunner",
27
+ "resolve_role_tools",
28
+ ]
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+ from dataclasses import dataclass
6
+ from uuid import uuid4
7
+
8
+ from windcode.domain.events import ApprovalRequested, ApprovalResponse
9
+ from windcode.domain.subagents import SubagentRole
10
+ from windcode.policy.models import (
11
+ ApprovalChoice,
12
+ PolicyDecision,
13
+ PolicyRequest,
14
+ summarize_policy_arguments,
15
+ )
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class _PendingApproval:
20
+ subagent_id: str
21
+ future: asyncio.Future[ApprovalChoice]
22
+
23
+
24
+ class ApprovalRouter:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ parent_session_id: str,
29
+ parent_run_id: str,
30
+ publish: Callable[[ApprovalRequested], Awaitable[None]],
31
+ ) -> None:
32
+ self.parent_session_id = parent_session_id
33
+ self.parent_run_id = parent_run_id
34
+ self.publish = publish
35
+ self._pending: dict[str, _PendingApproval] = {}
36
+
37
+ async def request(
38
+ self,
39
+ subagent_id: str,
40
+ role: SubagentRole,
41
+ request: PolicyRequest,
42
+ decision: PolicyDecision,
43
+ ) -> ApprovalChoice:
44
+ parent_request_id = uuid4().hex
45
+ future: asyncio.Future[ApprovalChoice] = asyncio.get_running_loop().create_future()
46
+ self._pending[parent_request_id] = _PendingApproval(subagent_id, future)
47
+ arguments_summary = summarize_policy_arguments(request)
48
+ try:
49
+ await self.publish(
50
+ ApprovalRequested(
51
+ event_id=uuid4().hex,
52
+ session_id=self.parent_session_id,
53
+ run_id=self.parent_run_id,
54
+ turn=0,
55
+ request_id=parent_request_id,
56
+ summary=request.summary,
57
+ risk=decision.risk.value,
58
+ choices=tuple(choice.value for choice in decision.choices),
59
+ subagent_id=subagent_id,
60
+ subagent_role=role.value,
61
+ tool_name=request.tool_name,
62
+ arguments_summary=arguments_summary,
63
+ )
64
+ )
65
+ return await future
66
+ finally:
67
+ self._pending.pop(parent_request_id, None)
68
+
69
+ def respond(self, response: ApprovalResponse) -> None:
70
+ try:
71
+ pending = self._pending[response.request_id]
72
+ except KeyError as exc:
73
+ raise ValueError(f"no pending subagent approval: {response.request_id}") from exc
74
+ try:
75
+ choice = ApprovalChoice(response.decision)
76
+ except ValueError:
77
+ choice = ApprovalChoice.DENY
78
+ if not pending.future.done():
79
+ pending.future.set_result(choice)
80
+
81
+ def cancel(self, subagent_id: str) -> None:
82
+ for pending in tuple(self._pending.values()):
83
+ if pending.subagent_id == subagent_id and not pending.future.done():
84
+ pending.future.cancel()
85
+
86
+ @property
87
+ def pending_count(self) -> int:
88
+ return len(self._pending)
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from time import monotonic
6
+
7
+
8
+ class AggregateBudgetExceeded(RuntimeError):
9
+ def __init__(self, budget: str) -> None:
10
+ self.scope = "aggregate"
11
+ self.budget = budget
12
+ super().__init__(f"aggregate subagent budget exhausted: {budget}")
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class AggregateUsage:
17
+ model_steps: int
18
+ tool_calls: int
19
+ runtime_seconds: float
20
+
21
+
22
+ class AggregateBudget:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ max_model_steps: int,
27
+ max_tool_calls: int,
28
+ max_runtime_seconds: float,
29
+ ) -> None:
30
+ self.max_model_steps = max_model_steps
31
+ self.max_tool_calls = max_tool_calls
32
+ self.max_runtime_seconds = max_runtime_seconds
33
+ self._model_steps = 0
34
+ self._tool_calls = 0
35
+ self._started_at = monotonic()
36
+ self._lock = asyncio.Lock()
37
+
38
+ async def consume_model_step(self) -> None:
39
+ async with self._lock:
40
+ self.consume_model_step_nowait()
41
+
42
+ async def consume_tool_calls(self, count: int = 1) -> None:
43
+ if count < 1:
44
+ raise ValueError("tool call count must be positive")
45
+ async with self._lock:
46
+ self.consume_tool_calls_nowait(count)
47
+
48
+ async def check_runtime(self) -> None:
49
+ async with self._lock:
50
+ self._check_runtime_unlocked()
51
+
52
+ async def usage(self) -> AggregateUsage:
53
+ async with self._lock:
54
+ return AggregateUsage(
55
+ model_steps=self._model_steps,
56
+ tool_calls=self._tool_calls,
57
+ runtime_seconds=max(0.0, monotonic() - self._started_at),
58
+ )
59
+
60
+ def _check_runtime_unlocked(self) -> None:
61
+ if monotonic() - self._started_at >= self.max_runtime_seconds:
62
+ raise AggregateBudgetExceeded("runtime_seconds")
63
+
64
+ def check_runtime_nowait(self) -> None:
65
+ self._check_runtime_unlocked()
66
+
67
+ def consume_model_step_nowait(self) -> None:
68
+ self._check_runtime_unlocked()
69
+ if self._model_steps >= self.max_model_steps:
70
+ raise AggregateBudgetExceeded("model_steps")
71
+ self._model_steps += 1
72
+
73
+ def consume_tool_calls_nowait(self, count: int = 1) -> None:
74
+ if count < 1:
75
+ raise ValueError("tool call count must be positive")
76
+ self._check_runtime_unlocked()
77
+ if self._tool_calls + count > self.max_tool_calls:
78
+ raise AggregateBudgetExceeded("tool_calls")
79
+ self._tool_calls += count