create-windy 0.2.20 → 0.2.21

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +84 -10
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/README.md +1 -0
  6. package/template/apps/agent-server/Dockerfile +20 -0
  7. package/template/apps/agent-server/README.md +46 -0
  8. package/template/apps/agent-server/pyproject.toml +44 -0
  9. package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
  13. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
  14. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
  15. package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
  18. package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
  23. package/template/apps/agent-server/tests/test_api.py +207 -0
  24. package/template/apps/agent-server/tests/test_config.py +56 -0
  25. package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
  26. package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
  27. package/template/apps/agent-server/uv.lock +925 -0
  28. package/template/apps/server/src/agent/execution-grants.ts +89 -0
  29. package/template/apps/server/src/agent/remote-runtime.ts +128 -0
  30. package/template/apps/server/src/agent/run-contracts.ts +43 -0
  31. package/template/apps/server/src/agent/run-facade.test.ts +257 -0
  32. package/template/apps/server/src/agent/run-facade.ts +190 -0
  33. package/template/apps/server/src/agent/run-routes.ts +222 -0
  34. package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
  35. package/template/apps/server/src/agent/tool-host.test.ts +242 -0
  36. package/template/apps/server/src/agent/tool-host.ts +153 -0
  37. package/template/apps/server/src/index.ts +12 -16
  38. package/template/apps/server/src/module-composition.test.ts +9 -0
  39. package/template/apps/server/src/module-composition.ts +13 -0
  40. package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
  41. package/template/apps/server/src/module-host/initialize.test.ts +69 -0
  42. package/template/apps/server/src/module-host/initialize.ts +50 -53
  43. package/template/apps/server/src/module-host.ts +1 -0
  44. package/template/apps/server/src/module-runtime-validation.ts +40 -0
  45. package/template/docker-compose.yml +25 -0
  46. package/template/docs/architecture/ai-runtime.md +744 -0
  47. package/template/docs/platform/agent-runtime.md +128 -0
  48. package/template/docs/platform/agent-tools.md +8 -1
  49. package/template/package.json +1 -0
  50. package/template/packages/config/index.test.ts +43 -0
  51. package/template/packages/config/index.ts +1 -0
  52. package/template/packages/config/package.json +2 -1
  53. package/template/packages/config/src/ai-openai-compatible.ts +131 -0
  54. package/template/packages/server-sdk/index.ts +11 -0
  55. package/template/packages/server-sdk/package.json +4 -2
  56. package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
  57. package/template/packages/server-sdk/src/module-host.ts +5 -0
  58. package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import os
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlsplit, urlunsplit
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class SecretRef:
12
+ provider: str
13
+ locator: str
14
+
15
+ def __str__(self) -> str:
16
+ return "[SecretRef]"
17
+
18
+
19
+ class EnvironmentSecretResolver:
20
+ def __init__(self, environment: Mapping[str, str]) -> None:
21
+ self._environment = environment
22
+
23
+ def resolve(self, ref: SecretRef) -> str | None:
24
+ if ref.provider != "env":
25
+ raise ValueError("Secret Provider 不匹配")
26
+ return self._environment.get(ref.locator)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class OpenAiCompatibleConfig:
31
+ base_url: str
32
+ model: str
33
+ api_key_ref: SecretRef | None
34
+ connect_timeout_seconds: float = 5.0
35
+ request_timeout_seconds: float = 60.0
36
+ max_response_bytes: int = 2 * 1024 * 1024
37
+ max_tool_rounds: int = 8
38
+
39
+ @classmethod
40
+ def from_environment(
41
+ cls,
42
+ environment: Mapping[str, str] = os.environ,
43
+ ) -> OpenAiCompatibleConfig | None:
44
+ base_url = environment.get("QWEN_API_URL", "").strip()
45
+ model = environment.get("QWEN_MODEL", "").strip()
46
+ if not base_url and not model:
47
+ return None
48
+ if not base_url or not model:
49
+ raise ValueError("OpenAI-compatible Endpoint 与 Model 必须同时配置")
50
+ return cls(
51
+ base_url=normalize_base_url(base_url),
52
+ model=model,
53
+ api_key_ref=(
54
+ SecretRef("env", "QWEN_API_KEY")
55
+ if environment.get("QWEN_API_KEY", "").strip()
56
+ else None
57
+ ),
58
+ connect_timeout_seconds=read_milliseconds(
59
+ environment,
60
+ "AI_PROVIDER_CONNECT_TIMEOUT_MS",
61
+ 5_000,
62
+ 100,
63
+ 30_000,
64
+ )
65
+ / 1_000,
66
+ request_timeout_seconds=read_milliseconds(
67
+ environment,
68
+ "AI_PROVIDER_REQUEST_TIMEOUT_MS",
69
+ 60_000,
70
+ 1_000,
71
+ 300_000,
72
+ )
73
+ / 1_000,
74
+ max_response_bytes=read_integer(
75
+ environment,
76
+ "AI_PROVIDER_MAX_RESPONSE_BYTES",
77
+ 2 * 1024 * 1024,
78
+ 1_024,
79
+ 16 * 1024 * 1024,
80
+ ),
81
+ )
82
+
83
+
84
+ def normalize_base_url(raw: str) -> str:
85
+ parsed = urlsplit(raw.strip())
86
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
87
+ raise ValueError("OpenAI-compatible Endpoint 无效")
88
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
89
+ raise ValueError("OpenAI-compatible Endpoint 不允许凭据、查询串或片段")
90
+ hostname = parsed.hostname.lower()
91
+ if hostname in {
92
+ "169.254.169.254",
93
+ "metadata.google.internal",
94
+ "metadata.azure.internal",
95
+ }:
96
+ raise ValueError("OpenAI-compatible Endpoint 禁止访问云 metadata")
97
+ if parsed.scheme == "http" and not private_hostname(hostname):
98
+ raise ValueError("公网 OpenAI-compatible Endpoint 必须使用 HTTPS")
99
+ path = parsed.path.rstrip("/")
100
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
101
+
102
+
103
+ def private_hostname(hostname: str) -> bool:
104
+ if (
105
+ hostname == "localhost"
106
+ or hostname.endswith((".localhost", ".internal", ".local"))
107
+ or "." not in hostname
108
+ ):
109
+ return True
110
+ try:
111
+ address = ipaddress.ip_address(hostname)
112
+ except ValueError:
113
+ return False
114
+ return address.is_private or address.is_loopback
115
+
116
+
117
+ def read_milliseconds(
118
+ environment: Mapping[str, str],
119
+ key: str,
120
+ default: int,
121
+ minimum: int,
122
+ maximum: int,
123
+ ) -> int:
124
+ return read_integer(environment, key, default, minimum, maximum)
125
+
126
+
127
+ def read_integer(
128
+ environment: Mapping[str, str],
129
+ key: str,
130
+ default: int,
131
+ minimum: int,
132
+ maximum: int,
133
+ ) -> int:
134
+ raw = environment.get(key)
135
+ try:
136
+ value = default if raw is None else int(raw)
137
+ except ValueError as error:
138
+ raise ValueError(f"{key} 必须是整数") from error
139
+ if not minimum <= value <= maximum:
140
+ raise ValueError(f"{key} 超出允许范围")
141
+ return value
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ type JsonValue = (
8
+ str | int | float | bool | None | list[JsonValue] | dict[str, JsonValue]
9
+ )
10
+ type RunStatus = Literal[
11
+ "accepted",
12
+ "running",
13
+ "awaiting-approval",
14
+ "completed",
15
+ "failed",
16
+ "cancelled",
17
+ ]
18
+ type AgentErrorCode = Literal[
19
+ "OPERATION_NOT_FOUND",
20
+ "OPERATION_DISABLED",
21
+ "INVALID_INPUT",
22
+ "FORBIDDEN",
23
+ "LICENSE_NOT_ALLOWED",
24
+ "RUNTIME_UNAVAILABLE",
25
+ "MODEL_ROUTE_UNAVAILABLE",
26
+ "PROVIDER_UNAVAILABLE",
27
+ "RATE_LIMITED",
28
+ "BUDGET_EXCEEDED",
29
+ "TOOL_DENIED",
30
+ "APPROVAL_REQUIRED",
31
+ "OUTPUT_VALIDATION_FAILED",
32
+ "RUN_CONFLICT",
33
+ "RUN_TIMEOUT",
34
+ "RUN_CANCELLED",
35
+ "STREAM_INTERRUPTED",
36
+ ]
37
+
38
+
39
+ def to_camel(value: str) -> str:
40
+ first, *rest = value.split("_")
41
+ return first + "".join(part.capitalize() for part in rest)
42
+
43
+
44
+ class CanonicalModel(BaseModel):
45
+ model_config = ConfigDict(
46
+ alias_generator=to_camel,
47
+ populate_by_name=True,
48
+ extra="forbid",
49
+ )
50
+
51
+
52
+ class RuntimeToolDefinition(CanonicalModel):
53
+ key: str = Field(min_length=1)
54
+ label: str = Field(min_length=1)
55
+ description: str = Field(min_length=1)
56
+
57
+
58
+ class RuntimeLimits(CanonicalModel):
59
+ max_duration_seconds: int = Field(default=60, ge=1, le=300)
60
+ max_output_bytes: int = Field(default=1_048_576, ge=1_024, le=16_777_216)
61
+
62
+
63
+ class StartRunRequest(CanonicalModel):
64
+ operation_key: str = Field(min_length=1)
65
+ input: JsonValue
66
+ instructions: str = ""
67
+ user_prompt: str = ""
68
+ allowed_tools: list[RuntimeToolDefinition] = Field(default_factory=list)
69
+ limits: RuntimeLimits = Field(default_factory=RuntimeLimits)
70
+ execution_grant: str = ""
71
+ tool_host_url: str = ""
72
+
73
+
74
+ class StartRunResponse(CanonicalModel):
75
+ run_id: str
76
+ status: RunStatus
77
+ events_url: str
78
+
79
+
80
+ class RunResultResponse(CanonicalModel):
81
+ run_id: str
82
+ status: Literal["completed"]
83
+ output: JsonValue
84
+
85
+
86
+ class CancelRunRequest(CanonicalModel):
87
+ reason: str | None = None
88
+
89
+
90
+ class CancelRunResponse(CanonicalModel):
91
+ run_id: str
92
+ status: RunStatus
93
+
94
+
95
+ class AgentError(CanonicalModel):
96
+ code: AgentErrorCode
97
+ message: str
98
+ request_id: str
99
+ retryable: bool
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+
6
+ from .engine import (
7
+ EngineCompleted,
8
+ EngineRunRequest,
9
+ EngineSignal,
10
+ OutputDelta,
11
+ UsageReported,
12
+ )
13
+
14
+
15
+ class DeterministicEngineAdapter:
16
+ """Deterministic test adapter. It never calls a model or remote service."""
17
+
18
+ operations = frozenset({"test.echo", "test.wait", "test.fail"})
19
+
20
+ def __init__(self) -> None:
21
+ self._cancellations: dict[str, asyncio.Event] = {}
22
+
23
+ def supports(self, operation_key: str) -> bool:
24
+ return operation_key in self.operations
25
+
26
+ async def execute(
27
+ self,
28
+ request: EngineRunRequest,
29
+ ) -> AsyncIterator[EngineSignal]:
30
+ cancellation = self._cancellations.setdefault(request.run_id, asyncio.Event())
31
+ try:
32
+ if request.operation_key == "test.fail":
33
+ raise RuntimeError("upstream-secret-body")
34
+ if request.operation_key == "test.wait":
35
+ await cancellation.wait()
36
+ return
37
+
38
+ text = "deterministic"
39
+ if isinstance(request.input, dict):
40
+ input_text = request.input.get("text")
41
+ if isinstance(input_text, str):
42
+ text = input_text
43
+ yield OutputDelta(text)
44
+ yield UsageReported({"source": "unknown"})
45
+ yield EngineCompleted(request.input)
46
+ finally:
47
+ self._cancellations.pop(request.run_id, None)
48
+
49
+ async def cancel(self, run_id: str) -> None:
50
+ cancellation = self._cancellations.get(run_id)
51
+ if cancellation is not None:
52
+ cancellation.set()
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator
4
+ from dataclasses import dataclass, field
5
+ from typing import Protocol
6
+
7
+ from .contracts import AgentErrorCode, JsonValue, RuntimeLimits, RuntimeToolDefinition
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class EngineRunRequest:
12
+ run_id: str
13
+ operation_key: str
14
+ input: JsonValue
15
+ instructions: str = ""
16
+ user_prompt: str = ""
17
+ allowed_tools: tuple[RuntimeToolDefinition, ...] = ()
18
+ limits: RuntimeLimits = field(default_factory=RuntimeLimits)
19
+ execution_grant: str = ""
20
+ tool_host_url: str = ""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class OutputDelta:
25
+ text: str
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ToolRequested:
30
+ tool_call_id: str
31
+ tool_key: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ApprovalRequired:
36
+ work_item_id: str
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ToolCompleted:
41
+ tool_call_id: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class UsageReported:
46
+ usage: dict[str, JsonValue]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class EngineCompleted:
51
+ output: JsonValue
52
+
53
+
54
+ type EngineSignal = (
55
+ OutputDelta
56
+ | ToolRequested
57
+ | ApprovalRequired
58
+ | ToolCompleted
59
+ | UsageReported
60
+ | EngineCompleted
61
+ )
62
+
63
+
64
+ class SafeEngineError(Exception):
65
+ def __init__(
66
+ self,
67
+ code: AgentErrorCode,
68
+ message: str,
69
+ *,
70
+ retryable: bool,
71
+ ) -> None:
72
+ super().__init__(message)
73
+ self.code = code
74
+ self.safe_message = message
75
+ self.retryable = retryable
76
+
77
+
78
+ class AgentEngineAdapter(Protocol):
79
+ """Internal engine seam. HTTP DTO and ADK types must not cross it."""
80
+
81
+ def supports(self, operation_key: str) -> bool: ...
82
+
83
+ def execute(self, request: EngineRunRequest) -> AsyncIterator[EngineSignal]: ...
84
+
85
+ async def cancel(self, run_id: str) -> None: ...
@@ -0,0 +1,197 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import AsyncIterator
6
+
7
+ from .contracts import JsonValue
8
+ from .engine import (
9
+ AgentEngineAdapter,
10
+ EngineCompleted,
11
+ EngineRunRequest,
12
+ EngineSignal,
13
+ OutputDelta,
14
+ SafeEngineError,
15
+ ToolCompleted,
16
+ ToolRequested,
17
+ UsageReported,
18
+ )
19
+ from .openai_provider import OpenAiCompatibleProvider, ProviderToolCall
20
+ from .tool_host import ToolHostClient, ToolInvocation
21
+
22
+
23
+ class OpenAiCompatibleEngineAdapter(AgentEngineAdapter):
24
+ def __init__(
25
+ self,
26
+ provider: OpenAiCompatibleProvider,
27
+ tool_host: ToolHostClient,
28
+ ) -> None:
29
+ self._provider = provider
30
+ self._tool_host = tool_host
31
+ self._cancellations: dict[str, asyncio.Event] = {}
32
+
33
+ def supports(self, operation_key: str) -> bool:
34
+ return bool(operation_key.strip())
35
+
36
+ async def execute(
37
+ self,
38
+ request: EngineRunRequest,
39
+ ) -> AsyncIterator[EngineSignal]:
40
+ validate_runtime_request(request)
41
+ cancellation = self._cancellations.setdefault(
42
+ request.run_id,
43
+ asyncio.Event(),
44
+ )
45
+ allowed = {tool.key for tool in request.allowed_tools}
46
+ messages: list[dict[str, JsonValue]] = [
47
+ {"role": "system", "content": request.instructions},
48
+ {"role": "user", "content": request.user_prompt},
49
+ ]
50
+ output_bytes = 0
51
+ try:
52
+ for _round in range(self._provider.config.max_tool_rounds):
53
+ cancelled(cancellation)
54
+ turn = await self._provider.complete_turn(
55
+ messages,
56
+ request.allowed_tools,
57
+ )
58
+ if turn.tool_calls:
59
+ messages.append(assistant_tool_message(turn.tool_calls))
60
+ async for signal in self._execute_tools(
61
+ request,
62
+ turn.tool_calls,
63
+ allowed,
64
+ messages,
65
+ cancellation,
66
+ ):
67
+ yield signal
68
+ continue
69
+ for delta in turn.text_deltas:
70
+ output_bytes += len(delta.encode())
71
+ if output_bytes > request.limits.max_output_bytes:
72
+ raise SafeEngineError(
73
+ "BUDGET_EXCEEDED",
74
+ "Agent 输出超过 Operation 上限",
75
+ retryable=False,
76
+ )
77
+ yield OutputDelta(delta)
78
+ yield UsageReported(turn.usage or {"source": "unknown"})
79
+ yield EngineCompleted({"text": turn.text})
80
+ return
81
+ raise SafeEngineError(
82
+ "BUDGET_EXCEEDED",
83
+ "Agent Tool 调用轮次超过上限",
84
+ retryable=False,
85
+ )
86
+ finally:
87
+ self._cancellations.pop(request.run_id, None)
88
+
89
+ async def _execute_tools(
90
+ self,
91
+ request: EngineRunRequest,
92
+ calls: list[ProviderToolCall],
93
+ allowed: set[str],
94
+ messages: list[dict[str, JsonValue]],
95
+ cancellation: asyncio.Event,
96
+ ) -> AsyncIterator[EngineSignal]:
97
+ for call in calls:
98
+ cancelled(cancellation)
99
+ if not call.call_id or not call.name or call.name not in allowed:
100
+ raise SafeEngineError(
101
+ "TOOL_DENIED",
102
+ "模型请求了未授权 Tool",
103
+ retryable=False,
104
+ )
105
+ arguments = parse_arguments(call.arguments)
106
+ yield ToolRequested(call.call_id, call.name)
107
+ result = await self._tool_host.execute(
108
+ request.tool_host_url,
109
+ ToolInvocation(
110
+ request.run_id,
111
+ call.call_id,
112
+ call.name,
113
+ arguments,
114
+ request.execution_grant,
115
+ ),
116
+ )
117
+ yield ToolCompleted(call.call_id)
118
+ messages.append(
119
+ {
120
+ "role": "tool",
121
+ "tool_call_id": call.call_id,
122
+ "content": json.dumps(
123
+ result,
124
+ ensure_ascii=False,
125
+ separators=(",", ":"),
126
+ ),
127
+ }
128
+ )
129
+
130
+ async def cancel(self, run_id: str) -> None:
131
+ event = self._cancellations.get(run_id)
132
+ if event is not None:
133
+ event.set()
134
+
135
+ async def health(self) -> None:
136
+ await self._provider.health()
137
+
138
+
139
+ def validate_runtime_request(request: EngineRunRequest) -> None:
140
+ if (
141
+ not request.instructions.strip()
142
+ or not request.user_prompt.strip()
143
+ or not request.execution_grant.strip()
144
+ or not request.tool_host_url.strip()
145
+ ):
146
+ raise SafeEngineError(
147
+ "INVALID_INPUT",
148
+ "Agent Operation 运行计划不完整",
149
+ retryable=False,
150
+ )
151
+
152
+
153
+ def cancelled(event: asyncio.Event) -> None:
154
+ if event.is_set():
155
+ raise SafeEngineError(
156
+ "RUN_CANCELLED",
157
+ "Agent Run 已取消",
158
+ retryable=False,
159
+ )
160
+
161
+
162
+ def parse_arguments(raw: str) -> dict[str, JsonValue]:
163
+ try:
164
+ value = json.loads(raw or "{}")
165
+ except json.JSONDecodeError:
166
+ raise SafeEngineError(
167
+ "TOOL_DENIED",
168
+ "Agent Tool 参数无效",
169
+ retryable=False,
170
+ ) from None
171
+ if not isinstance(value, dict):
172
+ raise SafeEngineError(
173
+ "TOOL_DENIED",
174
+ "Agent Tool 参数必须是对象",
175
+ retryable=False,
176
+ )
177
+ return value
178
+
179
+
180
+ def assistant_tool_message(
181
+ calls: list[ProviderToolCall],
182
+ ) -> dict[str, JsonValue]:
183
+ return {
184
+ "role": "assistant",
185
+ "content": None,
186
+ "tool_calls": [
187
+ {
188
+ "id": call.call_id,
189
+ "type": "function",
190
+ "function": {
191
+ "name": call.name,
192
+ "arguments": call.arguments,
193
+ },
194
+ }
195
+ for call in calls
196
+ ],
197
+ }