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,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))
|
windcode/tools/shell.py
ADDED
|
@@ -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})
|