create-windy 0.2.19 → 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.
- package/README.md +5 -1
- package/dist/cli.js +392 -160
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/application-services.ts +2 -2
- package/template/apps/server/src/audit/search-summary.ts +15 -8
- package/template/apps/server/src/example-modules.ts +58 -70
- package/template/apps/server/src/foundation.ts +8 -4
- package/template/apps/server/src/guards.test.ts +13 -0
- package/template/apps/server/src/guards.ts +12 -2
- package/template/apps/server/src/index.ts +48 -95
- package/template/apps/server/src/installed-business-modules.ts +23 -4
- package/template/apps/server/src/module-bootstrap.ts +83 -0
- package/template/apps/server/src/module-composition.test.ts +54 -1
- package/template/apps/server/src/module-composition.ts +58 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +192 -0
- package/template/apps/server/src/module-host/initialize.ts +328 -0
- package/template/apps/server/src/module-host/request-context.test.ts +66 -0
- package/template/apps/server/src/module-host/request-context.ts +156 -0
- package/template/apps/server/src/module-host/role-presets.test.ts +94 -0
- package/template/apps/server/src/module-host/role-presets.ts +76 -0
- package/template/apps/server/src/module-host/route-installer.test.ts +317 -0
- package/template/apps/server/src/module-host/route-installer.ts +97 -0
- package/template/apps/server/src/module-host/route-registration.test.ts +160 -0
- package/template/apps/server/src/module-host/search-providers.ts +31 -0
- package/template/apps/server/src/module-host/storage-port.test.ts +165 -0
- package/template/apps/server/src/module-host/storage-port.ts +59 -0
- package/template/apps/server/src/module-host/task-definitions.ts +41 -0
- package/template/apps/server/src/module-host/test-fixtures.ts +26 -0
- package/template/apps/server/src/module-host/tool-handlers.ts +25 -0
- package/template/apps/server/src/module-host.test.ts +202 -58
- package/template/apps/server/src/module-host.ts +35 -113
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/apps/server/src/route-guards.test.ts +112 -1
- package/template/apps/server/src/runtime-feature.ts +66 -31
- package/template/apps/server/src/runtime.test.ts +1 -0
- package/template/apps/server/src/runtime.ts +3 -1
- package/template/apps/server/src/system/built-in-roles.ts +1 -1
- package/template/apps/server/src/work-order/foundation-modules.test.ts +30 -10
- package/template/apps/server/src/work-order/routes.test.ts +30 -18
- package/template/apps/server/src/work-order/routes.ts +104 -135
- package/template/apps/server/src/work-order/search-api.test.ts +14 -3
- package/template/apps/server/src/work-order/search-provider.test.ts +23 -15
- package/template/apps/server/src/work-order/search-provider.ts +17 -12
- package/template/apps/server/src/work-order/task.test.ts +16 -11
- package/template/apps/web/Dockerfile +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.layering.webtest.ts +4 -1
- package/template/apps/web/src/layout/GlobalWatermark.vue +2 -0
- package/template/apps/web/src/layout/GlobalWatermark.webtest.ts +4 -1
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/architecture/object-storage.md +12 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +100 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/config/src/platform.ts +28 -1
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/runner.ts +7 -9
- package/template/packages/example-work-order/index.ts +1 -0
- package/template/packages/example-work-order/package.json +2 -0
- package/template/packages/example-work-order/src/server-module.ts +61 -0
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/types.ts +7 -1
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/catalog-validation.test.ts +212 -0
- package/template/packages/modules/src/catalog-validation.ts +19 -2
- package/template/packages/modules/src/migration-bundle-validation.test.ts +191 -0
- package/template/packages/server-sdk/index.ts +53 -0
- package/template/packages/server-sdk/package.json +18 -3
- package/template/packages/server-sdk/src/actor.ts +15 -0
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/audit-port.ts +22 -0
- package/template/packages/server-sdk/src/jobs-port.ts +24 -0
- package/template/packages/server-sdk/src/module-host.ts +41 -1
- package/template/packages/server-sdk/src/namespace.test.ts +26 -0
- package/template/packages/server-sdk/src/namespace.ts +13 -0
- package/template/packages/server-sdk/src/request-context.ts +21 -0
- package/template/packages/server-sdk/src/role-preset.ts +18 -0
- package/template/packages/server-sdk/src/route-definition.ts +26 -0
- package/template/packages/server-sdk/src/search-provider-port.ts +48 -0
- package/template/packages/server-sdk/src/storage-port.ts +25 -0
- package/template/packages/server-sdk/src/task-registration.ts +18 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
- package/template/packages/server-sdk/src/tool-registration.ts +29 -0
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/license-catalog.test.ts +73 -1
- package/template/packages/shared/src/license-catalog.ts +95 -0
- package/template/packages/{database/src/bundle-validation.ts → shared/src/migration-bundle-validation.ts} +2 -1
- package/template/apps/server/src/work-order/task.ts +0 -30
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUIDv7 } from "bun";
|
|
2
|
+
import type { ModuleAiOperationDefinition } from "@southwind-ai/modules";
|
|
3
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
4
|
+
|
|
5
|
+
export interface ExecutionGrant {
|
|
6
|
+
readonly token: string;
|
|
7
|
+
readonly operation: ModuleAiOperationDefinition;
|
|
8
|
+
readonly context: RequestGuardContext;
|
|
9
|
+
readonly expiresAt: number;
|
|
10
|
+
runId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ExecutionGrantStore {
|
|
14
|
+
readonly #grants = new Map<string, ExecutionGrant>();
|
|
15
|
+
readonly #runTokens = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly maxActiveGrants = 1_000,
|
|
19
|
+
private readonly now: () => number = Date.now,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
issue(
|
|
23
|
+
operation: ModuleAiOperationDefinition,
|
|
24
|
+
context: RequestGuardContext,
|
|
25
|
+
): ExecutionGrant {
|
|
26
|
+
this.prune();
|
|
27
|
+
if (this.#grants.size >= this.maxActiveGrants) {
|
|
28
|
+
throw new Error("Agent Execution Grant 容量已满");
|
|
29
|
+
}
|
|
30
|
+
const token = `grant_${randomUUIDv7().replaceAll("-", "")}`;
|
|
31
|
+
const expiresAt =
|
|
32
|
+
this.now() + Math.min(operation.limits.maxDurationSeconds, 300) * 1_000;
|
|
33
|
+
const grant = { token, operation, context, expiresAt };
|
|
34
|
+
this.#grants.set(token, grant);
|
|
35
|
+
return grant;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
bindRun(token: string, runId: string): void {
|
|
39
|
+
const grant = this.require(token);
|
|
40
|
+
const existingToken = this.#runTokens.get(runId);
|
|
41
|
+
if (existingToken && existingToken !== token) {
|
|
42
|
+
throw new Error("Agent Run 已绑定其他 Execution Grant");
|
|
43
|
+
}
|
|
44
|
+
grant.runId = runId;
|
|
45
|
+
this.#runTokens.set(runId, token);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
require(token: string, runId?: string): ExecutionGrant {
|
|
49
|
+
const grant = this.#grants.get(token);
|
|
50
|
+
if (!grant || grant.expiresAt <= this.now()) {
|
|
51
|
+
if (grant?.runId) this.#runTokens.delete(grant.runId);
|
|
52
|
+
this.#grants.delete(token);
|
|
53
|
+
throw new Error("Agent Execution Grant 无效或已过期");
|
|
54
|
+
}
|
|
55
|
+
if (runId && grant.runId !== runId) {
|
|
56
|
+
throw new Error("Agent Execution Grant 与 Run 不匹配");
|
|
57
|
+
}
|
|
58
|
+
return grant;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
requireRun(runId: string, actorId: string): ExecutionGrant {
|
|
62
|
+
const token = this.#runTokens.get(runId);
|
|
63
|
+
const grant = token ? this.require(token, runId) : undefined;
|
|
64
|
+
if (!grant || grant.context.actor.id !== actorId) {
|
|
65
|
+
throw new Error("Agent Run 不属于当前 Actor");
|
|
66
|
+
}
|
|
67
|
+
return grant;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
revokeRun(runId: string): void {
|
|
71
|
+
const token = this.#runTokens.get(runId);
|
|
72
|
+
if (token) this.#grants.delete(token);
|
|
73
|
+
this.#runTokens.delete(runId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
discard(token: string): void {
|
|
77
|
+
const grant = this.#grants.get(token);
|
|
78
|
+
if (grant?.runId) this.#runTokens.delete(grant.runId);
|
|
79
|
+
this.#grants.delete(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private prune(): void {
|
|
83
|
+
for (const [token, grant] of this.#grants) {
|
|
84
|
+
if (grant.expiresAt > this.now()) continue;
|
|
85
|
+
if (grant.runId) this.#runTokens.delete(grant.runId);
|
|
86
|
+
this.#grants.delete(token);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentRuntimeOperation,
|
|
3
|
+
AgentRuntimePort,
|
|
4
|
+
AgentRuntimeStartResponse,
|
|
5
|
+
} from "./run-contracts.js";
|
|
6
|
+
|
|
7
|
+
export interface RemoteAgentRuntimeOptions {
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
internalToken: string;
|
|
10
|
+
toolHostUrl: string;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class RemoteAgentRuntime implements AgentRuntimePort {
|
|
15
|
+
readonly #baseUrl: string;
|
|
16
|
+
readonly #timeoutMs: number;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: RemoteAgentRuntimeOptions) {
|
|
19
|
+
this.#baseUrl = normalizeInternalUrl(options.baseUrl);
|
|
20
|
+
this.#timeoutMs = options.timeoutMs ?? 10_000;
|
|
21
|
+
if (!options.internalToken.trim()) {
|
|
22
|
+
throw new Error("Agent Server 内部 Token 不能为空");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async start(
|
|
27
|
+
request: AgentRuntimeOperation,
|
|
28
|
+
idempotencyKey: string,
|
|
29
|
+
): Promise<AgentRuntimeStartResponse> {
|
|
30
|
+
const response = await this.request("/api/agent/runs", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"content-type": "application/json",
|
|
34
|
+
"idempotency-key": idempotencyKey,
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
...request,
|
|
38
|
+
toolHostUrl: this.options.toolHostUrl,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) throw await remoteError(response);
|
|
42
|
+
return (await response.json()) as AgentRuntimeStartResponse;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
events(runId: string, cursor: number, signal?: AbortSignal) {
|
|
46
|
+
return this.request(
|
|
47
|
+
`/api/agent/runs/${encodeURIComponent(runId)}/events?after=${cursor}`,
|
|
48
|
+
{ signal },
|
|
49
|
+
false,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
result(runId: string, signal?: AbortSignal) {
|
|
54
|
+
return this.request(`/api/agent/runs/${encodeURIComponent(runId)}/result`, {
|
|
55
|
+
signal,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
cancel(runId: string, reason?: string, signal?: AbortSignal) {
|
|
60
|
+
return this.request(`/api/agent/runs/${encodeURIComponent(runId)}/cancel`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify({ reason }),
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
health(signal?: AbortSignal) {
|
|
69
|
+
return this.request("/health/ready", { signal });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async request(
|
|
73
|
+
path: string,
|
|
74
|
+
init: RequestInit = {},
|
|
75
|
+
bounded = true,
|
|
76
|
+
): Promise<Response> {
|
|
77
|
+
const timeout = AbortSignal.timeout(this.#timeoutMs);
|
|
78
|
+
const signal = init.signal
|
|
79
|
+
? AbortSignal.any([init.signal, timeout])
|
|
80
|
+
: timeout;
|
|
81
|
+
const response = await fetch(`${this.#baseUrl}${path}`, {
|
|
82
|
+
...init,
|
|
83
|
+
signal,
|
|
84
|
+
headers: {
|
|
85
|
+
authorization: `Bearer ${this.options.internalToken}`,
|
|
86
|
+
...init.headers,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
if (bounded) {
|
|
90
|
+
const length = Number(response.headers.get("content-length") || "0");
|
|
91
|
+
if (length > 2 * 1_024 * 1_024) {
|
|
92
|
+
throw new Error("Agent Server 响应超过上限");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeInternalUrl(raw: string): string {
|
|
100
|
+
const url = new URL(raw);
|
|
101
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
102
|
+
throw new Error("Agent Server URL 只允许 HTTP 或 HTTPS");
|
|
103
|
+
}
|
|
104
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
105
|
+
throw new Error("Agent Server URL 不允许凭据、查询串或片段");
|
|
106
|
+
}
|
|
107
|
+
return url.toString().replace(/\/$/, "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function remoteError(response: Response): Promise<Error> {
|
|
111
|
+
const safe = `Agent Runtime 请求失败(HTTP ${response.status})`;
|
|
112
|
+
try {
|
|
113
|
+
const value = (await response.json()) as {
|
|
114
|
+
code?: unknown;
|
|
115
|
+
message?: unknown;
|
|
116
|
+
};
|
|
117
|
+
if (
|
|
118
|
+
typeof value.code === "string" &&
|
|
119
|
+
typeof value.message === "string" &&
|
|
120
|
+
value.message.length <= 200
|
|
121
|
+
) {
|
|
122
|
+
return new Error(`${value.code}: ${value.message}`);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Provider 或代理正文不可透传。
|
|
126
|
+
}
|
|
127
|
+
return new Error(safe);
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ModuleAiJsonValue } from "@southwind-ai/server-sdk";
|
|
2
|
+
|
|
3
|
+
export interface AgentRuntimeOperation {
|
|
4
|
+
operationKey: string;
|
|
5
|
+
input: ModuleAiJsonValue;
|
|
6
|
+
instructions: string;
|
|
7
|
+
userPrompt: string;
|
|
8
|
+
allowedTools: Array<{
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
description: string;
|
|
12
|
+
}>;
|
|
13
|
+
limits: {
|
|
14
|
+
maxDurationSeconds: number;
|
|
15
|
+
maxOutputBytes: number;
|
|
16
|
+
};
|
|
17
|
+
executionGrant: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentRuntimeStartResponse {
|
|
21
|
+
runId: string;
|
|
22
|
+
status: string;
|
|
23
|
+
eventsUrl: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentRuntimePort {
|
|
27
|
+
start(
|
|
28
|
+
request: AgentRuntimeOperation,
|
|
29
|
+
idempotencyKey: string,
|
|
30
|
+
): Promise<AgentRuntimeStartResponse>;
|
|
31
|
+
events(
|
|
32
|
+
runId: string,
|
|
33
|
+
cursor: number,
|
|
34
|
+
signal?: AbortSignal,
|
|
35
|
+
): Promise<Response>;
|
|
36
|
+
result(runId: string, signal?: AbortSignal): Promise<Response>;
|
|
37
|
+
cancel(
|
|
38
|
+
runId: string,
|
|
39
|
+
reason?: string,
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
): Promise<Response>;
|
|
42
|
+
health(signal?: AbortSignal): Promise<Response>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
3
|
+
import { featureKey } from "@southwind-ai/shared";
|
|
4
|
+
import type {
|
|
5
|
+
AgentRuntimeOperation,
|
|
6
|
+
AgentRuntimePort,
|
|
7
|
+
} from "./run-contracts.js";
|
|
8
|
+
import { buildScopeContext } from "../data-access/context.js";
|
|
9
|
+
import { createRequestGuardContext } from "../guards.js";
|
|
10
|
+
import { initializeModuleHosts } from "../module-host.js";
|
|
11
|
+
import { createServerRuntime } from "../runtime.js";
|
|
12
|
+
import { ExecutionGrantStore } from "./execution-grants.js";
|
|
13
|
+
import { AgentRunFacade } from "./run-facade.js";
|
|
14
|
+
|
|
15
|
+
describe("Guarded Agent Run facade", () => {
|
|
16
|
+
test("Guard 后由宿主 Handler 生成计划并签发短时 Grant", async () => {
|
|
17
|
+
const fixture = await createFixture();
|
|
18
|
+
const created = await fixture.facade.start(
|
|
19
|
+
"business.answer",
|
|
20
|
+
{ question: "你好" },
|
|
21
|
+
"request-1",
|
|
22
|
+
fixture.context,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
expect(created).toEqual({
|
|
26
|
+
runId: "run_1",
|
|
27
|
+
status: "accepted",
|
|
28
|
+
eventsUrl: "/api/agent/runs/run_1/events",
|
|
29
|
+
});
|
|
30
|
+
expect(fixture.remote.started).toHaveLength(1);
|
|
31
|
+
expect(fixture.remote.started[0]).toMatchObject({
|
|
32
|
+
operationKey: "business.answer",
|
|
33
|
+
instructions: "只回答当前业务问题。",
|
|
34
|
+
userPrompt: "你好",
|
|
35
|
+
allowedTools: [{ key: "business.records.list" }],
|
|
36
|
+
});
|
|
37
|
+
expect(fixture.remote.started[0]?.executionGrant).toMatch(/^grant_/);
|
|
38
|
+
expect(() =>
|
|
39
|
+
fixture.facade.authorizeRun("run_1", fixture.context),
|
|
40
|
+
).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("客户端输入不能覆盖 Operation、Tool 或授权", async () => {
|
|
44
|
+
const fixture = await createFixture();
|
|
45
|
+
await fixture.facade.start(
|
|
46
|
+
"business.answer",
|
|
47
|
+
{
|
|
48
|
+
question: "正常问题",
|
|
49
|
+
allowedToolKeys: ["business.admin.delete"],
|
|
50
|
+
permissionKeys: ["business.admin"],
|
|
51
|
+
executionGrant: "forged",
|
|
52
|
+
},
|
|
53
|
+
"request-2",
|
|
54
|
+
fixture.context,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(
|
|
58
|
+
fixture.remote.started[0]?.allowedTools.map(({ key }) => key),
|
|
59
|
+
).toEqual(["business.records.list"]);
|
|
60
|
+
expect(fixture.remote.started[0]?.executionGrant).not.toBe("forged");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("缺少 Operation 权限时不调用 Handler 或 Runtime", async () => {
|
|
64
|
+
const fixture = await createFixture([]);
|
|
65
|
+
await expect(
|
|
66
|
+
fixture.facade.start(
|
|
67
|
+
"business.answer",
|
|
68
|
+
{ question: "越权" },
|
|
69
|
+
"request-3",
|
|
70
|
+
fixture.context,
|
|
71
|
+
),
|
|
72
|
+
).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
|
|
73
|
+
expect(fixture.remote.started).toEqual([]);
|
|
74
|
+
expect(fixture.prepared).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
class FakeRuntime implements AgentRuntimePort {
|
|
79
|
+
readonly started: AgentRuntimeOperation[] = [];
|
|
80
|
+
|
|
81
|
+
start(request: AgentRuntimeOperation) {
|
|
82
|
+
this.started.push(request);
|
|
83
|
+
return Promise.resolve({
|
|
84
|
+
runId: "run_1",
|
|
85
|
+
status: "accepted",
|
|
86
|
+
eventsUrl: "/internal/events",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
events() {
|
|
91
|
+
return Promise.resolve(new Response());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
result() {
|
|
95
|
+
return Promise.resolve(new Response());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
cancel() {
|
|
99
|
+
return Promise.resolve(new Response());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
health() {
|
|
103
|
+
return Promise.resolve(new Response());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function createFixture(
|
|
108
|
+
permissionKeys: string[] = ["business.ai.execute", "business.records.read"],
|
|
109
|
+
) {
|
|
110
|
+
const module = manifest();
|
|
111
|
+
const prepared: unknown[] = [];
|
|
112
|
+
const hosts = await initializeModuleHosts({
|
|
113
|
+
modules: [module],
|
|
114
|
+
installations: [
|
|
115
|
+
{
|
|
116
|
+
manifest: module,
|
|
117
|
+
setup(host) {
|
|
118
|
+
host.registerAiOperationHandlers([
|
|
119
|
+
{
|
|
120
|
+
operationKey: "business.answer",
|
|
121
|
+
prepare(input, context) {
|
|
122
|
+
prepared.push({ input, actorId: context.actor.id });
|
|
123
|
+
const question =
|
|
124
|
+
typeof input === "object" &&
|
|
125
|
+
input !== null &&
|
|
126
|
+
!Array.isArray(input) &&
|
|
127
|
+
typeof input.question === "string"
|
|
128
|
+
? input.question
|
|
129
|
+
: "";
|
|
130
|
+
return {
|
|
131
|
+
instructions: "只回答当前业务问题。",
|
|
132
|
+
userPrompt: question,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
const runtime = createServerRuntime(
|
|
142
|
+
{},
|
|
143
|
+
{
|
|
144
|
+
featureCatalog: module.features,
|
|
145
|
+
licenseResolver: { resolve: () => ({ status: "valid" }) },
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
const context = createRequestGuardContext(
|
|
149
|
+
new Request("http://localhost/api/agent/runs"),
|
|
150
|
+
{
|
|
151
|
+
resolveActor: () => ({
|
|
152
|
+
id: "actor-1",
|
|
153
|
+
type: "user",
|
|
154
|
+
name: "测试用户",
|
|
155
|
+
roleCodes: ["user"],
|
|
156
|
+
permissionKeys,
|
|
157
|
+
scopeContext: buildScopeContext("actor-1", undefined, [
|
|
158
|
+
{
|
|
159
|
+
id: "role-user",
|
|
160
|
+
code: "user",
|
|
161
|
+
dataScope: "self",
|
|
162
|
+
departmentIds: [],
|
|
163
|
+
},
|
|
164
|
+
]),
|
|
165
|
+
}),
|
|
166
|
+
resolveLicense: () => ({ status: "valid" }),
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
const remote = new FakeRuntime();
|
|
170
|
+
const facade = new AgentRunFacade(
|
|
171
|
+
[module],
|
|
172
|
+
hosts,
|
|
173
|
+
runtime,
|
|
174
|
+
remote,
|
|
175
|
+
new ExecutionGrantStore(),
|
|
176
|
+
);
|
|
177
|
+
return { context, facade, prepared, remote };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function manifest(): ModuleManifest {
|
|
181
|
+
return {
|
|
182
|
+
name: "business",
|
|
183
|
+
version: "0.1.0",
|
|
184
|
+
label: "业务",
|
|
185
|
+
description: "业务",
|
|
186
|
+
menus: [],
|
|
187
|
+
adminRoutes: [],
|
|
188
|
+
permissions: [
|
|
189
|
+
permission("business.ai.execute", "ai", "manage"),
|
|
190
|
+
permission("business.records.read", "records", "read"),
|
|
191
|
+
],
|
|
192
|
+
apiPermissions: [],
|
|
193
|
+
features: [
|
|
194
|
+
enabledFeature("business.ai"),
|
|
195
|
+
enabledFeature("business.records"),
|
|
196
|
+
],
|
|
197
|
+
migrations: [],
|
|
198
|
+
schemaExports: [],
|
|
199
|
+
tasks: [],
|
|
200
|
+
tools: [
|
|
201
|
+
{
|
|
202
|
+
key: "business.records.list",
|
|
203
|
+
label: "查询记录",
|
|
204
|
+
description: "查询当前范围记录。",
|
|
205
|
+
permissionKey: "business.records.read",
|
|
206
|
+
featureKey: "business.records",
|
|
207
|
+
auditAction: "business.records.read",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
aiOperations: [
|
|
211
|
+
{
|
|
212
|
+
key: "business.answer",
|
|
213
|
+
kind: "agent",
|
|
214
|
+
featureKey: "business.ai",
|
|
215
|
+
permissionKey: "business.ai.execute",
|
|
216
|
+
auditAction: "business.ai.execute",
|
|
217
|
+
inputSchemaKey: "business.input.v1",
|
|
218
|
+
outputSchemaKey: "business.output.v1",
|
|
219
|
+
requiredCapabilities: ["text-generation"],
|
|
220
|
+
allowedToolKeys: ["business.records.list"],
|
|
221
|
+
dataClassification: "internal",
|
|
222
|
+
execution: "streaming",
|
|
223
|
+
publicNetworkAccess: "forbidden",
|
|
224
|
+
limits: {
|
|
225
|
+
maxDurationSeconds: 60,
|
|
226
|
+
maxInputBytes: 4096,
|
|
227
|
+
maxOutputBytes: 4096,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
providers: [],
|
|
232
|
+
auditActions: ["business.ai.execute", "business.records.read"],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function permission(key: string, resource: string, action: "read" | "manage") {
|
|
237
|
+
return {
|
|
238
|
+
key,
|
|
239
|
+
label: key,
|
|
240
|
+
module: "business",
|
|
241
|
+
moduleLabel: "业务",
|
|
242
|
+
resource,
|
|
243
|
+
resourceLabel: resource,
|
|
244
|
+
action,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function enabledFeature(key: string) {
|
|
249
|
+
return {
|
|
250
|
+
key: featureKey(key),
|
|
251
|
+
label: key,
|
|
252
|
+
module: "business",
|
|
253
|
+
visible: "visible" as const,
|
|
254
|
+
enabled: true,
|
|
255
|
+
stage: "stable" as const,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { randomUUIDv7 } from "bun";
|
|
2
|
+
import type {
|
|
3
|
+
ModuleAiOperationDefinition,
|
|
4
|
+
ModuleManifest,
|
|
5
|
+
} from "@southwind-ai/modules";
|
|
6
|
+
import type {
|
|
7
|
+
ModuleAiJsonValue,
|
|
8
|
+
ModuleAiOperationHandlerRegistration,
|
|
9
|
+
} from "@southwind-ai/server-sdk";
|
|
10
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
11
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
12
|
+
import type { InitializedModuleHosts } from "../module-host.js";
|
|
13
|
+
import { toModuleActor } from "../module-host.js";
|
|
14
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
15
|
+
import { findFeature } from "../route-guards.js";
|
|
16
|
+
import { ExecutionGrantStore } from "./execution-grants.js";
|
|
17
|
+
import type {
|
|
18
|
+
AgentRuntimePort,
|
|
19
|
+
AgentRuntimeStartResponse,
|
|
20
|
+
} from "./run-contracts.js";
|
|
21
|
+
|
|
22
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
23
|
+
|
|
24
|
+
export class AgentRunFacade {
|
|
25
|
+
readonly #operations: Map<string, RegisteredOperation>;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
modules: readonly ModuleManifest[],
|
|
29
|
+
hosts: InitializedModuleHosts,
|
|
30
|
+
private readonly runtime: ServerRuntime,
|
|
31
|
+
private readonly remote: AgentRuntimePort,
|
|
32
|
+
private readonly grants: ExecutionGrantStore,
|
|
33
|
+
) {
|
|
34
|
+
const handlers = new Map(
|
|
35
|
+
hosts.aiOperationHandlers.map(({ registration }) => [
|
|
36
|
+
registration.operationKey,
|
|
37
|
+
registration,
|
|
38
|
+
]),
|
|
39
|
+
);
|
|
40
|
+
const features = modules.flatMap(({ features }) => features);
|
|
41
|
+
this.#operations = new Map(
|
|
42
|
+
modules.flatMap((module) =>
|
|
43
|
+
(module.aiOperations ?? []).map(
|
|
44
|
+
(definition) =>
|
|
45
|
+
[
|
|
46
|
+
definition.key,
|
|
47
|
+
{
|
|
48
|
+
definition,
|
|
49
|
+
handler: handlers.get(definition.key),
|
|
50
|
+
feature: findFeature(features, definition.featureKey),
|
|
51
|
+
tools: toolDefinitions(modules, definition.allowedToolKeys),
|
|
52
|
+
},
|
|
53
|
+
] as const,
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async start(
|
|
60
|
+
operationKey: string,
|
|
61
|
+
input: ModuleAiJsonValue,
|
|
62
|
+
idempotencyKey: string,
|
|
63
|
+
context: RequestGuardContext,
|
|
64
|
+
): Promise<AgentRuntimeStartResponse> {
|
|
65
|
+
const operation = this.#operations.get(operationKey);
|
|
66
|
+
if (!operation?.handler) {
|
|
67
|
+
throw new AgentRunFacadeError(
|
|
68
|
+
"OPERATION_NOT_FOUND",
|
|
69
|
+
"Agent Operation 不存在",
|
|
70
|
+
404,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const decision = await evaluateAccessGuards(this.runtime, context, {
|
|
74
|
+
permission: {
|
|
75
|
+
permissionKey: operation.definition.permissionKey,
|
|
76
|
+
},
|
|
77
|
+
feature: operation.feature,
|
|
78
|
+
});
|
|
79
|
+
if (!decision.decision.allowed) {
|
|
80
|
+
throw new AgentRunFacadeError(
|
|
81
|
+
"FORBIDDEN",
|
|
82
|
+
"无权执行 Agent Operation",
|
|
83
|
+
403,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
assertInputSize(input, operation.definition.limits.maxInputBytes);
|
|
87
|
+
const plan = await operation.handler.prepare(input, {
|
|
88
|
+
actor: toModuleActor(context.actor),
|
|
89
|
+
requestId: context.requestId,
|
|
90
|
+
});
|
|
91
|
+
if (!plan.instructions.trim() || !plan.userPrompt.trim()) {
|
|
92
|
+
throw new AgentRunFacadeError(
|
|
93
|
+
"INVALID_INPUT",
|
|
94
|
+
"Agent Operation 执行计划无效",
|
|
95
|
+
400,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const grant = this.grants.issue(operation.definition, context);
|
|
99
|
+
try {
|
|
100
|
+
const created = await this.remote.start(
|
|
101
|
+
{
|
|
102
|
+
operationKey,
|
|
103
|
+
input,
|
|
104
|
+
instructions: plan.instructions,
|
|
105
|
+
userPrompt: plan.userPrompt,
|
|
106
|
+
allowedTools: operation.tools,
|
|
107
|
+
limits: operation.definition.limits,
|
|
108
|
+
executionGrant: grant.token,
|
|
109
|
+
},
|
|
110
|
+
idempotencyKey,
|
|
111
|
+
);
|
|
112
|
+
this.grants.bindRun(grant.token, created.runId);
|
|
113
|
+
this.runtime.auditSink.recordEvent({
|
|
114
|
+
id: randomUUIDv7(),
|
|
115
|
+
action: operation.definition.auditAction,
|
|
116
|
+
outcome: "success",
|
|
117
|
+
actor: {
|
|
118
|
+
id: context.actor.id,
|
|
119
|
+
type:
|
|
120
|
+
context.actor.type === "anonymous" ? "user" : context.actor.type,
|
|
121
|
+
name: context.actor.name,
|
|
122
|
+
},
|
|
123
|
+
target: {
|
|
124
|
+
type: "agent-run",
|
|
125
|
+
id: created.runId,
|
|
126
|
+
label: operationKey,
|
|
127
|
+
},
|
|
128
|
+
requestId: context.requestId,
|
|
129
|
+
occurredAt: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
...created,
|
|
133
|
+
eventsUrl: `/api/agent/runs/${created.runId}/events`,
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.grants.discard(grant.token);
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
authorizeRun(runId: string, context: RequestGuardContext): void {
|
|
142
|
+
this.grants.requireRun(runId, context.actor.id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface RegisteredOperation {
|
|
147
|
+
definition: ModuleAiOperationDefinition;
|
|
148
|
+
handler?: ModuleAiOperationHandlerRegistration;
|
|
149
|
+
feature: ReturnType<typeof findFeature>;
|
|
150
|
+
tools: Array<{ key: string; label: string; description: string }>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export class AgentRunFacadeError extends Error {
|
|
154
|
+
constructor(
|
|
155
|
+
readonly code:
|
|
156
|
+
| "OPERATION_NOT_FOUND"
|
|
157
|
+
| "INVALID_INPUT"
|
|
158
|
+
| "FORBIDDEN"
|
|
159
|
+
| "RUNTIME_UNAVAILABLE",
|
|
160
|
+
message: string,
|
|
161
|
+
readonly status: number,
|
|
162
|
+
) {
|
|
163
|
+
super(message);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function toolDefinitions(
|
|
168
|
+
modules: readonly ModuleManifest[],
|
|
169
|
+
allowedKeys: readonly string[],
|
|
170
|
+
) {
|
|
171
|
+
const tools = new Map(
|
|
172
|
+
modules.flatMap(({ tools }) => tools.map((tool) => [tool.key, tool])),
|
|
173
|
+
);
|
|
174
|
+
return allowedKeys.map((key) => {
|
|
175
|
+
const tool = tools.get(key);
|
|
176
|
+
if (!tool) throw new Error(`AI Operation 引用了未知 Tool:${key}`);
|
|
177
|
+
return {
|
|
178
|
+
key: tool.key,
|
|
179
|
+
label: tool.label,
|
|
180
|
+
description: tool.description,
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function assertInputSize(value: ModuleAiJsonValue, maxBytes: number): void {
|
|
186
|
+
const size = new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
187
|
+
if (size > maxBytes) {
|
|
188
|
+
throw new AgentRunFacadeError("INVALID_INPUT", "Agent 输入超过上限", 400);
|
|
189
|
+
}
|
|
190
|
+
}
|