create-windy 0.2.8 → 0.2.10

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 (36) hide show
  1. package/dist/cli.js +8 -3
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/server/Dockerfile +2 -0
  5. package/template/apps/server/src/index.ts +1 -0
  6. package/template/apps/server/src/module-host.test.ts +6 -0
  7. package/template/apps/server/src/module-host.ts +9 -6
  8. package/template/apps/web/Dockerfile +2 -0
  9. package/template/package.json +1 -1
  10. package/template/packages/agent-client/index.ts +3 -0
  11. package/template/packages/agent-client/package.json +27 -0
  12. package/template/packages/agent-client/src/client.test.ts +344 -0
  13. package/template/packages/agent-client/src/client.ts +186 -0
  14. package/template/packages/agent-client/src/error.ts +88 -0
  15. package/template/packages/agent-client/src/event-queue.ts +62 -0
  16. package/template/packages/agent-client/src/http.ts +19 -0
  17. package/template/packages/agent-client/src/run-protocol.test.ts +231 -0
  18. package/template/packages/agent-client/src/run.ts +320 -0
  19. package/template/packages/agent-client/src/types.ts +35 -0
  20. package/template/packages/agent-client/tsconfig.json +21 -0
  21. package/template/packages/agent-contracts/index.ts +7 -0
  22. package/template/packages/agent-contracts/package.json +24 -0
  23. package/template/packages/agent-contracts/src/contracts.test.ts +213 -0
  24. package/template/packages/agent-contracts/src/errors.ts +51 -0
  25. package/template/packages/agent-contracts/src/events.ts +161 -0
  26. package/template/packages/agent-contracts/src/operation.ts +28 -0
  27. package/template/packages/agent-contracts/src/runs.ts +58 -0
  28. package/template/packages/agent-contracts/src/sse.ts +95 -0
  29. package/template/packages/agent-contracts/src/usage.ts +62 -0
  30. package/template/packages/agent-contracts/src/validation.ts +68 -0
  31. package/template/packages/agent-contracts/tsconfig.json +16 -0
  32. package/template/packages/modules/src/ai-operation-composition.test.ts +195 -0
  33. package/template/packages/modules/src/ai-operation-composition.ts +145 -0
  34. package/template/packages/modules/src/composition.ts +19 -37
  35. package/template/packages/modules/src/implementation-bindings.ts +70 -0
  36. package/template/packages/modules/src/manifest.ts +37 -1
@@ -0,0 +1,186 @@
1
+ import type {
2
+ AgentOperation,
3
+ StartAgentRunRequest,
4
+ } from "@southwind-ai/agent-contracts";
5
+ import { parseStartAgentRunResponse } from "@southwind-ai/agent-contracts";
6
+ import { AgentClientError, requestError, safeHttpError } from "./error.js";
7
+ import { safeJson } from "./http.js";
8
+ import { HttpAgentRun } from "./run.js";
9
+ import type {
10
+ AgentCaller,
11
+ AgentClientOptions,
12
+ AgentFetch,
13
+ AgentRun,
14
+ StartAgentRunOptions,
15
+ } from "./types.js";
16
+
17
+ export class HttpAgentCaller implements AgentCaller {
18
+ private readonly baseUrl: URL;
19
+ private readonly fetch: AgentFetch;
20
+ private readonly runsPath: string;
21
+ private readonly maxReconnects: number;
22
+ private readonly reconnectDelayMs: number;
23
+ private readonly wait: NonNullable<AgentClientOptions["wait"]>;
24
+
25
+ constructor(options: AgentClientOptions) {
26
+ this.baseUrl = validateBaseUrl(options.baseUrl);
27
+ this.fetch = options.fetch || globalThis.fetch.bind(globalThis);
28
+ this.runsPath = options.runsPath || "/api/agent/runs";
29
+ this.maxReconnects = nonNegativeInteger(
30
+ options.maxReconnects,
31
+ 3,
32
+ "maxReconnects",
33
+ );
34
+ this.reconnectDelayMs = nonNegativeInteger(
35
+ options.reconnectDelayMs,
36
+ 250,
37
+ "reconnectDelayMs",
38
+ );
39
+ this.wait = options.wait || wait;
40
+ }
41
+
42
+ async start<Input, Output>(
43
+ operation: AgentOperation<Input, Output>,
44
+ input: Input,
45
+ options: StartAgentRunOptions,
46
+ ): Promise<AgentRun<Output>> {
47
+ if (!operation.key.trim()) {
48
+ throw new AgentClientError(
49
+ "INVALID_CONFIGURATION",
50
+ "Agent Operation key 不能为空",
51
+ false,
52
+ );
53
+ }
54
+ if (!options.idempotencyKey.trim()) {
55
+ throw new AgentClientError(
56
+ "INVALID_CONFIGURATION",
57
+ "幂等键不能为空",
58
+ false,
59
+ );
60
+ }
61
+
62
+ const request: StartAgentRunRequest<Input> = {
63
+ operationKey: operation.key,
64
+ input,
65
+ };
66
+ let response: Response;
67
+ try {
68
+ response = await this.fetch(this.endpoint(this.runsPath), {
69
+ method: "POST",
70
+ headers: {
71
+ "content-type": "application/json",
72
+ "idempotency-key": options.idempotencyKey,
73
+ },
74
+ body: JSON.stringify(request),
75
+ signal: options.signal,
76
+ });
77
+ } catch (error) {
78
+ if (options.signal?.aborted) {
79
+ throw new AgentClientError(
80
+ "CANCELLED",
81
+ "创建 Agent Run 已取消",
82
+ false,
83
+ undefined,
84
+ { cause: error },
85
+ );
86
+ }
87
+ throw requestError(error, "创建 Agent Run");
88
+ }
89
+ if (!response.ok) throw await safeHttpError(response, "创建 Agent Run");
90
+
91
+ const created = parseCreatedRun(await safeJson(response, "创建 Agent Run"));
92
+ const eventsUrl = this.endpoint(created.eventsUrl);
93
+ return new HttpAgentRun<Output>({
94
+ id: created.runId,
95
+ eventsUrl,
96
+ resultUrl: this.endpoint(
97
+ `${this.runsPath}/${encodeURIComponent(created.runId)}/result`,
98
+ ),
99
+ cancelUrl: this.endpoint(
100
+ `${this.runsPath}/${encodeURIComponent(created.runId)}/cancel`,
101
+ ),
102
+ fetch: this.fetch,
103
+ signal: options.signal,
104
+ maxReconnects: this.maxReconnects,
105
+ reconnectDelayMs: this.reconnectDelayMs,
106
+ wait: this.wait,
107
+ parseOutput: operation.parseOutput,
108
+ });
109
+ }
110
+
111
+ private endpoint(path: string): URL {
112
+ const endpoint = new URL(path, this.baseUrl);
113
+ if (endpoint.origin !== this.baseUrl.origin) {
114
+ throw new AgentClientError(
115
+ "INVALID_RESPONSE",
116
+ "Agent Server 返回了不受信任的 Endpoint",
117
+ false,
118
+ );
119
+ }
120
+ return endpoint;
121
+ }
122
+ }
123
+
124
+ function parseCreatedRun(value: unknown) {
125
+ try {
126
+ return parseStartAgentRunResponse(value);
127
+ } catch (error) {
128
+ throw new AgentClientError(
129
+ "INVALID_RESPONSE",
130
+ "创建 Agent Run 响应格式无效",
131
+ false,
132
+ undefined,
133
+ { cause: error },
134
+ );
135
+ }
136
+ }
137
+
138
+ function validateBaseUrl(value: string | URL): URL {
139
+ const url = new URL(value);
140
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
141
+ throw new AgentClientError(
142
+ "INVALID_CONFIGURATION",
143
+ "Agent Server URL 只允许 HTTP 或 HTTPS",
144
+ false,
145
+ );
146
+ }
147
+ return url;
148
+ }
149
+
150
+ function nonNegativeInteger(
151
+ value: number | undefined,
152
+ fallback: number,
153
+ name: string,
154
+ ): number {
155
+ if (value === undefined) return fallback;
156
+ if (!Number.isSafeInteger(value) || value < 0) {
157
+ throw new AgentClientError(
158
+ "INVALID_CONFIGURATION",
159
+ `${name} 必须是非负安全整数`,
160
+ false,
161
+ );
162
+ }
163
+ return value;
164
+ }
165
+
166
+ function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
167
+ return new Promise((resolve, reject) => {
168
+ if (signal?.aborted) {
169
+ reject(signal.reason);
170
+ return;
171
+ }
172
+ const timer = setTimeout(resolve, milliseconds);
173
+ signal?.addEventListener(
174
+ "abort",
175
+ () => {
176
+ clearTimeout(timer);
177
+ reject(signal.reason);
178
+ },
179
+ { once: true },
180
+ );
181
+ });
182
+ }
183
+
184
+ export function createAgentCaller(options: AgentClientOptions): AgentCaller {
185
+ return new HttpAgentCaller(options);
186
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ parseAgentError,
3
+ type AgentError,
4
+ } from "@southwind-ai/agent-contracts";
5
+
6
+ export type AgentClientErrorCode =
7
+ | "INVALID_CONFIGURATION"
8
+ | "INVALID_RESPONSE"
9
+ | "RUN_REJECTED"
10
+ | "RUN_FAILED"
11
+ | "RUN_CANCELLED"
12
+ | "STREAM_INTERRUPTED"
13
+ | "PROTOCOL_VIOLATION"
14
+ | "REQUEST_FAILED"
15
+ | "CANCELLED";
16
+
17
+ export class AgentClientError extends Error {
18
+ constructor(
19
+ readonly code: AgentClientErrorCode,
20
+ message: string,
21
+ readonly retryable: boolean,
22
+ readonly runId?: string,
23
+ options?: ErrorOptions & { agentError?: AgentError },
24
+ ) {
25
+ super(message, options);
26
+ this.name = "AgentClientError";
27
+ this.agentError = options?.agentError;
28
+ }
29
+
30
+ readonly agentError?: AgentError;
31
+ }
32
+
33
+ export async function safeHttpError(
34
+ response: Response,
35
+ action: string,
36
+ runId?: string,
37
+ ): Promise<AgentClientError> {
38
+ const agentError = await safeAgentError(response);
39
+ return new AgentClientError(
40
+ response.status === 429 ? "RUN_REJECTED" : "REQUEST_FAILED",
41
+ agentError?.message || `${action}失败(HTTP ${response.status})`,
42
+ agentError?.retryable ??
43
+ (response.status === 408 ||
44
+ response.status === 425 ||
45
+ response.status === 429 ||
46
+ response.status >= 500),
47
+ runId,
48
+ agentError ? { agentError } : undefined,
49
+ );
50
+ }
51
+
52
+ export function requestError(
53
+ error: unknown,
54
+ action: string,
55
+ runId?: string,
56
+ ): AgentClientError {
57
+ if (error instanceof AgentClientError) return error;
58
+ if (isAbortError(error)) {
59
+ return new AgentClientError("CANCELLED", `${action}已取消`, false, runId, {
60
+ cause: error,
61
+ });
62
+ }
63
+ return new AgentClientError("REQUEST_FAILED", `${action}失败`, true, runId, {
64
+ cause: error,
65
+ });
66
+ }
67
+
68
+ function isAbortError(error: unknown): boolean {
69
+ return error instanceof DOMException && error.name === "AbortError";
70
+ }
71
+
72
+ async function safeAgentError(
73
+ response: Response,
74
+ ): Promise<AgentError | undefined> {
75
+ if (
76
+ !response.headers
77
+ .get("content-type")
78
+ ?.toLowerCase()
79
+ .includes("application/json")
80
+ ) {
81
+ return undefined;
82
+ }
83
+ try {
84
+ return parseAgentError(await response.json());
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
@@ -0,0 +1,62 @@
1
+ export class EventQueue<T> implements AsyncIterable<T> {
2
+ private readonly values: T[] = [];
3
+ private readonly waiters: Array<{
4
+ resolve(result: IteratorResult<T>): void;
5
+ reject(error: unknown): void;
6
+ }> = [];
7
+ private closed = false;
8
+ private failure?: unknown;
9
+ private claimed = false;
10
+
11
+ constructor(private readonly onClaim: () => void) {}
12
+
13
+ push(value: T): void {
14
+ if (this.closed) return;
15
+ const waiter = this.waiters.shift();
16
+ if (waiter) {
17
+ waiter.resolve({ value, done: false });
18
+ return;
19
+ }
20
+ this.values.push(value);
21
+ }
22
+
23
+ close(): void {
24
+ if (this.closed) return;
25
+ this.closed = true;
26
+ for (const waiter of this.waiters.splice(0)) {
27
+ waiter.resolve({ value: undefined, done: true });
28
+ }
29
+ }
30
+
31
+ fail(error: unknown): void {
32
+ if (this.closed) return;
33
+ this.failure = error;
34
+ this.closed = true;
35
+ for (const waiter of this.waiters.splice(0)) waiter.reject(error);
36
+ }
37
+
38
+ [Symbol.asyncIterator](): AsyncIterator<T> {
39
+ if (this.claimed) {
40
+ throw new TypeError("Agent Run events 只能消费一次");
41
+ }
42
+ this.claimed = true;
43
+ this.onClaim();
44
+ return {
45
+ next: () => this.next(),
46
+ };
47
+ }
48
+
49
+ private next(): Promise<IteratorResult<T>> {
50
+ const value = this.values.shift();
51
+ if (value !== undefined) {
52
+ return Promise.resolve({ value, done: false });
53
+ }
54
+ if (this.failure !== undefined) return Promise.reject(this.failure);
55
+ if (this.closed) {
56
+ return Promise.resolve({ value: undefined, done: true });
57
+ }
58
+ return new Promise((resolve, reject) => {
59
+ this.waiters.push({ resolve, reject });
60
+ });
61
+ }
62
+ }
@@ -0,0 +1,19 @@
1
+ import { AgentClientError } from "./error.js";
2
+
3
+ export async function safeJson(
4
+ response: Response,
5
+ action: string,
6
+ runId?: string,
7
+ ): Promise<unknown> {
8
+ try {
9
+ return await response.json();
10
+ } catch (error) {
11
+ throw new AgentClientError(
12
+ "INVALID_RESPONSE",
13
+ `${action}响应不是合法 JSON`,
14
+ false,
15
+ runId,
16
+ { cause: error },
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,231 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ defineAgentOperation,
4
+ type AgentEvent,
5
+ } from "@southwind-ai/agent-contracts";
6
+ import { createAgentCaller } from "../index.js";
7
+ import type { AgentFetch } from "./types.js";
8
+
9
+ const operation = defineAgentOperation<null, string>("agent.test", (value) => {
10
+ if (typeof value !== "string") throw new TypeError("string required");
11
+ return value;
12
+ });
13
+
14
+ describe("Agent Run protocol safety", () => {
15
+ test("拒绝跨源 eventsUrl", async () => {
16
+ const fake = plannedFetch([
17
+ () =>
18
+ json({
19
+ runId: "run-origin",
20
+ status: "accepted",
21
+ eventsUrl: "https://evil.example/events",
22
+ }),
23
+ ]);
24
+
25
+ await expect(start(fake.fetch, "origin")).rejects.toMatchObject({
26
+ code: "INVALID_RESPONSE",
27
+ retryable: false,
28
+ });
29
+ expect(fake.calls).toBe(1);
30
+ });
31
+
32
+ test("跨 chunk 解析 heartbeat 和事件 frame", async () => {
33
+ const started = frame({
34
+ type: "run.started",
35
+ runId: "run-chunks",
36
+ sequence: 1,
37
+ });
38
+ const completed = frame({
39
+ type: "run.completed",
40
+ runId: "run-chunks",
41
+ sequence: 2,
42
+ });
43
+ const fake = plannedFetch([
44
+ () =>
45
+ json({
46
+ runId: "run-chunks",
47
+ status: "accepted",
48
+ eventsUrl: "/events",
49
+ }),
50
+ () =>
51
+ sse([
52
+ ": heart",
53
+ "beat\n\n" + started.slice(0, 9),
54
+ started.slice(9) + completed.slice(0, 17),
55
+ completed.slice(17),
56
+ ]),
57
+ () => json({ runId: "run-chunks", status: "completed", output: "ok" }),
58
+ ]);
59
+ const run = await start(fake.fetch, "chunks");
60
+ const events = collect(run.events);
61
+
62
+ await expect(run.result()).resolves.toBe("ok");
63
+ expect((await events).map(({ type }) => type)).toEqual([
64
+ "run.started",
65
+ "run.completed",
66
+ ]);
67
+ });
68
+
69
+ test("run.failed 返回 canonical 安全错误且不请求 result", async () => {
70
+ const fake = plannedFetch([
71
+ () =>
72
+ json({
73
+ runId: "run-failed",
74
+ status: "accepted",
75
+ eventsUrl: "/events",
76
+ }),
77
+ () =>
78
+ sse([
79
+ frame({
80
+ type: "run.failed",
81
+ runId: "run-failed",
82
+ sequence: 1,
83
+ error: {
84
+ code: "PROVIDER_UNAVAILABLE",
85
+ message: "模型服务暂不可用",
86
+ requestId: "request-failed",
87
+ retryable: true,
88
+ },
89
+ }),
90
+ ]),
91
+ ]);
92
+ const run = await start(fake.fetch, "failed");
93
+
94
+ await expect(run.result()).rejects.toMatchObject({
95
+ code: "RUN_FAILED",
96
+ message: "模型服务暂不可用",
97
+ retryable: true,
98
+ agentError: {
99
+ code: "PROVIDER_UNAVAILABLE",
100
+ requestId: "request-failed",
101
+ },
102
+ });
103
+ expect(fake.calls).toBe(2);
104
+ });
105
+
106
+ test("run.cancelled 返回唯一取消终态且不请求 result", async () => {
107
+ const fake = plannedFetch([
108
+ () =>
109
+ json({
110
+ runId: "run-cancelled",
111
+ status: "accepted",
112
+ eventsUrl: "/events",
113
+ }),
114
+ () =>
115
+ sse([
116
+ frame({
117
+ type: "run.cancelled",
118
+ runId: "run-cancelled",
119
+ sequence: 1,
120
+ }),
121
+ ]),
122
+ ]);
123
+ const run = await start(fake.fetch, "cancelled");
124
+ const events = collect(run.events);
125
+
126
+ await expect(run.result()).rejects.toMatchObject({
127
+ code: "RUN_CANCELLED",
128
+ retryable: false,
129
+ });
130
+ expect((await events).map(({ type }) => type)).toEqual(["run.cancelled"]);
131
+ expect(fake.calls).toBe(2);
132
+ });
133
+
134
+ test.each([
135
+ {
136
+ name: "content-type",
137
+ response: () =>
138
+ new Response("not sse", {
139
+ headers: { "content-type": "text/plain" },
140
+ }),
141
+ },
142
+ {
143
+ name: "sequence",
144
+ response: () =>
145
+ sse([
146
+ 'id: 2\nevent: run.started\ndata: {"type":"run.started","runId":"run-invalid","sequence":1}\n\n',
147
+ ]),
148
+ },
149
+ {
150
+ name: "runId",
151
+ response: () =>
152
+ sse([
153
+ frame({
154
+ type: "run.started",
155
+ runId: "another-run",
156
+ sequence: 1,
157
+ }),
158
+ ]),
159
+ },
160
+ ])("非法 $name 以协议错误终止且不重试", async ({ response }) => {
161
+ const fake = plannedFetch([
162
+ () =>
163
+ json({
164
+ runId: "run-invalid",
165
+ status: "accepted",
166
+ eventsUrl: "/events",
167
+ }),
168
+ response,
169
+ ]);
170
+ const run = await start(fake.fetch, `invalid-${response.name}`);
171
+
172
+ await expect(run.result()).rejects.toMatchObject({
173
+ code: "PROTOCOL_VIOLATION",
174
+ retryable: false,
175
+ });
176
+ expect(fake.calls).toBe(2);
177
+ });
178
+ });
179
+
180
+ function start(fetch: AgentFetch, idempotencyKey: string) {
181
+ return createAgentCaller({
182
+ baseUrl: "https://agent.internal",
183
+ fetch,
184
+ reconnectDelayMs: 0,
185
+ wait: async () => undefined,
186
+ }).start(operation, null, { idempotencyKey });
187
+ }
188
+
189
+ function plannedFetch(plans: Array<() => Response>): {
190
+ fetch: AgentFetch;
191
+ readonly calls: number;
192
+ } {
193
+ let calls = 0;
194
+ return {
195
+ fetch: async () => {
196
+ const plan = plans[calls++];
197
+ if (!plan) throw new Error("unexpected fetch");
198
+ return plan();
199
+ },
200
+ get calls() {
201
+ return calls;
202
+ },
203
+ };
204
+ }
205
+
206
+ function json(value: unknown): Response {
207
+ return Response.json(value);
208
+ }
209
+
210
+ function sse(chunks: string[]): Response {
211
+ const encoder = new TextEncoder();
212
+ return new Response(
213
+ new ReadableStream<Uint8Array>({
214
+ start(controller) {
215
+ for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
216
+ controller.close();
217
+ },
218
+ }),
219
+ { headers: { "content-type": "text/event-stream" } },
220
+ );
221
+ }
222
+
223
+ function frame(event: AgentEvent): string {
224
+ return `id: ${event.sequence}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
225
+ }
226
+
227
+ async function collect(events: AsyncIterable<AgentEvent>) {
228
+ const collected: AgentEvent[] = [];
229
+ for await (const event of events) collected.push(event);
230
+ return collected;
231
+ }