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
windcode/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Windcode public package."""
2
+
3
+ from windcode.sdk import Windcode
4
+
5
+ __all__ = ["Windcode"]
6
+ __version__ = "0.1.0"
windcode/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from windcode.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,11 @@
1
+ from windcode.auth.store import (
2
+ CredentialStore,
3
+ CredentialStoreError,
4
+ FileCredentialStore,
5
+ )
6
+
7
+ __all__ = [
8
+ "CredentialStore",
9
+ "CredentialStoreError",
10
+ "FileCredentialStore",
11
+ ]
windcode/auth/store.py ADDED
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Protocol, TypedDict, cast
8
+ from uuid import uuid4
9
+
10
+ from platformdirs import user_data_path
11
+
12
+
13
+ class CredentialStoreError(RuntimeError):
14
+ """Raised when credentials cannot be persisted safely."""
15
+
16
+
17
+ class CredentialStore(Protocol):
18
+ def get(self, credential_id: str) -> str | None: ...
19
+
20
+ def set(self, credential_id: str, secret: str) -> None: ...
21
+
22
+ def delete(self, credential_id: str) -> None: ...
23
+
24
+
25
+ class ApiCredential(TypedDict):
26
+ type: str
27
+ key: str
28
+
29
+
30
+ class FileCredentialStore:
31
+ """OpenCode-style API credentials stored in the user's data directory."""
32
+
33
+ def __init__(self, path: Path | None = None) -> None:
34
+ self.path = (path or user_data_path("windcode") / "auth.json").expanduser().resolve()
35
+
36
+ def _read(self) -> dict[str, ApiCredential]:
37
+ try:
38
+ raw: object = json.loads(self.path.read_text(encoding="utf-8"))
39
+ except FileNotFoundError:
40
+ return {}
41
+ except (OSError, json.JSONDecodeError) as exc:
42
+ raise CredentialStoreError("无法读取 Windcode 凭据文件") from exc
43
+ if not isinstance(raw, dict):
44
+ raise CredentialStoreError("Windcode 凭据文件格式无效")
45
+ values = cast(dict[object, object], raw)
46
+ credentials: dict[str, ApiCredential] = {}
47
+ for credential_id, value in values.items():
48
+ if not isinstance(credential_id, str):
49
+ continue
50
+ if not isinstance(value, dict):
51
+ continue
52
+ record = cast(dict[object, object], value)
53
+ credential_type = record.get("type")
54
+ key = record.get("key")
55
+ if credential_type == "api" and isinstance(key, str):
56
+ credentials[credential_id] = {"type": "api", "key": key}
57
+ return credentials
58
+
59
+ def get(self, credential_id: str) -> str | None:
60
+ credential = self._read().get(credential_id)
61
+ return credential["key"] if credential is not None else None
62
+
63
+ def set(self, credential_id: str, secret: str) -> None:
64
+ values = self._read()
65
+ values[credential_id] = {"type": "api", "key": secret}
66
+ self._write(values)
67
+
68
+ def delete(self, credential_id: str) -> None:
69
+ values = self._read()
70
+ if values.pop(credential_id, None) is not None:
71
+ self._write(values)
72
+
73
+ def _write(self, values: Mapping[str, ApiCredential]) -> None:
74
+ try:
75
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
76
+ os.chmod(self.path.parent, 0o700)
77
+ temporary = self.path.with_suffix(f"{self.path.suffix}.tmp-{uuid4().hex}")
78
+ try:
79
+ descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
80
+ with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
81
+ json.dump(dict(values), stream, ensure_ascii=True, indent=2, sort_keys=True)
82
+ stream.write("\n")
83
+ stream.flush()
84
+ os.fsync(stream.fileno())
85
+ temporary.replace(self.path)
86
+ os.chmod(self.path, 0o600)
87
+ finally:
88
+ temporary.unlink(missing_ok=True)
89
+ except OSError as exc:
90
+ raise CredentialStoreError("无法写入 Windcode 凭据文件") from exc
windcode/cli.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from windcode.config import AppConfig, ConfigError, PermissionMode, load_config
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CLIOptions:
16
+ workspace: Path
17
+ config_file: Path | None
18
+ model: str | None
19
+ resume_session: str | None
20
+ permission_mode: PermissionMode | None
21
+ sandbox_enabled: bool | None
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="windcode", description="Terminal coding agent")
26
+ parser.add_argument("workspace", nargs="?", type=Path, default=Path.cwd())
27
+ parser.add_argument("--config", type=Path, help="explicit TOML configuration file")
28
+ parser.add_argument("--model", help="provider alias or model override")
29
+ parser.add_argument("--resume", metavar="SESSION_ID", help="resume an existing session")
30
+ parser.add_argument(
31
+ "--permission-mode",
32
+ choices=tuple(mode.value for mode in PermissionMode),
33
+ help="initial permission mode",
34
+ )
35
+ parser.add_argument(
36
+ "--sandbox",
37
+ action=argparse.BooleanOptionalAction,
38
+ default=None,
39
+ help="enable or explicitly disable the Linux tool sandbox",
40
+ )
41
+ return parser
42
+
43
+
44
+ def parse_options(argv: Sequence[str] | None = None) -> CLIOptions:
45
+ namespace = build_parser().parse_args(argv)
46
+ raw_mode = namespace.permission_mode
47
+ return CLIOptions(
48
+ workspace=namespace.workspace.expanduser().resolve(),
49
+ config_file=namespace.config,
50
+ model=namespace.model,
51
+ resume_session=namespace.resume,
52
+ permission_mode=PermissionMode(raw_mode) if raw_mode is not None else None,
53
+ sandbox_enabled=namespace.sandbox,
54
+ )
55
+
56
+
57
+ def resolve_config(options: CLIOptions) -> AppConfig:
58
+ overrides: dict[str, object] = {}
59
+ if options.permission_mode is not None:
60
+ overrides["permission"] = {"mode": options.permission_mode.value}
61
+ if options.sandbox_enabled is not None:
62
+ overrides["sandbox"] = {"enabled": options.sandbox_enabled}
63
+ return load_config(
64
+ options.workspace,
65
+ explicit_file=options.config_file,
66
+ overrides=overrides,
67
+ )
68
+
69
+
70
+ def build_extensions_parser() -> argparse.ArgumentParser:
71
+ parser = argparse.ArgumentParser(prog="windcode extensions")
72
+ parser.add_argument(
73
+ "action", choices=("list", "inspect", "install", "enable", "disable", "reload", "trust")
74
+ )
75
+ parser.add_argument("target", nargs="?")
76
+ parser.add_argument("--workspace", type=Path, default=Path.cwd())
77
+ parser.add_argument("--config", type=Path)
78
+ parser.add_argument("--enable", action="store_true", help="enable a plugin while installing")
79
+ parser.add_argument("--untrust", action="store_true", help="remove workspace trust")
80
+ parser.add_argument("--json", action="store_true", dest="json_output")
81
+ return parser
82
+
83
+
84
+ async def _run_extensions(argv: Sequence[str]) -> int:
85
+ namespace = build_extensions_parser().parse_args(argv)
86
+ workspace: Path = namespace.workspace.expanduser().resolve()
87
+ if not workspace.is_dir(): # noqa: ASYNC240 - CLI setup happens before concurrent work
88
+ raise ConfigError(workspace, "workspace is not a directory")
89
+ config = load_config(workspace, explicit_file=namespace.config)
90
+ from windcode.sdk import Windcode
91
+
92
+ async with Windcode.open(config, workspace=workspace) as client:
93
+ action = str(namespace.action)
94
+ target = None if namespace.target is None else str(namespace.target)
95
+ if action == "list":
96
+ value: object = await client.list_extensions()
97
+ elif action == "inspect":
98
+ if target is None:
99
+ raise ConfigError("extensions inspect", "TARGET is required")
100
+ value = await client.inspect_extension(target)
101
+ elif action == "install":
102
+ if target is None:
103
+ raise ConfigError("extensions install", "PATH is required")
104
+ value = await client.install_extension(Path(target), enable=bool(namespace.enable))
105
+ elif action in {"enable", "disable"}:
106
+ if target is None:
107
+ raise ConfigError(f"extensions {action}", "TARGET is required")
108
+ value = await client.set_extension_enabled(target, action == "enable")
109
+ elif action == "trust":
110
+ trust_target = (
111
+ workspace if target is None else Path(target).expanduser().resolve() # noqa: ASYNC240
112
+ )
113
+ value = await client.trust_extension_workspace(
114
+ trust_target, not bool(namespace.untrust)
115
+ )
116
+ else:
117
+ value = await client.reload_extensions()
118
+ if namespace.json_output:
119
+ from dataclasses import asdict, is_dataclass
120
+
121
+ payload = (
122
+ [asdict(item) for item in value]
123
+ if isinstance(value, tuple)
124
+ else asdict(value)
125
+ if is_dataclass(value)
126
+ else value
127
+ )
128
+ print(json.dumps(payload, default=str, sort_keys=True))
129
+ else:
130
+ if isinstance(value, tuple):
131
+ for item in value:
132
+ print(getattr(item, "capability_id", str(item)))
133
+ else:
134
+ print(value)
135
+ return 0
136
+
137
+
138
+ def run(argv: Sequence[str] | None = None) -> int:
139
+ arguments = tuple(sys.argv[1:] if argv is None else argv)
140
+ if arguments and arguments[0] == "extensions":
141
+ try:
142
+ return asyncio.run(_run_extensions(arguments[1:]))
143
+ except ConfigError as exc:
144
+ print(f"windcode: {exc}", file=sys.stderr)
145
+ return 2
146
+ except KeyError as exc:
147
+ print(f"windcode: {exc}", file=sys.stderr)
148
+ return 3
149
+ except ValueError as exc:
150
+ print(f"windcode: {exc}", file=sys.stderr)
151
+ return 4
152
+ except OSError as exc:
153
+ print(f"windcode: {exc}", file=sys.stderr)
154
+ return 5
155
+ try:
156
+ options = parse_options(arguments)
157
+ if not options.workspace.is_dir():
158
+ raise ConfigError(options.workspace, "workspace is not a directory")
159
+ config = resolve_config(options)
160
+ except ConfigError as exc:
161
+ print(f"windcode: {exc}", file=sys.stderr)
162
+ return 2
163
+
164
+ from windcode.tui import WindcodeApp
165
+
166
+ app = WindcodeApp(
167
+ config,
168
+ workspace=options.workspace,
169
+ model=options.model,
170
+ session_id=options.resume_session,
171
+ permission_mode=(
172
+ options.permission_mode.value if options.permission_mode is not None else None
173
+ ),
174
+ config_file=options.config_file,
175
+ )
176
+ app.run()
177
+ return 0
178
+
179
+
180
+ def main(argv: Sequence[str] | None = None) -> None:
181
+ raise SystemExit(run(argv))
@@ -0,0 +1,41 @@
1
+ from windcode.config.loader import ConfigError, load_config
2
+ from windcode.config.models import (
3
+ HARD_MAX_CONCURRENT_SUBAGENTS,
4
+ HARD_MAX_SUBAGENT_TASKS,
5
+ AppConfig,
6
+ BudgetConfig,
7
+ ContextConfig,
8
+ DelegationMode,
9
+ MemoryConfig,
10
+ PermissionConfig,
11
+ PermissionMode,
12
+ ProviderConfig,
13
+ ProviderProtocol,
14
+ SandboxConfig,
15
+ StorageConfig,
16
+ SubagentConfig,
17
+ TraceConfig,
18
+ )
19
+ from windcode.config.writer import save_memory_config, save_model_config
20
+
21
+ __all__ = [
22
+ "HARD_MAX_CONCURRENT_SUBAGENTS",
23
+ "HARD_MAX_SUBAGENT_TASKS",
24
+ "AppConfig",
25
+ "BudgetConfig",
26
+ "ConfigError",
27
+ "ContextConfig",
28
+ "DelegationMode",
29
+ "MemoryConfig",
30
+ "PermissionConfig",
31
+ "PermissionMode",
32
+ "ProviderConfig",
33
+ "ProviderProtocol",
34
+ "SandboxConfig",
35
+ "StorageConfig",
36
+ "SubagentConfig",
37
+ "TraceConfig",
38
+ "load_config",
39
+ "save_memory_config",
40
+ "save_model_config",
41
+ ]
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import tomllib
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any, cast
8
+
9
+ from platformdirs import user_config_path
10
+ from pydantic import ValidationError
11
+
12
+ from windcode.config.models import AppConfig
13
+
14
+
15
+ class ConfigError(ValueError):
16
+ def __init__(self, source: Path | str, message: str) -> None:
17
+ self.source = source
18
+ super().__init__(f"{source}: {message}")
19
+
20
+
21
+ def _deep_merge(base: dict[str, Any], overlay: Mapping[str, Any]) -> dict[str, Any]:
22
+ merged = copy.deepcopy(base)
23
+ for key, value in overlay.items():
24
+ previous = merged.get(key)
25
+ if isinstance(previous, dict) and isinstance(value, Mapping):
26
+ merged[key] = _deep_merge(
27
+ cast(dict[str, Any], previous), cast(Mapping[str, Any], value)
28
+ )
29
+ else:
30
+ merged[key] = copy.deepcopy(value)
31
+ return merged
32
+
33
+
34
+ def _read_toml(path: Path, *, required: bool) -> dict[str, Any]:
35
+ if not path.exists():
36
+ if required:
37
+ raise ConfigError(path, "configuration file does not exist")
38
+ return {}
39
+ try:
40
+ with path.open("rb") as stream:
41
+ return tomllib.load(stream)
42
+ except (OSError, tomllib.TOMLDecodeError) as exc:
43
+ raise ConfigError(path, str(exc)) from exc
44
+
45
+
46
+ def load_config(
47
+ workspace: Path,
48
+ *,
49
+ user_file: Path | None = None,
50
+ project_file: Path | None = None,
51
+ explicit_file: Path | None = None,
52
+ overrides: Mapping[str, Any] | None = None,
53
+ ) -> AppConfig:
54
+ workspace = workspace.expanduser().resolve()
55
+ default_user = user_config_path("windcode") / "config.toml"
56
+ default_project = workspace / ".windcode" / "config.toml"
57
+ layers: list[tuple[Path | str, dict[str, Any]]] = [
58
+ (
59
+ user_file or default_user,
60
+ _read_toml(user_file or default_user, required=user_file is not None),
61
+ ),
62
+ (
63
+ project_file or default_project,
64
+ _read_toml(project_file or default_project, required=project_file is not None),
65
+ ),
66
+ ]
67
+ if explicit_file is not None:
68
+ layers.append((explicit_file, _read_toml(explicit_file, required=True)))
69
+ if overrides is not None:
70
+ layers.append(("explicit overrides", dict(overrides)))
71
+
72
+ merged: dict[str, Any] = {}
73
+ project_mcp_servers: set[str] = set()
74
+ disabled_aliases: set[str] = set()
75
+ last_source: Path | str = "built-in defaults"
76
+ for layer_index, (source, layer) in enumerate(layers):
77
+ if layer:
78
+ if layer_index == 1:
79
+ raw_extensions = layer.get("extensions")
80
+ if isinstance(raw_extensions, Mapping):
81
+ extension_values = cast(Mapping[object, object], raw_extensions)
82
+ raw_mcp = extension_values.get("mcp_servers")
83
+ if isinstance(raw_mcp, Mapping):
84
+ mcp_values = cast(Mapping[object, object], raw_mcp)
85
+ project_mcp_servers.update(str(server_id) for server_id in mcp_values)
86
+ raw_disabled = layer.get("disabled_providers", [])
87
+ raw_enabled = layer.get("enabled_providers", [])
88
+ if not isinstance(raw_disabled, list) or not isinstance(raw_enabled, list):
89
+ raise ConfigError(source, "provider enable/disable lists must be arrays")
90
+ disabled_aliases.update(str(alias) for alias in cast(list[object], raw_disabled))
91
+ disabled_aliases.difference_update(
92
+ str(alias) for alias in cast(list[object], raw_enabled)
93
+ )
94
+ merged = _deep_merge(merged, layer)
95
+ last_source = source
96
+ merged.pop("disabled_providers", None)
97
+ merged.pop("enabled_providers", None)
98
+ if project_mcp_servers:
99
+ raw_extensions = merged.setdefault("extensions", {})
100
+ if isinstance(raw_extensions, dict):
101
+ raw_extensions["project_mcp_servers"] = sorted(project_mcp_servers)
102
+ if disabled_aliases and isinstance(merged.get("providers"), dict):
103
+ providers = cast(dict[str, Any], merged["providers"])
104
+ for alias in disabled_aliases:
105
+ providers.pop(alias, None)
106
+ if merged.get("primary_provider") in disabled_aliases:
107
+ merged.pop("primary_provider", None)
108
+ fallback = merged.get("fallback_chain")
109
+ if isinstance(fallback, list):
110
+ fallback_values = cast(list[object], fallback)
111
+ merged["fallback_chain"] = [
112
+ alias for alias in fallback_values if str(alias) not in disabled_aliases
113
+ ]
114
+ try:
115
+ return AppConfig.model_validate(merged)
116
+ except ValidationError as exc:
117
+ raise ConfigError(last_source, str(exc)) from exc
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Annotated, Literal, Self
5
+
6
+ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
7
+
8
+
9
+ class StrictModel(BaseModel):
10
+ model_config = ConfigDict(extra="forbid", frozen=True)
11
+
12
+
13
+ class ProviderProtocol(StrEnum):
14
+ ANTHROPIC_MESSAGES = "anthropic_messages"
15
+ OPENAI_RESPONSES = "openai_responses"
16
+ OPENAI_COMPATIBLE = "openai_compatible"
17
+
18
+
19
+ class ProviderConfig(StrictModel):
20
+ protocol: ProviderProtocol
21
+ model: str = Field(min_length=1)
22
+ provider_id: str | None = Field(default=None, min_length=1, pattern=r"^[a-z0-9][a-z0-9_-]*$")
23
+ api_key_env: str | None = Field(default=None, min_length=1, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
24
+ credential_id: str | None = Field(
25
+ default=None, min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$"
26
+ )
27
+ base_url: str | None = None
28
+
29
+ @model_validator(mode="after")
30
+ def validate_credentials(self) -> Self:
31
+ if self.api_key_env is None and self.credential_id is None:
32
+ raise ValueError("provider requires api_key_env or credential_id")
33
+ return self
34
+
35
+
36
+ class BudgetConfig(StrictModel):
37
+ max_model_steps: int = Field(default=40, ge=1)
38
+ max_tool_calls: int = Field(default=100, ge=1)
39
+ max_runtime_seconds: float = Field(default=1800.0, gt=0)
40
+ shell_timeout_seconds: float = Field(default=120.0, gt=0)
41
+
42
+
43
+ HARD_MAX_SUBAGENT_TASKS = 16
44
+ HARD_MAX_CONCURRENT_SUBAGENTS = 8
45
+
46
+
47
+ class DelegationMode(StrEnum):
48
+ EXPLICIT = "explicit"
49
+ PROACTIVE = "proactive"
50
+
51
+
52
+ class SubagentConfig(StrictModel):
53
+ mode: DelegationMode = DelegationMode.EXPLICIT
54
+ max_tasks: int = Field(default=8, ge=1, le=HARD_MAX_SUBAGENT_TASKS)
55
+ max_concurrent: int = Field(default=4, ge=1, le=HARD_MAX_CONCURRENT_SUBAGENTS)
56
+ max_model_steps: int = Field(default=20, ge=1)
57
+ max_tool_calls: int = Field(default=50, ge=1)
58
+ max_runtime_seconds: float = Field(default=900.0, gt=0)
59
+ max_total_model_steps: int = Field(default=80, ge=1)
60
+ max_total_tool_calls: int = Field(default=200, ge=1)
61
+
62
+ @model_validator(mode="after")
63
+ def validate_aggregate_limits(self) -> Self:
64
+ if self.max_concurrent > self.max_tasks:
65
+ raise ValueError("max_concurrent cannot exceed max_tasks")
66
+ if self.max_total_model_steps < self.max_model_steps:
67
+ raise ValueError("max_total_model_steps cannot be below max_model_steps")
68
+ if self.max_total_tool_calls < self.max_tool_calls:
69
+ raise ValueError("max_total_tool_calls cannot be below max_tool_calls")
70
+ return self
71
+
72
+
73
+ class PermissionMode(StrEnum):
74
+ PLAN = "plan"
75
+ DEFAULT = "default"
76
+ ACCEPT_EDITS = "accept_edits"
77
+ FULL_ACCESS = "full_access"
78
+
79
+
80
+ class PermissionConfig(StrictModel):
81
+ mode: PermissionMode = PermissionMode.DEFAULT
82
+
83
+
84
+ class SandboxConfig(StrictModel):
85
+ enabled: bool = True
86
+ network_enabled: bool = False
87
+
88
+
89
+ class ContextConfig(StrictModel):
90
+ window_tokens: int = Field(default=128_000, ge=1_024)
91
+ compaction_threshold: float = Field(default=0.8, gt=0.0, lt=1.0)
92
+ preserve_recent_turns: int = Field(default=8, ge=1)
93
+ max_tool_result_chars: int = Field(default=20_000, ge=1_000)
94
+
95
+
96
+ class TraceConfig(StrictModel):
97
+ enabled: bool = True
98
+ include_tool_arguments: bool = False
99
+ include_transient_events: bool = False
100
+ retention_days: int = Field(default=14, ge=1, le=3_650)
101
+ max_total_mb: int = Field(default=100, ge=1, le=100_000)
102
+
103
+
104
+ class StorageConfig(StrictModel):
105
+ project_state_root: str | None = None
106
+ user_storage_root: str | None = None
107
+
108
+
109
+ class MemoryConfig(StrictModel):
110
+ enabled: bool = True
111
+ extraction_enabled: bool = True
112
+ recall_limit: int = Field(default=5, ge=1, le=20)
113
+ recall_max_chars: int = Field(default=12_000, ge=1_000, le=100_000)
114
+ baseline_max_records: int = Field(default=30, ge=1, le=200)
115
+ baseline_max_chars: int = Field(default=6_000, ge=1_000, le=100_000)
116
+ extraction_max_chars: int = Field(default=20_000, ge=1_000, le=200_000)
117
+ extraction_max_output_tokens: int = Field(default=600, ge=128, le=4_096)
118
+ candidate_retention_days: int = Field(default=90, ge=1, le=3_650)
119
+ stale_after_days: int = Field(default=30, ge=1, le=3_650)
120
+ user_profile_enabled: bool = True
121
+ project_knowledge_enabled: bool = True
122
+ experience_enabled: bool = True
123
+ sop_enabled: bool = True
124
+ reference_enabled: bool = True
125
+
126
+
127
+ class EnvironmentReference(StrictModel):
128
+ env: str = Field(min_length=1, pattern=r"^[A-Za-z_][A-Za-z0-9_]*$")
129
+
130
+
131
+ class CredentialReference(StrictModel):
132
+ credential: str = Field(min_length=1, pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
133
+
134
+
135
+ SecretReference = Annotated[
136
+ EnvironmentReference | CredentialReference, Field(union_mode="left_to_right")
137
+ ]
138
+
139
+
140
+ class McpStdioConfig(StrictModel):
141
+ transport: Literal["stdio"] = "stdio"
142
+ enabled: bool = Field(default=True, validation_alias=AliasChoices("enable", "enabled"))
143
+ command: str = Field(min_length=1)
144
+ args: tuple[str, ...] = ()
145
+ cwd: str | None = Field(default=None, min_length=1)
146
+ env: dict[str, SecretReference] = Field(default_factory=dict[str, SecretReference])
147
+ required: bool = False
148
+
149
+
150
+ class McpHttpConfig(StrictModel):
151
+ transport: Literal["streamable_http"]
152
+ enabled: bool = Field(default=True, validation_alias=AliasChoices("enable", "enabled"))
153
+ url: str = Field(min_length=1, pattern=r"^https?://")
154
+ headers: dict[str, SecretReference] = Field(default_factory=dict[str, SecretReference])
155
+ required: bool = False
156
+
157
+
158
+ McpServerConfig = Annotated[McpStdioConfig | McpHttpConfig, Field(union_mode="left_to_right")]
159
+
160
+
161
+ class ExtensionConfig(StrictModel):
162
+ enabled: bool = False
163
+ direct_tool_limit: int = Field(default=24, ge=0, le=256)
164
+ connect_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
165
+ call_timeout_seconds: float = Field(default=60.0, gt=0, le=3600)
166
+ hook_timeout_seconds: float = Field(default=10.0, gt=0, le=300)
167
+ max_metadata_bytes: int = Field(default=65_536, ge=1_024, le=1_048_576)
168
+ max_content_bytes: int = Field(default=1_048_576, ge=1_024, le=16_777_216)
169
+ max_scan_depth: int = Field(default=8, ge=1, le=32)
170
+ max_scan_entries: int = Field(default=10_000, ge=1, le=100_000)
171
+ skill_roots: tuple[str, ...] = ()
172
+ mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict[str, McpServerConfig])
173
+ project_mcp_servers: frozenset[str] = Field(default_factory=frozenset, exclude=True)
174
+
175
+
176
+ class AppConfig(StrictModel):
177
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict[str, ProviderConfig])
178
+ primary_provider: str | None = None
179
+ fallback_chain: tuple[str, ...] = ()
180
+ budgets: BudgetConfig = Field(default_factory=BudgetConfig)
181
+ permission: PermissionConfig = Field(default_factory=PermissionConfig)
182
+ sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
183
+ context: ContextConfig = Field(default_factory=ContextConfig)
184
+ trace: TraceConfig = Field(default_factory=TraceConfig)
185
+ storage: StorageConfig = Field(default_factory=StorageConfig)
186
+ memory: MemoryConfig = Field(default_factory=MemoryConfig)
187
+ subagents: SubagentConfig = Field(default_factory=SubagentConfig)
188
+ extensions: ExtensionConfig = Field(default_factory=ExtensionConfig)
189
+
190
+ @model_validator(mode="after")
191
+ def validate_provider_chain(self) -> Self:
192
+ if self.primary_provider is None:
193
+ if self.fallback_chain:
194
+ raise ValueError("fallback_chain requires primary_provider")
195
+ return self
196
+
197
+ chain = (self.primary_provider, *self.fallback_chain)
198
+ missing = [name for name in chain if name not in self.providers]
199
+ if missing:
200
+ raise ValueError(f"provider chain references unknown providers: {', '.join(missing)}")
201
+ if len(set(chain)) != len(chain):
202
+ raise ValueError("provider chain contains a duplicate or cycle")
203
+ return self