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,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import cast
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
8
+ from windcode.tools.filesystem import content_sha256, require_workspace_path
9
+
10
+
11
+ class ReadFileInput(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+ path: str = Field(min_length=1)
15
+ offset: int = Field(default=1, ge=1)
16
+ limit: int = Field(default=500, ge=1, le=2_000)
17
+
18
+
19
+ class ReadFileTool:
20
+ name = "read_file"
21
+ description = "Read a UTF-8 text file with stable one-based line numbers."
22
+ input_model = ReadFileInput
23
+ effects = frozenset({ToolEffect.READ})
24
+ max_bytes = 2_000_000
25
+
26
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
27
+ parsed = cast(ReadFileInput, arguments)
28
+ path = require_workspace_path(context.workspace, parsed.path)
29
+ raw = path.read_bytes()
30
+ if len(raw) > self.max_bytes:
31
+ return ToolResult(
32
+ output=f"file is too large: {len(raw)} bytes (limit {self.max_bytes})",
33
+ is_error=True,
34
+ data={"error": "file_too_large", "size": len(raw)},
35
+ )
36
+ if b"\x00" in raw:
37
+ return ToolResult(output="binary files are not supported", is_error=True)
38
+ content = raw.decode("utf-8")
39
+ lines = content.splitlines()
40
+ start = parsed.offset - 1
41
+ selected = lines[start : start + parsed.limit]
42
+ output = "\n".join(
43
+ f"{number:>6}\t{line}" for number, line in enumerate(selected, parsed.offset)
44
+ )
45
+ truncated = start + len(selected) < len(lines)
46
+ return ToolResult(
47
+ output=output,
48
+ data={
49
+ "path": str(path.relative_to(context.workspace.resolve())),
50
+ "sha256": content_sha256(raw),
51
+ "line_count": len(lines),
52
+ "truncated": truncated,
53
+ },
54
+ )
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from dataclasses import replace
6
+ from time import monotonic
7
+ from typing import Any, Protocol, cast
8
+
9
+ import jsonschema
10
+ from pydantic import ValidationError
11
+
12
+ from windcode.domain.models import ToolSchema
13
+ from windcode.domain.tools import Tool, ToolContext, ToolResult, ValidatedArguments
14
+
15
+
16
+ class _RawSchemaTool(Protocol):
17
+ @property
18
+ def input_schema(self) -> Mapping[str, Any]: ...
19
+
20
+ def validate_arguments(self, arguments: Mapping[str, Any]) -> ValidatedArguments: ...
21
+
22
+
23
+ def _input_schema(tool: Tool) -> dict[str, Any]:
24
+ raw = getattr(tool, "input_schema", None)
25
+ if isinstance(raw, Mapping):
26
+ mapping = cast(Mapping[object, object], raw)
27
+ return {str(key): value for key, value in mapping.items()}
28
+ return tool.input_model.model_json_schema()
29
+
30
+
31
+ def _validate(tool: Tool, arguments: Mapping[str, Any]) -> ValidatedArguments:
32
+ validator = getattr(tool, "validate_arguments", None)
33
+ if callable(validator):
34
+ return cast(_RawSchemaTool, tool).validate_arguments(arguments)
35
+ schema = getattr(tool, "input_schema", None)
36
+ if isinstance(schema, Mapping):
37
+ typed_schema = cast(Mapping[str, Any], schema)
38
+ validator = jsonschema.Draft202012Validator(typed_schema)
39
+ validator.validate(arguments) # pyright: ignore[reportUnknownMemberType]
40
+ return dict(arguments)
41
+ return tool.input_model.model_validate(arguments)
42
+
43
+
44
+ class ToolRegistry:
45
+ def __init__(self) -> None:
46
+ self._tools: dict[str, Tool] = {}
47
+
48
+ def register(self, tool: Tool, *, replace: bool = False) -> None:
49
+ if tool.name in self._tools and not replace:
50
+ raise ValueError(f"tool already registered: {tool.name}")
51
+ self._tools[tool.name] = tool
52
+
53
+ def clone(self) -> ToolRegistry:
54
+ registry = ToolRegistry()
55
+ registry._tools = self._tools.copy()
56
+ return registry
57
+
58
+ def names(self) -> tuple[str, ...]:
59
+ return tuple(self._tools)
60
+
61
+ def get(self, name: str) -> Tool:
62
+ try:
63
+ return self._tools[name]
64
+ except KeyError as exc:
65
+ raise KeyError(f"unknown tool: {name}") from exc
66
+
67
+ def schemas(self) -> tuple[ToolSchema, ...]:
68
+ return tuple(
69
+ ToolSchema(
70
+ name=tool.name,
71
+ description=tool.description,
72
+ parameters=_input_schema(tool),
73
+ )
74
+ for tool in self._tools.values()
75
+ )
76
+
77
+ async def execute(
78
+ self,
79
+ name: str,
80
+ context: ToolContext,
81
+ arguments: Mapping[str, Any],
82
+ ) -> ToolResult:
83
+ try:
84
+ tool = self.get(name)
85
+ except KeyError as exc:
86
+ return ToolResult(
87
+ output=str(exc),
88
+ is_error=True,
89
+ data={"error": "unknown_tool", "tool": name},
90
+ )
91
+ try:
92
+ parsed = _validate(tool, arguments)
93
+ except (ValidationError, jsonschema.ValidationError, jsonschema.SchemaError) as exc:
94
+ errors: object
95
+ if isinstance(exc, ValidationError):
96
+ errors = exc.errors(include_url=False)
97
+ elif isinstance(exc, jsonschema.ValidationError):
98
+ path_parts = cast(tuple[object, ...], tuple(exc.absolute_path))
99
+ path_values: list[str] = [str(part) for part in path_parts]
100
+ errors = cast(
101
+ object,
102
+ [{"path": path_values, "message": str(exc.message)}],
103
+ )
104
+ else:
105
+ errors = [{"path": tuple[str, ...](), "message": str(exc)}]
106
+ return ToolResult(
107
+ output=json.dumps(errors, ensure_ascii=True),
108
+ is_error=True,
109
+ data={"error": "invalid_arguments"},
110
+ )
111
+ started = monotonic()
112
+ try:
113
+ result = await tool.execute(context, cast(Any, parsed))
114
+ except (OSError, ValueError, UnicodeError) as exc:
115
+ result = ToolResult(
116
+ output=str(exc),
117
+ is_error=True,
118
+ data={"error": "execution_failed", "type": type(exc).__name__},
119
+ )
120
+ return replace(result, elapsed_seconds=max(0.0, monotonic() - started))
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import signal
6
+ from dataclasses import dataclass
7
+ from time import monotonic
8
+ from typing import cast
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
13
+ from windcode.sandbox import BubblewrapSandbox
14
+ from windcode.tools.filesystem import require_workspace_path
15
+
16
+
17
+ class ShellInput(BaseModel):
18
+ model_config = ConfigDict(extra="forbid")
19
+
20
+ command: str = Field(min_length=1)
21
+ cwd: str = "."
22
+ timeout_seconds: float | None = Field(default=None, gt=0, le=3600)
23
+ network: bool = False
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class _BoundedOutput:
28
+ limit: int
29
+ data: bytearray
30
+ truncated: bool = False
31
+
32
+ def append(self, chunk: bytes) -> None:
33
+ remaining = self.limit - len(self.data)
34
+ if remaining > 0:
35
+ self.data.extend(chunk[:remaining])
36
+ if len(chunk) > remaining:
37
+ self.truncated = True
38
+
39
+
40
+ async def _terminate_process_group(process: asyncio.subprocess.Process) -> None:
41
+ if process.returncode is not None:
42
+ return
43
+ try:
44
+ os.killpg(process.pid, signal.SIGTERM)
45
+ except ProcessLookupError:
46
+ return
47
+ try:
48
+ await asyncio.wait_for(process.wait(), timeout=0.5)
49
+ except TimeoutError:
50
+ try:
51
+ os.killpg(process.pid, signal.SIGKILL)
52
+ except ProcessLookupError:
53
+ pass
54
+ await process.wait()
55
+
56
+
57
+ class ShellTool:
58
+ name = "shell"
59
+ description = "Run a bounded shell command with timeout, cancellation, and optional sandboxing."
60
+ input_model = ShellInput
61
+ effects = frozenset({ToolEffect.PROCESS})
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ sandbox: BubblewrapSandbox | None = None,
67
+ default_timeout: float = 120.0,
68
+ output_limit: int = 1_000_000,
69
+ ) -> None:
70
+ self.sandbox = sandbox
71
+ self.default_timeout = default_timeout
72
+ self.output_limit = output_limit
73
+
74
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
75
+ parsed = cast(ShellInput, arguments)
76
+ cwd = require_workspace_path(context.workspace, parsed.cwd)
77
+ command = ("bash", "-lc", parsed.command)
78
+ if self.sandbox is not None:
79
+ if not self.sandbox.status.available:
80
+ return ToolResult(
81
+ output=self.sandbox.status.warning or "sandbox unavailable",
82
+ is_error=True,
83
+ data={"error": "sandbox_unavailable"},
84
+ )
85
+ command = self.sandbox.wrap(command, cwd=cwd, allow_network=parsed.network)
86
+
87
+ started = monotonic()
88
+ process = await asyncio.create_subprocess_exec(
89
+ *command,
90
+ cwd=cwd,
91
+ stdout=asyncio.subprocess.PIPE,
92
+ stderr=asyncio.subprocess.PIPE,
93
+ start_new_session=True,
94
+ )
95
+ stdout = _BoundedOutput(self.output_limit, bytearray())
96
+ stderr = _BoundedOutput(self.output_limit, bytearray())
97
+
98
+ async def read_stream(
99
+ stream: asyncio.StreamReader | None, target: _BoundedOutput, label: str
100
+ ) -> None:
101
+ if stream is None:
102
+ return
103
+ while chunk := await stream.read(4096):
104
+ target.append(chunk)
105
+ if context.progress is not None:
106
+ await context.progress(f"{label}: {chunk.decode('utf-8', errors='replace')}")
107
+
108
+ readers = [
109
+ asyncio.create_task(read_stream(process.stdout, stdout, "stdout")),
110
+ asyncio.create_task(read_stream(process.stderr, stderr, "stderr")),
111
+ ]
112
+ timeout = parsed.timeout_seconds or self.default_timeout
113
+ try:
114
+ async with asyncio.timeout(timeout):
115
+ while process.returncode is None:
116
+ if context.cancelled():
117
+ raise asyncio.CancelledError
118
+ await asyncio.sleep(0.02)
119
+ await asyncio.gather(*readers)
120
+ except TimeoutError:
121
+ await _terminate_process_group(process)
122
+ await asyncio.gather(*readers)
123
+ elapsed = monotonic() - started
124
+ return ToolResult(
125
+ output=f"command timed out after {timeout:g} seconds",
126
+ is_error=True,
127
+ elapsed_seconds=elapsed,
128
+ data={"error": "timeout", "exit_code": process.returncode, "timed_out": True},
129
+ )
130
+ except asyncio.CancelledError:
131
+ await _terminate_process_group(process)
132
+ await asyncio.gather(*readers, return_exceptions=True)
133
+ raise
134
+ finally:
135
+ for reader in readers:
136
+ if not reader.done():
137
+ reader.cancel()
138
+
139
+ elapsed = monotonic() - started
140
+ stdout_text = stdout.data.decode("utf-8", errors="replace")
141
+ stderr_text = stderr.data.decode("utf-8", errors="replace")
142
+ output = stdout_text
143
+ if stderr_text:
144
+ output += ("\n" if output and not output.endswith("\n") else "") + stderr_text
145
+ truncated = stdout.truncated or stderr.truncated
146
+ if truncated:
147
+ output += "\n[output truncated]"
148
+ return ToolResult(
149
+ output=output,
150
+ is_error=process.returncode != 0,
151
+ elapsed_seconds=elapsed,
152
+ data={
153
+ "exit_code": process.returncode,
154
+ "stdout": stdout_text,
155
+ "stderr": stderr_text,
156
+ "truncated": truncated,
157
+ "command": parsed.command,
158
+ },
159
+ )
@@ -0,0 +1,31 @@
1
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator
2
+ from windcode.tools.registry import ToolRegistry
3
+ from windcode.tools.subagents.cancel import CancelSubagentTool
4
+ from windcode.tools.subagents.integrate import IntegrateSubagentTool
5
+ from windcode.tools.subagents.list import ListSubagentsTool
6
+ from windcode.tools.subagents.spawn import SpawnSubagentsTool
7
+ from windcode.tools.subagents.wait import WaitSubagentsTool
8
+
9
+
10
+ def register_subagent_tools(
11
+ registry: ToolRegistry,
12
+ coordinator: SubagentCoordinator,
13
+ ) -> None:
14
+ for tool in (
15
+ SpawnSubagentsTool(coordinator),
16
+ ListSubagentsTool(coordinator),
17
+ WaitSubagentsTool(coordinator),
18
+ CancelSubagentTool(coordinator),
19
+ IntegrateSubagentTool(coordinator),
20
+ ):
21
+ registry.register(tool)
22
+
23
+
24
+ __all__ = [
25
+ "CancelSubagentTool",
26
+ "IntegrateSubagentTool",
27
+ "ListSubagentsTool",
28
+ "SpawnSubagentsTool",
29
+ "WaitSubagentsTool",
30
+ "register_subagent_tools",
31
+ ]
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
9
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator, SubagentCoordinatorError
10
+
11
+
12
+ class CancelSubagentInput(BaseModel):
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ subagent_id: str = Field(min_length=1)
16
+
17
+
18
+ class CancelSubagentTool:
19
+ name = "cancel_subagent"
20
+ description = "Cancel one queued or running temporary subagent."
21
+ input_model = CancelSubagentInput
22
+ effects = frozenset({ToolEffect.PROCESS})
23
+
24
+ def __init__(self, coordinator: SubagentCoordinator) -> None:
25
+ self.coordinator = coordinator
26
+
27
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
28
+ del context
29
+ parsed = cast(CancelSubagentInput, arguments)
30
+ try:
31
+ record = await self.coordinator.cancel(parsed.subagent_id)
32
+ except SubagentCoordinatorError as exc:
33
+ return ToolResult(output=str(exc), is_error=True, data={"error": exc.category})
34
+ data = {"subagent_id": record.subagent_id, "status": record.status.value}
35
+ return ToolResult(json.dumps(data, ensure_ascii=True), data=data)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
9
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator, SubagentCoordinatorError
10
+
11
+
12
+ class IntegrateSubagentInput(BaseModel):
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ subagent_id: str = Field(min_length=1)
16
+ verification_commands: tuple[str, ...] = ()
17
+
18
+
19
+ class IntegrateSubagentTool:
20
+ name = "integrate_subagent"
21
+ description = "Integrate a completed subagent commit and verify it in the parent workspace."
22
+ input_model = IntegrateSubagentInput
23
+ effects = frozenset({ToolEffect.WORKSPACE_WRITE, ToolEffect.PROCESS})
24
+
25
+ def __init__(self, coordinator: SubagentCoordinator) -> None:
26
+ self.coordinator = coordinator
27
+
28
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
29
+ del context
30
+ parsed = cast(IntegrateSubagentInput, arguments)
31
+ try:
32
+ result = await self.coordinator.integrate(
33
+ parsed.subagent_id, parsed.verification_commands
34
+ )
35
+ except SubagentCoordinatorError as exc:
36
+ return ToolResult(output=str(exc), is_error=True, data={"error": exc.category})
37
+ data = {
38
+ "subagent_id": result.subagent_id,
39
+ "status": result.status.value,
40
+ "commit": result.commit,
41
+ "verification": [
42
+ {
43
+ "command": item.command,
44
+ "exit_code": item.exit_code,
45
+ "passed": item.passed,
46
+ "output_summary": item.output_summary,
47
+ }
48
+ for item in result.verification
49
+ ],
50
+ "error_category": result.error_category,
51
+ "error_message": result.error_message,
52
+ }
53
+ is_error = result.error_category is not None
54
+ return ToolResult(json.dumps(data, ensure_ascii=True), is_error=is_error, data=data)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
8
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator
9
+
10
+
11
+ class ListSubagentsInput(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+
15
+ class ListSubagentsTool:
16
+ name = "list_subagents"
17
+ description = (
18
+ "Take a non-blocking subagent status snapshot for explicit inspection. "
19
+ "Do not poll this tool; use wait_subagents to await completion."
20
+ )
21
+ input_model = ListSubagentsInput
22
+ effects = frozenset({ToolEffect.READ})
23
+
24
+ def __init__(self, coordinator: SubagentCoordinator) -> None:
25
+ self.coordinator = coordinator
26
+
27
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
28
+ del context, arguments
29
+ data = [
30
+ {
31
+ "subagent_id": record.subagent_id,
32
+ "task_index": record.task_index,
33
+ "task_name": record.spec.task_name,
34
+ "role": record.spec.role.value,
35
+ "kind": record.spec.kind.value,
36
+ "status": record.status.value,
37
+ "commit": record.commit,
38
+ "worktree_path": (
39
+ None if record.worktree_path is None else str(record.worktree_path)
40
+ ),
41
+ "error_category": record.error_category,
42
+ "error_message": record.error_message,
43
+ }
44
+ for record in self.coordinator.list()
45
+ ]
46
+ return ToolResult(json.dumps(data, ensure_ascii=True), data={"subagents": data})
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from windcode.domain.subagents import SubagentRole, SubagentTaskKind, SubagentTaskSpec
9
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
10
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator, SubagentCoordinatorError
11
+
12
+
13
+ class SubagentTaskInput(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", frozen=True)
15
+
16
+ task_name: str = Field(pattern=r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
17
+ role: SubagentRole
18
+ kind: SubagentTaskKind
19
+ goal: str = Field(min_length=1)
20
+ context: str = Field(min_length=1)
21
+ expected_output: str = Field(min_length=1)
22
+ verification: tuple[str, ...] = Field(min_length=1)
23
+ allowed_tools: frozenset[str] | None = None
24
+ model: str | None = None
25
+ requires_network: bool = Field(
26
+ default=False,
27
+ description="Whether the task requires external network access.",
28
+ )
29
+
30
+ def to_spec(self) -> SubagentTaskSpec:
31
+ return SubagentTaskSpec(
32
+ self.task_name,
33
+ self.role,
34
+ self.kind,
35
+ self.goal,
36
+ self.context,
37
+ self.expected_output,
38
+ self.verification,
39
+ self.allowed_tools,
40
+ self.model,
41
+ self.requires_network,
42
+ )
43
+
44
+
45
+ class SpawnSubagentsInput(BaseModel):
46
+ model_config = ConfigDict(extra="forbid")
47
+
48
+ tasks: tuple[SubagentTaskInput, ...] = Field(min_length=1, max_length=16)
49
+
50
+
51
+ class SpawnSubagentsTool:
52
+ name = "spawn_subagents"
53
+ description = (
54
+ "Create bounded temporary subagents. Declare requires_network for network-dependent "
55
+ "tasks; network access follows the parent run's network policy and permission workflow."
56
+ )
57
+ input_model = SpawnSubagentsInput
58
+ effects = frozenset({ToolEffect.PROCESS, ToolEffect.WORKSPACE_WRITE})
59
+
60
+ def __init__(self, coordinator: SubagentCoordinator) -> None:
61
+ self.coordinator = coordinator
62
+
63
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
64
+ del context
65
+ parsed = cast(SpawnSubagentsInput, arguments)
66
+ try:
67
+ records = await self.coordinator.spawn(tuple(item.to_spec() for item in parsed.tasks))
68
+ except (SubagentCoordinatorError, ValueError) as exc:
69
+ category = getattr(exc, "category", "invalid_task")
70
+ return ToolResult(
71
+ output=str(exc),
72
+ is_error=True,
73
+ data={"error": str(category)},
74
+ )
75
+ data = [
76
+ {
77
+ "subagent_id": record.subagent_id,
78
+ "task_index": record.task_index,
79
+ "task_name": record.spec.task_name,
80
+ "role": record.spec.role.value,
81
+ "kind": record.spec.kind.value,
82
+ "status": record.status.value,
83
+ }
84
+ for record in records
85
+ ]
86
+ return ToolResult(json.dumps(data, ensure_ascii=True), data={"subagents": data})
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import cast
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from windcode.domain.subagents import SubagentResult
10
+ from windcode.domain.tools import ToolContext, ToolEffect, ToolResult
11
+ from windcode.runtime.subagents.coordinator import SubagentCoordinator, SubagentCoordinatorError
12
+
13
+
14
+ class WaitSubagentsInput(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+ subagent_ids: tuple[str, ...] = Field(min_length=1, max_length=16)
18
+ timeout_seconds: float = Field(default=300.0, gt=0, le=900.0)
19
+
20
+
21
+ def _result_data(result: SubagentResult) -> dict[str, object]:
22
+ return {
23
+ "subagent_id": result.subagent_id,
24
+ "task_name": result.task_name,
25
+ "status": result.status.value,
26
+ "summary": result.summary,
27
+ "changed_files": list(result.changed_files),
28
+ "commit": result.commit,
29
+ "usage": {
30
+ "input_tokens": result.usage.input_tokens,
31
+ "output_tokens": result.usage.output_tokens,
32
+ "cache_read_tokens": result.usage.cache_read_tokens,
33
+ "cache_write_tokens": result.usage.cache_write_tokens,
34
+ },
35
+ "error_category": result.error_category,
36
+ "error_message": result.error_message,
37
+ }
38
+
39
+
40
+ class WaitSubagentsTool:
41
+ name = "wait_subagents"
42
+ description = (
43
+ "Wait once for temporary subagents to reach terminal results. Use this after spawning; "
44
+ "do not poll list_subagents."
45
+ )
46
+ input_model = WaitSubagentsInput
47
+ effects = frozenset({ToolEffect.READ})
48
+
49
+ def __init__(self, coordinator: SubagentCoordinator) -> None:
50
+ self.coordinator = coordinator
51
+
52
+ async def execute(self, context: ToolContext, arguments: BaseModel) -> ToolResult:
53
+ del context
54
+ parsed = cast(WaitSubagentsInput, arguments)
55
+ try:
56
+ async with asyncio.timeout(parsed.timeout_seconds):
57
+ results = await asyncio.gather(
58
+ *(self.coordinator.wait(subagent_id) for subagent_id in parsed.subagent_ids)
59
+ )
60
+ except TimeoutError:
61
+ data = {
62
+ "error": "wait_timeout",
63
+ "subagent_ids": list(parsed.subagent_ids),
64
+ "statuses": {
65
+ record.subagent_id: record.status.value
66
+ for record in self.coordinator.list()
67
+ if record.subagent_id in parsed.subagent_ids
68
+ },
69
+ }
70
+ return ToolResult(json.dumps(data, ensure_ascii=True), is_error=True, data=data)
71
+ except SubagentCoordinatorError as exc:
72
+ return ToolResult(output=str(exc), is_error=True, data={"error": exc.category})
73
+ data = [_result_data(result) for result in results]
74
+ return ToolResult(json.dumps(data, ensure_ascii=True), data={"subagents": data})