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,317 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import sqlite3
6
+ import time
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from .contracts import AgentError, AgentErrorCode, JsonValue, RunStatus
11
+ from .store import (
12
+ CanonicalEvent,
13
+ IdempotencyConflictError,
14
+ RunRecord,
15
+ status_for,
16
+ )
17
+
18
+
19
+ class RunStoreCapacityError(Exception):
20
+ pass
21
+
22
+
23
+ class SqliteRunStore:
24
+ """Single-replica durable Run store with bounded rows, events and output."""
25
+
26
+ def __init__(
27
+ self,
28
+ path: str,
29
+ *,
30
+ max_runs: int = 10_000,
31
+ max_events_per_run: int = 2_000,
32
+ max_output_bytes: int = 16 * 1024 * 1024,
33
+ terminal_ttl_seconds: int = 7 * 24 * 60 * 60,
34
+ ) -> None:
35
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
36
+ self._database = sqlite3.connect(path, check_same_thread=False)
37
+ self._database.row_factory = sqlite3.Row
38
+ self._max_runs = max_runs
39
+ self._max_events = max_events_per_run
40
+ self._max_output_bytes = max_output_bytes
41
+ self._terminal_ttl = terminal_ttl_seconds
42
+ self._condition = asyncio.Condition()
43
+ self._initialize()
44
+ self._recover_incomplete()
45
+
46
+ async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]:
47
+ async with self._condition:
48
+ existing = self._database.execute(
49
+ "select run_id from agent_runs where idempotency_key = ?",
50
+ (record.idempotency_key,),
51
+ ).fetchone()
52
+ if existing:
53
+ current = self._read(str(existing["run_id"]))
54
+ if current is None or current.fingerprint != record.fingerprint:
55
+ raise IdempotencyConflictError
56
+ return current, False
57
+ self._prune()
58
+ count = int(
59
+ self._database.execute("select count(*) from agent_runs").fetchone()[0]
60
+ )
61
+ if count >= self._max_runs:
62
+ raise RunStoreCapacityError("Agent Run Store 容量已满")
63
+ now = time.time()
64
+ self._database.execute(
65
+ """
66
+ insert into agent_runs (
67
+ run_id, operation_key, input_json, idempotency_key,
68
+ fingerprint, status, output_json, created_at, updated_at
69
+ ) values (?, ?, ?, ?, ?, 'accepted', 'null', ?, ?)
70
+ """,
71
+ (
72
+ record.run_id,
73
+ record.operation_key,
74
+ "null",
75
+ record.idempotency_key,
76
+ record.fingerprint,
77
+ now,
78
+ now,
79
+ ),
80
+ )
81
+ self._database.commit()
82
+ self._condition.notify_all()
83
+ return record, True
84
+
85
+ async def get(self, run_id: str) -> RunRecord | None:
86
+ async with self._condition:
87
+ return self._read(run_id)
88
+
89
+ async def append(
90
+ self,
91
+ run_id: str,
92
+ event_type: str,
93
+ fields: dict[str, object] | None = None,
94
+ ) -> CanonicalEvent | None:
95
+ async with self._condition:
96
+ record = self._read(run_id)
97
+ if record is None:
98
+ raise KeyError(run_id)
99
+ if record.terminal:
100
+ return None
101
+ sequence = len(record.events) + 1
102
+ if sequence >= self._max_events and not event_type.startswith("run."):
103
+ self._append_failure(run_id, sequence, "Agent 事件超过安全上限")
104
+ self._condition.notify_all()
105
+ raise RunStoreCapacityError("Agent 事件超过安全上限")
106
+ event: CanonicalEvent = {
107
+ "type": event_type,
108
+ "runId": run_id,
109
+ "sequence": sequence,
110
+ **(fields or {}),
111
+ }
112
+ self._insert_event(run_id, event, status_for(event_type, record.status))
113
+ self._condition.notify_all()
114
+ return event
115
+
116
+ async def complete(self, run_id: str, output: JsonValue) -> None:
117
+ async with self._condition:
118
+ record = self._read(run_id)
119
+ if record is None:
120
+ raise KeyError(run_id)
121
+ if record.terminal:
122
+ return
123
+ encoded = encode(output)
124
+ sequence = len(record.events) + 1
125
+ if len(encoded.encode()) > self._max_output_bytes:
126
+ self._append_failure(run_id, sequence, "Agent 输出超过安全上限")
127
+ self._condition.notify_all()
128
+ return
129
+ event: CanonicalEvent = {
130
+ "type": "run.completed",
131
+ "runId": run_id,
132
+ "sequence": sequence,
133
+ }
134
+ self._insert_event(run_id, event, "completed", encoded)
135
+ self._condition.notify_all()
136
+
137
+ async def events_after(
138
+ self,
139
+ run_id: str,
140
+ after: int,
141
+ wait_seconds: float,
142
+ ) -> tuple[list[CanonicalEvent], bool]:
143
+ async with self._condition:
144
+
145
+ def ready() -> bool:
146
+ record = self._read(run_id)
147
+ return bool(
148
+ record
149
+ and (
150
+ any(sequence_of(event) > after for event in record.events)
151
+ or record.terminal
152
+ )
153
+ )
154
+
155
+ if not ready():
156
+ try:
157
+ await asyncio.wait_for(
158
+ self._condition.wait_for(ready),
159
+ wait_seconds,
160
+ )
161
+ except TimeoutError:
162
+ return [], False
163
+ record = self._read(run_id)
164
+ if record is None:
165
+ raise KeyError(run_id)
166
+ return (
167
+ [event for event in record.events if sequence_of(event) > after],
168
+ record.terminal,
169
+ )
170
+
171
+ def close(self) -> None:
172
+ self._database.close()
173
+
174
+ def _initialize(self) -> None:
175
+ self._database.executescript(
176
+ """
177
+ create table if not exists agent_runs (
178
+ run_id text primary key,
179
+ operation_key text not null,
180
+ input_json text not null,
181
+ idempotency_key text not null unique,
182
+ fingerprint text not null,
183
+ status text not null,
184
+ output_json text not null,
185
+ created_at real not null,
186
+ updated_at real not null
187
+ );
188
+ create table if not exists agent_events (
189
+ run_id text not null references agent_runs(run_id) on delete cascade,
190
+ sequence integer not null,
191
+ event_json text not null,
192
+ primary key (run_id, sequence)
193
+ );
194
+ """
195
+ )
196
+ self._database.commit()
197
+
198
+ def _recover_incomplete(self) -> None:
199
+ rows = self._database.execute(
200
+ """
201
+ select run_id from agent_runs
202
+ where status not in ('completed', 'failed', 'cancelled')
203
+ """
204
+ ).fetchall()
205
+ for row in rows:
206
+ run_id = str(row["run_id"])
207
+ sequence = (
208
+ int(
209
+ self._database.execute(
210
+ "select count(*) from agent_events where run_id = ?",
211
+ (run_id,),
212
+ ).fetchone()[0]
213
+ )
214
+ + 1
215
+ )
216
+ self._append_failure(
217
+ run_id,
218
+ sequence,
219
+ "Agent Runtime 重启中断了 Run",
220
+ code="RUNTIME_UNAVAILABLE",
221
+ retryable=True,
222
+ )
223
+ self._database.commit()
224
+
225
+ def _read(self, run_id: str) -> RunRecord | None:
226
+ row = self._database.execute(
227
+ "select * from agent_runs where run_id = ?",
228
+ (run_id,),
229
+ ).fetchone()
230
+ if not row:
231
+ return None
232
+ events = [
233
+ json.loads(str(event["event_json"]))
234
+ for event in self._database.execute(
235
+ """
236
+ select event_json from agent_events
237
+ where run_id = ? order by sequence
238
+ """,
239
+ (run_id,),
240
+ )
241
+ ]
242
+ return RunRecord(
243
+ run_id=str(row["run_id"]),
244
+ operation_key=str(row["operation_key"]),
245
+ input=json.loads(str(row["input_json"])),
246
+ idempotency_key=str(row["idempotency_key"]),
247
+ fingerprint=str(row["fingerprint"]),
248
+ status=cast(RunStatus, str(row["status"])),
249
+ events=events,
250
+ output=json.loads(str(row["output_json"])),
251
+ )
252
+
253
+ def _insert_event(
254
+ self,
255
+ run_id: str,
256
+ event: CanonicalEvent,
257
+ status: RunStatus,
258
+ output_json: str | None = None,
259
+ ) -> None:
260
+ self._database.execute(
261
+ "insert into agent_events values (?, ?, ?)",
262
+ (run_id, event["sequence"], encode(event)),
263
+ )
264
+ self._database.execute(
265
+ """
266
+ update agent_runs
267
+ set status = ?, output_json = coalesce(?, output_json), updated_at = ?
268
+ where run_id = ?
269
+ """,
270
+ (status, output_json, time.time(), run_id),
271
+ )
272
+ self._database.commit()
273
+
274
+ def _append_failure(
275
+ self,
276
+ run_id: str,
277
+ sequence: int,
278
+ message: str,
279
+ *,
280
+ code: AgentErrorCode = "BUDGET_EXCEEDED",
281
+ retryable: bool = False,
282
+ ) -> None:
283
+ event: CanonicalEvent = {
284
+ "type": "run.failed",
285
+ "runId": run_id,
286
+ "sequence": sequence,
287
+ "error": AgentError(
288
+ code=code,
289
+ message=message,
290
+ request_id=f"request_store_{sequence}",
291
+ retryable=retryable,
292
+ ).model_dump(by_alias=True),
293
+ }
294
+ self._insert_event(run_id, event, "failed")
295
+
296
+ def _prune(self) -> None:
297
+ cutoff = time.time() - self._terminal_ttl
298
+ self._database.execute(
299
+ """
300
+ delete from agent_runs
301
+ where status in ('completed', 'failed', 'cancelled')
302
+ and updated_at < ?
303
+ """,
304
+ (cutoff,),
305
+ )
306
+ self._database.commit()
307
+
308
+
309
+ def encode(value: object) -> str:
310
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
311
+
312
+
313
+ def sequence_of(event: CanonicalEvent) -> int:
314
+ value = event.get("sequence")
315
+ if not isinstance(value, int):
316
+ raise RuntimeError("Agent Event sequence 无效")
317
+ return value
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+
6
+ from .service import AgentRunService
7
+
8
+
9
+ def encode_event(event: dict[str, object]) -> str:
10
+ sequence = event["sequence"]
11
+ event_type = event["type"]
12
+ data = json.dumps(event, ensure_ascii=False, separators=(",", ":"))
13
+ return f"id: {sequence}\nevent: {event_type}\ndata: {data}\n\n"
14
+
15
+
16
+ async def event_stream(
17
+ service: AgentRunService,
18
+ run_id: str,
19
+ after: int,
20
+ heartbeat_seconds: float,
21
+ ) -> AsyncIterator[str]:
22
+ cursor = after
23
+ while True:
24
+ events, terminal = await service.store.events_after(
25
+ run_id,
26
+ cursor,
27
+ heartbeat_seconds,
28
+ )
29
+ if not events:
30
+ if terminal:
31
+ return
32
+ yield ": heartbeat\n\n"
33
+ continue
34
+ for event in events:
35
+ sequence = event["sequence"]
36
+ if not isinstance(sequence, int):
37
+ raise RuntimeError("invalid stored sequence")
38
+ cursor = sequence
39
+ yield encode_event(event)
40
+ if terminal:
41
+ return
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass, field
5
+ from typing import Protocol
6
+
7
+ from .contracts import JsonValue, RunStatus
8
+
9
+ CanonicalEvent = dict[str, object]
10
+ TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
11
+
12
+
13
+ @dataclass
14
+ class RunRecord:
15
+ run_id: str
16
+ operation_key: str
17
+ input: JsonValue
18
+ idempotency_key: str
19
+ fingerprint: str
20
+ status: RunStatus = "accepted"
21
+ events: list[CanonicalEvent] = field(default_factory=list)
22
+ output: JsonValue = None
23
+
24
+ @property
25
+ def terminal(self) -> bool:
26
+ return self.status in TERMINAL_STATUSES
27
+
28
+
29
+ class IdempotencyConflictError(Exception):
30
+ pass
31
+
32
+
33
+ class RunStore(Protocol):
34
+ async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]: ...
35
+ async def get(self, run_id: str) -> RunRecord | None: ...
36
+ async def append(
37
+ self,
38
+ run_id: str,
39
+ event_type: str,
40
+ fields: dict[str, object] | None = None,
41
+ ) -> CanonicalEvent | None: ...
42
+ async def complete(self, run_id: str, output: JsonValue) -> None: ...
43
+ async def events_after(
44
+ self,
45
+ run_id: str,
46
+ after: int,
47
+ wait_seconds: float,
48
+ ) -> tuple[list[CanonicalEvent], bool]: ...
49
+
50
+
51
+ class InMemoryRunStore:
52
+ """Non-production store for deterministic tests and local development only."""
53
+
54
+ def __init__(self) -> None:
55
+ self._runs: dict[str, RunRecord] = {}
56
+ self._idempotency: dict[str, str] = {}
57
+ self._condition = asyncio.Condition()
58
+
59
+ async def create_or_get(self, record: RunRecord) -> tuple[RunRecord, bool]:
60
+ async with self._condition:
61
+ existing_id = self._idempotency.get(record.idempotency_key)
62
+ if existing_id is not None:
63
+ existing = self._runs[existing_id]
64
+ if existing.fingerprint != record.fingerprint:
65
+ raise IdempotencyConflictError
66
+ return existing, False
67
+ self._runs[record.run_id] = record
68
+ self._idempotency[record.idempotency_key] = record.run_id
69
+ self._condition.notify_all()
70
+ return record, True
71
+
72
+ async def get(self, run_id: str) -> RunRecord | None:
73
+ async with self._condition:
74
+ return self._runs.get(run_id)
75
+
76
+ async def append(
77
+ self,
78
+ run_id: str,
79
+ event_type: str,
80
+ fields: dict[str, object] | None = None,
81
+ ) -> CanonicalEvent | None:
82
+ async with self._condition:
83
+ record = self._runs[run_id]
84
+ if record.terminal:
85
+ return None
86
+ event: CanonicalEvent = {
87
+ "type": event_type,
88
+ "runId": run_id,
89
+ "sequence": len(record.events) + 1,
90
+ **(fields or {}),
91
+ }
92
+ record.events.append(event)
93
+ record.status = status_for(event_type, record.status)
94
+ self._condition.notify_all()
95
+ return event
96
+
97
+ async def complete(self, run_id: str, output: JsonValue) -> None:
98
+ async with self._condition:
99
+ record = self._runs[run_id]
100
+ if record.terminal:
101
+ return
102
+ record.output = output
103
+ event: CanonicalEvent = {
104
+ "type": "run.completed",
105
+ "runId": run_id,
106
+ "sequence": len(record.events) + 1,
107
+ }
108
+ record.events.append(event)
109
+ record.status = "completed"
110
+ self._condition.notify_all()
111
+
112
+ async def events_after(
113
+ self,
114
+ run_id: str,
115
+ after: int,
116
+ wait_seconds: float,
117
+ ) -> tuple[list[CanonicalEvent], bool]:
118
+ async with self._condition:
119
+ record = self._runs[run_id]
120
+
121
+ def ready() -> bool:
122
+ return len(record.events) > after or record.terminal
123
+
124
+ if not ready():
125
+ try:
126
+ await asyncio.wait_for(
127
+ self._condition.wait_for(ready),
128
+ wait_seconds,
129
+ )
130
+ except TimeoutError:
131
+ return [], False
132
+ return list(record.events[after:]), record.terminal
133
+
134
+
135
+ def status_for(event_type: str, current: RunStatus) -> RunStatus:
136
+ if event_type == "run.started":
137
+ return "running"
138
+ if event_type == "approval.required":
139
+ return "awaiting-approval"
140
+ if event_type == "run.failed":
141
+ return "failed"
142
+ if event_type == "run.cancelled":
143
+ return "cancelled"
144
+ return current
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+ import httpx
7
+
8
+ from .config import normalize_base_url
9
+ from .contracts import JsonValue
10
+ from .engine import SafeEngineError
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ToolInvocation:
15
+ run_id: str
16
+ tool_call_id: str
17
+ tool_key: str
18
+ arguments: dict[str, JsonValue]
19
+ execution_grant: str
20
+
21
+
22
+ class ToolHostClient:
23
+ def __init__(
24
+ self,
25
+ internal_token: str,
26
+ *,
27
+ transport: httpx.AsyncBaseTransport | None = None,
28
+ timeout_seconds: float = 15.0,
29
+ max_response_bytes: int = 1024 * 1024,
30
+ ) -> None:
31
+ self._internal_token = internal_token
32
+ self._transport = transport
33
+ self._timeout_seconds = timeout_seconds
34
+ self._max_response_bytes = max_response_bytes
35
+
36
+ async def execute(
37
+ self,
38
+ base_url: str,
39
+ invocation: ToolInvocation,
40
+ ) -> JsonValue:
41
+ endpoint = normalize_base_url(base_url)
42
+ headers = {
43
+ "authorization": f"Bearer {self._internal_token}",
44
+ "content-type": "application/json",
45
+ }
46
+ try:
47
+ async with httpx.AsyncClient(
48
+ base_url=f"{endpoint}/",
49
+ headers=headers,
50
+ timeout=self._timeout_seconds,
51
+ transport=self._transport,
52
+ ) as client:
53
+ response = await client.post(
54
+ "api/internal/agent/tools/execute",
55
+ json={
56
+ "executionGrant": invocation.execution_grant,
57
+ "runId": invocation.run_id,
58
+ "toolCallId": invocation.tool_call_id,
59
+ "toolKey": invocation.tool_key,
60
+ "arguments": invocation.arguments,
61
+ },
62
+ )
63
+ body = await response.aread()
64
+ except httpx.HTTPError:
65
+ raise denied("Agent Tool Host 暂不可用", retryable=True) from None
66
+ if len(body) > self._max_response_bytes:
67
+ raise denied("Agent Tool 响应超过安全上限")
68
+ if not response.is_success:
69
+ raise denied("Agent Tool 执行被拒绝")
70
+ try:
71
+ value = json.loads(body)
72
+ except json.JSONDecodeError:
73
+ raise denied("Agent Tool 响应无效") from None
74
+ if not isinstance(value, dict) or "value" not in value:
75
+ raise denied("Agent Tool 响应无效")
76
+ return value["value"] # type: ignore[no-any-return]
77
+
78
+
79
+ def denied(message: str, *, retryable: bool = False) -> SafeEngineError:
80
+ return SafeEngineError("TOOL_DENIED", message, retryable=retryable)