create-windy 0.8.0 → 0.8.2

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 (50) hide show
  1. package/dist/cli.js +14 -8
  2. package/package.json +1 -1
  3. package/template/.windy-template.json +2 -2
  4. package/template/apps/agent-server/Dockerfile +1 -1
  5. package/template/apps/agent-server/README.md +1 -1
  6. package/template/apps/agent-server/src/southwind_agent_server/asgi.py +8 -0
  7. package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
  8. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
  9. package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
  10. package/template/apps/agent-server/src/southwind_agent_server/runtime_logging.py +57 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +145 -0
  12. package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +4 -0
  13. package/template/apps/agent-server/tests/test_run_operational_logging.py +114 -0
  14. package/template/apps/agent-server/tests/test_structured_logging.py +142 -0
  15. package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
  16. package/template/apps/server/src/agent/isolated-execution-guard.test.ts +194 -0
  17. package/template/apps/server/src/agent/isolated-execution-guard.ts +202 -0
  18. package/template/apps/server/src/agent/run-definition-support.ts +11 -1
  19. package/template/apps/server/src/agent/run-facade.ts +7 -11
  20. package/template/apps/server/src/agent/run-operation-policy.ts +11 -0
  21. package/template/apps/server/src/agent/workflow-durable-runtime-input.ts +8 -5
  22. package/template/apps/server/src/jobs/legacy-service-runtime.ts +4 -0
  23. package/template/apps/server/src/jobs/runtime-logging.test.ts +212 -0
  24. package/template/apps/server/src/jobs/runtime-logging.ts +58 -0
  25. package/template/apps/server/src/jobs/worker.ts +54 -12
  26. package/template/apps/server/src/observability/bootstrap.ts +1 -4
  27. package/template/apps/server/src/observability/http-observability.ts +2 -3
  28. package/template/apps/server/src/observability/structured-logger.test.ts +31 -0
  29. package/template/apps/server/src/observability/structured-logger.ts +27 -0
  30. package/template/apps/server/src/platform-bootstrap.ts +9 -3
  31. package/template/apps/server/src/startup-finalization.ts +3 -3
  32. package/template/apps/server/src/system/built-in-roles.test.ts +23 -0
  33. package/template/apps/server/src/system/built-in-roles.ts +10 -4
  34. package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +16 -5
  35. package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +4 -0
  36. package/template/docs/architecture/log-governance-implementation.md +96 -0
  37. package/template/docs/architecture/log-governance-plan.md +39 -0
  38. package/template/docs/operations/observability.md +36 -4
  39. package/template/docs/platform/agent-runtime.md +18 -0
  40. package/template/packages/modules/package.json +1 -1
  41. package/template/packages/modules/src/ai-isolated-execution-policy.test.ts +70 -0
  42. package/template/packages/modules/src/ai-isolated-execution-policy.ts +76 -0
  43. package/template/packages/modules/src/ai-operation-composition.ts +2 -0
  44. package/template/packages/modules/src/manifest.ts +23 -0
  45. package/template/packages/modules/src/system-features.ts +11 -10
  46. package/template/packages/server-sdk/package.json +1 -1
  47. package/template/packages/shared/index.ts +1 -0
  48. package/template/packages/shared/package.json +3 -2
  49. package/template/packages/shared/src/structured-log.test.ts +147 -0
  50. package/template/packages/shared/src/structured-log.ts +215 -0
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ from dataclasses import replace
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from southwind_agent_server.attempt_context import govern_provider_attempt
14
+ from southwind_agent_server.attempt_context_projection import (
15
+ AttemptContextPreparation,
16
+ governed_envelope,
17
+ )
18
+ from southwind_agent_server.context_message_digest import canonical_json_digest
19
+ from southwind_agent_server.context_version_port import (
20
+ PreparedContext,
21
+ PreparedSource,
22
+ UnavailableContextVersionPort,
23
+ )
24
+ from southwind_agent_server.contracts import JsonValue, ProviderFieldPolicy
25
+ from southwind_agent_server.engine import (
26
+ EngineCompleted,
27
+ EngineRunRequest,
28
+ SafeEngineError,
29
+ ToolCompleted,
30
+ )
31
+ from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
32
+ from southwind_agent_server.tool_host import ToolHostClient
33
+ from tests.openai_provider_test_support import (
34
+ create_provider,
35
+ governance,
36
+ runtime_request,
37
+ sse_response,
38
+ )
39
+
40
+ SCHEMA: dict[str, JsonValue] = {
41
+ "type": "object",
42
+ "properties": {"total": {"type": "integer"}},
43
+ "required": ["total"],
44
+ "additionalProperties": False,
45
+ }
46
+
47
+
48
+ def structured_request() -> EngineRunRequest:
49
+ request = runtime_request("business.records.list")
50
+ return replace(
51
+ request,
52
+ output_schema_key="business.answer.v1",
53
+ output_schema=SCHEMA,
54
+ provider_data_policy=request.provider_data_policy.model_copy(
55
+ update={
56
+ "envelope_fields": [
57
+ *request.provider_data_policy.envelope_fields,
58
+ ProviderFieldPolicy(
59
+ field="output-schema",
60
+ classification="internal",
61
+ dlp_action="redact",
62
+ ),
63
+ ]
64
+ }
65
+ ),
66
+ )
67
+
68
+
69
+ def test_tool_payload_uses_schema_guidance_without_mutation_or_duplicate() -> None:
70
+ provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
71
+ request = runtime_request("business.records.list")
72
+ messages: list[dict[str, JsonValue]] = [{"role": "system", "content": "原指令"}]
73
+ body = provider.request_body(messages, request.allowed_tools, "answer.v1", SCHEMA)
74
+ payload = json.loads(body)
75
+ assert "response_format" not in payload
76
+ assert payload["tools"][0]["function"]["name"] == "business.records.list"
77
+ assert payload["messages"][0]["content"].startswith("原指令\n\n")
78
+ assert (
79
+ json.dumps(SCHEMA, ensure_ascii=False, separators=(",", ":"))
80
+ in payload["messages"][0]["content"]
81
+ )
82
+ assert messages[0]["content"] == "原指令"
83
+ assert (
84
+ provider.request_body(
85
+ payload["messages"], request.allowed_tools, "answer.v1", SCHEMA
86
+ )
87
+ == body
88
+ )
89
+ plain = json.loads(provider.request_body(messages, (), "answer.v1", SCHEMA))
90
+ assert plain["messages"] == messages
91
+ assert plain["response_format"]["json_schema"]["strict"] is True
92
+
93
+
94
+ @pytest.mark.parametrize("final", ['{"total":1}', '{"total":"1"}', "```json\n{}\n```"])
95
+ async def test_real_function_round_keeps_strict_final_schema(final: str) -> None:
96
+ turns = 0
97
+ calls = 0
98
+
99
+ async def provider_handler(request: httpx.Request) -> httpx.Response:
100
+ nonlocal turns
101
+ turns += 1
102
+ body = json.loads(request.content)
103
+ assert "response_format" not in body
104
+ assert '"required":["total"]' in body["messages"][0]["content"]
105
+ if turns == 1:
106
+ return sse_response(
107
+ [
108
+ {
109
+ "choices": [
110
+ {
111
+ "delta": {
112
+ "tool_calls": [
113
+ {
114
+ "index": 0,
115
+ "id": "call_1",
116
+ "function": {
117
+ "name": "business.records.list",
118
+ "arguments": "{}",
119
+ },
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ ]
125
+ }
126
+ ]
127
+ )
128
+ assert body["messages"][-1]["role"] == "tool"
129
+ return sse_response([{"choices": [{"delta": {"content": final}}]}])
130
+
131
+ async def tool_handler(request: httpx.Request) -> httpx.Response:
132
+ nonlocal calls
133
+ calls += 1
134
+ assert json.loads(request.content)["executionGrant"] == "grant_1"
135
+ return httpx.Response(200, json={"value": {"total": 1}})
136
+
137
+ engine = OpenAiCompatibleEngineAdapter(
138
+ create_provider(httpx.MockTransport(provider_handler)),
139
+ ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
140
+ governance(),
141
+ )
142
+ request = structured_request()
143
+ if final == '{"total":1}':
144
+ signals = [signal async for signal in engine.execute(request)]
145
+ assert any(isinstance(signal, ToolCompleted) for signal in signals)
146
+ assert isinstance(signals[-1], EngineCompleted)
147
+ assert signals[-1].output == {"total": 1}
148
+ else:
149
+ with pytest.raises(SafeEngineError) as denied:
150
+ _ = [signal async for signal in engine.execute(request)]
151
+ assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
152
+ assert turns == 2 and calls == 1
153
+
154
+
155
+ async def test_text_tool_call_is_not_executable() -> None:
156
+ async def provider_handler(_: httpx.Request) -> httpx.Response:
157
+ return sse_response(
158
+ [
159
+ {
160
+ "choices": [
161
+ {
162
+ "delta": {
163
+ "content": (
164
+ '{"tool_calls":[{"name":"business.records.list",'
165
+ '"arguments":{}}]}'
166
+ )
167
+ }
168
+ }
169
+ ]
170
+ }
171
+ ]
172
+ )
173
+
174
+ async def tool_handler(_: httpx.Request) -> httpx.Response:
175
+ pytest.fail("正文中的工具调用不得执行")
176
+
177
+ engine = OpenAiCompatibleEngineAdapter(
178
+ create_provider(httpx.MockTransport(provider_handler)),
179
+ ToolHostClient("internal-token", transport=httpx.MockTransport(tool_handler)),
180
+ governance(),
181
+ )
182
+ with pytest.raises(SafeEngineError) as denied:
183
+ _ = [signal async for signal in engine.execute(structured_request())]
184
+ assert denied.value.code == "OUTPUT_VALIDATION_FAILED"
185
+
186
+
187
+ async def test_governed_guidance_matches_audit_and_context_bytes(
188
+ tmp_path: Path,
189
+ ) -> None:
190
+ request = structured_request()
191
+ request = replace(
192
+ request, output_schema={**SCHEMA, "description": "password=secret"}
193
+ )
194
+ provider = create_provider(httpx.MockTransport(lambda _: httpx.Response(500)))
195
+ audit_path = str(tmp_path / "audit.sqlite3")
196
+ messages: list[dict[str, JsonValue]] = [
197
+ {"role": "system", "content": request.instructions},
198
+ {"role": "user", "content": "历史来源"},
199
+ {"role": "user", "content": request.user_prompt},
200
+ ]
201
+ prepared = await govern_provider_attempt(
202
+ UnavailableContextVersionPort(),
203
+ governance(audit_path),
204
+ provider,
205
+ request,
206
+ 1,
207
+ None,
208
+ messages,
209
+ )
210
+ payload = json.loads(prepared.provider_request)
211
+ assert payload["messages"] == prepared.governed.messages
212
+ assert "password=secret" not in prepared.provider_request.decode()
213
+ assert "[REDACTED]" in payload["messages"][0]["content"]
214
+ with sqlite3.connect(audit_path) as connection:
215
+ assert connection.execute(
216
+ "select sent_bytes, decision from provider_attempt_audit"
217
+ ).fetchone() == (len(prepared.provider_request), "redact")
218
+ source = PreparedSource(
219
+ "business", "session", "source-1", "v1", 1, "a" * 64, {}, "历史来源", "user"
220
+ )
221
+ envelope = governed_envelope(
222
+ AttemptContextPreparation(
223
+ PreparedContext("preparation-1", (source,)), messages, 1
224
+ ),
225
+ request.run_id,
226
+ "attempt-1",
227
+ prepared.governed.messages,
228
+ prepared.provider_request,
229
+ provider.config.deployment_key,
230
+ provider.config.model,
231
+ request.prompt_version,
232
+ (),
233
+ )
234
+ entries = cast(list[dict[str, JsonValue]], envelope["messages"])
235
+ assert entries[0]["content"] == payload["messages"][0]["content"]
236
+ assert (
237
+ cast(list[dict[str, JsonValue]], entries[1]["origins"])[0]["sourceId"]
238
+ == "source-1"
239
+ )
240
+ provider_fields = cast(dict[str, JsonValue], envelope["provider"])
241
+ assert (
242
+ provider_fields["requestDigest"]
243
+ == hashlib.sha256(prepared.provider_request).hexdigest()
244
+ )
245
+ assert provider_fields["outputSchemaDigest"] == hashlib.sha256(b"null").hexdigest()
246
+ assert entries[0]["digest"] == canonical_json_digest(payload["messages"][0])
247
+ assert entries[0]["digest"] != canonical_json_digest(cast(JsonValue, messages[0]))
248
+
249
+
250
+ async def test_schema_guidance_still_requires_output_schema_authorization() -> None:
251
+ request = structured_request()
252
+ request = replace(
253
+ request,
254
+ provider_data_policy=runtime_request(
255
+ "business.records.list"
256
+ ).provider_data_policy,
257
+ )
258
+ with pytest.raises(SafeEngineError) as denied:
259
+ await govern_provider_attempt(
260
+ UnavailableContextVersionPort(),
261
+ governance(),
262
+ create_provider(httpx.MockTransport(lambda _: httpx.Response(500))),
263
+ request,
264
+ 1,
265
+ None,
266
+ [{"role": "system", "content": "指令"}],
267
+ )
268
+ assert denied.value.code == "FORBIDDEN"
269
+
270
+
271
+ async def test_schema_guidance_counts_toward_input_token_budget() -> None:
272
+ calls = 0
273
+
274
+ async def handler(_: httpx.Request) -> httpx.Response:
275
+ nonlocal calls
276
+ calls += 1
277
+ return sse_response([])
278
+
279
+ provider = create_provider(httpx.MockTransport(handler))
280
+ request = structured_request()
281
+ messages: list[dict[str, JsonValue]] = [
282
+ {"role": "system", "content": request.instructions},
283
+ {"role": "user", "content": request.user_prompt},
284
+ ]
285
+ original_bytes = len(provider.request_body(messages, request.allowed_tools))
286
+ assert original_bytes < 1024
287
+ request = replace(
288
+ request,
289
+ output_schema={**SCHEMA, "description": "业务结果字段说明。" * 100},
290
+ limits=request.limits.model_copy(update={"max_input_tokens": original_bytes}),
291
+ )
292
+ engine = OpenAiCompatibleEngineAdapter(
293
+ provider, ToolHostClient("internal-token"), governance()
294
+ )
295
+ with pytest.raises(SafeEngineError) as denied:
296
+ _ = [signal async for signal in engine.execute(request)]
297
+ assert denied.value.code == "BUDGET_EXCEEDED"
298
+ assert calls == 0
299
+
300
+
301
+ async def test_tool_schema_respects_model_attempt_limit() -> None:
302
+ calls = 0
303
+
304
+ async def handler(_: httpx.Request) -> httpx.Response:
305
+ nonlocal calls
306
+ calls += 1
307
+ return sse_response(
308
+ [
309
+ {
310
+ "choices": [
311
+ {
312
+ "delta": {
313
+ "tool_calls": [
314
+ {
315
+ "index": 0,
316
+ "id": "call_1",
317
+ "function": {
318
+ "name": "business.records.list",
319
+ "arguments": "{}",
320
+ },
321
+ }
322
+ ]
323
+ }
324
+ }
325
+ ]
326
+ }
327
+ ]
328
+ )
329
+
330
+ request = structured_request()
331
+ request = replace(
332
+ request, limits=request.limits.model_copy(update={"max_model_attempts": 1})
333
+ )
334
+ engine = OpenAiCompatibleEngineAdapter(
335
+ create_provider(httpx.MockTransport(handler)),
336
+ ToolHostClient(
337
+ "internal-token",
338
+ transport=httpx.MockTransport(
339
+ lambda _: httpx.Response(200, json={"value": {"total": 1}})
340
+ ),
341
+ ),
342
+ governance(),
343
+ )
344
+ with pytest.raises(SafeEngineError) as denied:
345
+ _ = [signal async for signal in engine.execute(request)]
346
+ assert denied.value.code == "BUDGET_EXCEEDED"
347
+ assert calls == 1
@@ -0,0 +1,194 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ModuleManifest } from "@southwind-ai/modules";
3
+ import { buildScopeContext } from "../data-access/context.js";
4
+ import { createRequestGuardContext } from "../guards.js";
5
+ import type { InitializedModuleHosts } from "../module-host.js";
6
+ import { createServerRuntime } from "../runtime.js";
7
+ import { IsolatedExecutionGuard } from "./isolated-execution-guard.js";
8
+ import { createTestRunAuthorizationStore } from "./run-authorization.test-support.js";
9
+ import { buildAgentRuntimeRelease } from "./run-release.js";
10
+
11
+ const definition: NonNullable<ModuleManifest["aiOperations"]>[number] = {
12
+ key: "business.answer",
13
+ kind: "agent",
14
+ featureKey: "business.ai",
15
+ permissionKey: "business.ai.execute",
16
+ auditAction: "business.ai.execute",
17
+ inputSchemaKey: "business.input.v1",
18
+ outputSchemaKey: "business.output.v1",
19
+ requiredCapabilities: ["text-generation"],
20
+ allowedToolKeys: [],
21
+ dataClassification: "internal",
22
+ execution: "streaming",
23
+ toolNetworkPolicy: { mode: "forbidden" },
24
+ limits: { maxDurationSeconds: 60, maxInputBytes: 4096, maxOutputBytes: 4096 },
25
+ isolatedExecution: {
26
+ version: 1,
27
+ mode: "isolated",
28
+ languages: ["python"],
29
+ inputArtifactTypes: ["text/plain"],
30
+ network: "forbidden",
31
+ limits: {
32
+ maxWorkspaceBytes: 4096,
33
+ maxFileBytes: 2048,
34
+ maxOutputBytes: 1024,
35
+ maxDurationSeconds: 30,
36
+ maxCpuMillis: 30_000,
37
+ maxMemoryBytes: 134_217_728,
38
+ maxPids: 4,
39
+ maxConcurrentProcesses: 1,
40
+ maxToolCalls: 3,
41
+ maxToolRounds: 2,
42
+ },
43
+ },
44
+ };
45
+
46
+ const module = {
47
+ name: "business",
48
+ version: "0.1.0",
49
+ features: [
50
+ {
51
+ key: "business.ai",
52
+ enabled: true,
53
+ module: "business",
54
+ label: "AI",
55
+ visible: "visible",
56
+ stage: "stable",
57
+ },
58
+ ],
59
+ jsonSchemas: [
60
+ { key: "business.input.v1", schema: { type: "object" } },
61
+ { key: "business.output.v1", schema: { type: "object" } },
62
+ ],
63
+ tools: [],
64
+ aiOperations: [definition],
65
+ } as unknown as ModuleManifest;
66
+ const hosts = { aiOperationHandlers: [] } as unknown as InitializedModuleHosts;
67
+
68
+ describe("隔离执行逐次准入", () => {
69
+ test("运行时或持久预算端口缺失时拒绝,绝不执行宿主脚本", async () => {
70
+ const fixture = await setup();
71
+ await expect(
72
+ fixture.guard().admit(fixture.invocation),
73
+ ).rejects.toMatchObject({
74
+ code: "RUNTIME_UNAVAILABLE",
75
+ });
76
+ });
77
+
78
+ test("当前授权、Run 状态及预算每次复核", async () => {
79
+ const fixture = await setup();
80
+ const calls: unknown[] = [];
81
+ const guard = fixture.guard({
82
+ state: { status: async () => "running" },
83
+ budget: {
84
+ reserve: async (input) => {
85
+ calls.push(input);
86
+ return true;
87
+ },
88
+ },
89
+ available: true,
90
+ });
91
+ await expect(guard.admit(fixture.invocation)).resolves.toEqual(
92
+ definition.isolatedExecution!,
93
+ );
94
+ expect(calls).toEqual([
95
+ {
96
+ runId: "run_1",
97
+ callId: "call_1",
98
+ maxToolCalls: 3,
99
+ maxToolRounds: 2,
100
+ maxConcurrentProcesses: 1,
101
+ },
102
+ ]);
103
+ await expect(
104
+ guard.admit({ ...fixture.invocation, language: "node" }),
105
+ ).rejects.toMatchObject({ code: "EXECUTION_DENIED" });
106
+
107
+ const denied = fixture.guard({
108
+ state: { status: async () => "terminal" },
109
+ budget: {
110
+ reserve: async () => {
111
+ throw new Error("terminal Run must not reserve");
112
+ },
113
+ },
114
+ available: true,
115
+ });
116
+ await expect(denied.admit(fixture.invocation)).rejects.toMatchObject({
117
+ code: "EXECUTION_DENIED",
118
+ });
119
+ fixture.actor.permissionKeys = [];
120
+ await expect(guard.admit(fixture.invocation)).rejects.toMatchObject({
121
+ code: "EXECUTION_DENIED",
122
+ });
123
+ expect(calls).toHaveLength(1);
124
+ });
125
+ });
126
+
127
+ async function setup() {
128
+ const actor = {
129
+ id: "actor-1",
130
+ type: "user" as const,
131
+ name: "测试用户",
132
+ roleCodes: ["user"],
133
+ permissionKeys: ["business.ai.execute"],
134
+ scopeContext: buildScopeContext("actor-1", undefined, [
135
+ { id: "role-user", code: "user", dataScope: "self", departmentIds: [] },
136
+ ]),
137
+ };
138
+ const runtime = createServerRuntime(
139
+ {},
140
+ {
141
+ featureCatalog: module.features,
142
+ licenseResolver: { resolve: () => ({ status: "valid" }) },
143
+ },
144
+ );
145
+ const context = createRequestGuardContext(
146
+ new Request("http://localhost/api/agent/runs"),
147
+ {
148
+ resolveActor: () => actor,
149
+ resolveLicense: () => ({ status: "valid" }),
150
+ },
151
+ );
152
+ const authorizations = createTestRunAuthorizationStore();
153
+ const grant = await authorizations.issue({
154
+ operationKey: definition.key,
155
+ releaseId: buildAgentRuntimeRelease([module], module, definition, undefined)
156
+ .releaseId,
157
+ idempotencyKey: "isolated-execution-test",
158
+ input: { question: "analyze" },
159
+ authorizationSnapshot: {
160
+ permissionKey: definition.permissionKey,
161
+ featureKey: definition.featureKey,
162
+ allowedToolKeys: [],
163
+ toolNetworkMode: "forbidden",
164
+ },
165
+ context,
166
+ });
167
+ await authorizations.bind(grant, "run_1");
168
+ return {
169
+ actor,
170
+ invocation: {
171
+ executionGrant: grant.token,
172
+ runId: "run_1",
173
+ callId: "call_1",
174
+ language: "python" as const,
175
+ },
176
+ guard: (
177
+ ports: {
178
+ state?: ConstructorParameters<typeof IsolatedExecutionGuard>[5];
179
+ budget?: ConstructorParameters<typeof IsolatedExecutionGuard>[6];
180
+ available?: boolean;
181
+ } = {},
182
+ ) =>
183
+ new IsolatedExecutionGuard(
184
+ [module],
185
+ hosts,
186
+ runtime,
187
+ authorizations,
188
+ async () => actor,
189
+ ports.state,
190
+ ports.budget,
191
+ ports.available,
192
+ ),
193
+ };
194
+ }