create-windy 0.8.1 → 0.8.3
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/dist/cli.js +52 -34
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
- package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +16 -5
- package/template/apps/agent-server/tests/test_structured_logging.py +37 -0
- package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
- package/template/apps/server/src/agent/admin-catalog-bootstrap.ts +25 -0
- package/template/apps/server/src/agent/agent-session-bootstrap.ts +7 -8
- package/template/apps/server/src/agent/agent-session-facade.ts +12 -0
- package/template/apps/server/src/agent/agent-session-validation.ts +43 -0
- package/template/apps/server/src/agent/operation-catalog.test.ts +202 -0
- package/template/apps/server/src/agent/operation-catalog.ts +135 -0
- package/template/apps/server/src/agent/run-browser-visibility.test.ts +59 -0
- package/template/apps/server/src/agent/run-browser-visibility.ts +11 -0
- package/template/apps/server/src/agent/run-operation-policy.ts +1 -0
- package/template/apps/server/src/agent/run-route-response.ts +35 -0
- package/template/apps/server/src/agent/run-routes.ts +46 -43
- package/template/apps/server/src/agent/runtime-bootstrap.ts +21 -20
- package/template/apps/server/src/agent/runtime-disabled-composition.ts +28 -0
- package/template/apps/server/src/agent/session-projection-facade.ts +8 -9
- package/template/apps/server/src/agent/session-projection-governance.ts +10 -0
- package/template/apps/server/src/agent/session-projection-view.ts +19 -16
- package/template/apps/server/src/agent/session-recording-reader.contract.test.ts +131 -0
- package/template/apps/server/src/agent/session-recording-reader.ts +62 -0
- package/template/apps/server/src/agent/tool-catalog-audit.ts +22 -0
- package/template/apps/server/src/agent/tool-catalog-availability.ts +21 -0
- package/template/apps/server/src/agent/tool-catalog-routes.test.ts +103 -0
- package/template/apps/server/src/agent/tool-catalog-routes.ts +62 -0
- package/template/apps/server/src/agent/tool-catalog.test.ts +122 -0
- package/template/apps/server/src/agent/tool-catalog.ts +44 -0
- package/template/apps/server/src/agent/tool-registry.ts +45 -14
- package/template/apps/server/src/agent/workbench-bootstrap.ts +39 -0
- package/template/apps/server/src/agent/workbench-event-projection.ts +115 -0
- package/template/apps/server/src/agent/workbench-facade.contract.test.ts +189 -0
- package/template/apps/server/src/agent/workbench-facade.ts +339 -0
- package/template/apps/server/src/agent/workbench-recording.contract.test.ts +328 -0
- package/template/apps/server/src/agent/workbench-routes.ts +97 -0
- package/template/apps/server/src/agent/workbench-run-integration.test.ts +231 -0
- package/template/apps/server/src/agent/workbench-run-reader.ts +187 -0
- package/template/apps/server/src/agent/workbench-safe-result.ts +61 -0
- package/template/apps/server/src/agent/workbench-sse.ts +50 -0
- package/template/apps/server/src/agent-workflow/query-service.ts +36 -0
- package/template/apps/server/src/agent-workflow/recording-projection.contract.test.ts +196 -0
- package/template/apps/server/src/agent-workflow/recording-projection.ts +86 -0
- package/template/apps/web/src/pages/system/AgentDebugPage.vue +14 -0
- package/template/apps/web/src/pages/system/AgentSessionPage.vue +29 -8
- package/template/apps/web/src/pages/system/AgentToolsPage.vue +76 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/AgentWorkbench.vue +236 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/DraftLeaveDialog.vue +34 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ReplayEventDetail.vue +175 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.vue +142 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/SessionReplayTimeline.webtest.ts +242 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/TestRunComposer.vue +97 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.vue +100 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolCatalogPanel.webtest.ts +55 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/ToolRuleDetail.vue +81 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/WorkbenchSessionList.vue +57 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/catalog-presenter.ts +20 -0
- package/template/apps/web/src/pages/system/components/agent-workbench/replay-presenter.ts +94 -0
- package/template/apps/web/src/pages/system/composables/useAgentWorkbench.ts +298 -0
- package/template/apps/web/src/pages/system/composables/useAgentWorkbench.webtest.ts +164 -0
- package/template/apps/web/src/pages/system/composables/useSessionReplay.ts +48 -0
- package/template/apps/web/src/router/index.ts +4 -0
- package/template/apps/web/src/router/platform-admin-route-bindings.ts +4 -0
- package/template/apps/web/src/services/agent-admin-api.ts +155 -0
- package/template/apps/web/src/services/agent-admin-api.webtest.ts +120 -0
- package/template/apps/web/src/services/agent-workbench-event.ts +60 -0
- package/template/docs/architecture/agent-harness.md +4 -0
- package/template/docs/architecture/log-governance-implementation.md +20 -3
- package/template/docs/architecture/log-governance-plan.md +2 -2
- package/template/docs/platform/agent-runtime.md +18 -0
- package/template/packages/modules/src/ai-operation-composition.ts +9 -0
- package/template/packages/modules/src/ai-operation-visibility.test.ts +33 -0
- package/template/packages/modules/src/manifest.ts +2 -0
- package/template/packages/modules/src/system-admin-routes.ts +16 -0
- package/template/packages/modules/src/system-admin-ui-catalog.ts +7 -1
- package/template/packages/modules/src/system-agent-admin-bindings.ts +42 -0
- package/template/packages/modules/src/system-agent-admin-menus.ts +26 -0
- package/template/packages/modules/src/system-agent-admin-registration.test.ts +52 -0
- package/template/packages/modules/src/system-agent-audit-actions.ts +5 -0
- package/template/packages/modules/src/system-ai-api-bindings.ts +2 -0
- package/template/packages/modules/src/system-audit-actions.ts +25 -0
- package/template/packages/modules/src/system-features.ts +19 -0
- package/template/packages/modules/src/system-module-catalog.ts +14 -12
- package/template/packages/modules/src/system-module-ownership.ts +12 -0
- package/template/packages/modules/src/system-modules.ts +2 -0
- package/template/packages/modules/src/system-permissions.ts +5 -0
- package/template/packages/server-sdk/index.ts +2 -0
- package/template/packages/server-sdk/src/agent-session-provider.ts +19 -0
- package/template/packages/shared/index.ts +2 -0
- package/template/packages/shared/src/agent-admin-catalog.ts +73 -0
- package/template/packages/shared/src/agent-workbench.ts +100 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { AgentWorkbenchEventV1, JsonValue } from "@southwind-ai/shared";
|
|
2
|
+
|
|
3
|
+
const labels: Readonly<Record<string, string>> = {
|
|
4
|
+
"run.started": "运行开始",
|
|
5
|
+
"run.resumed": "运行恢复",
|
|
6
|
+
"input.prepared": "输入已准备",
|
|
7
|
+
"output.delta": "模型生成回答",
|
|
8
|
+
"tool.requested": "请求调用工具",
|
|
9
|
+
"tool.completed": "工具调用完成",
|
|
10
|
+
"approval.required": "等待审批",
|
|
11
|
+
"approval.resolved": "审批已处理",
|
|
12
|
+
"usage.reported": "模型用量已记录",
|
|
13
|
+
"run.completed": "运行完成",
|
|
14
|
+
"run.failed": "运行失败",
|
|
15
|
+
"run.cancelled": "运行已取消",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function projectWorkbenchRunEvent(
|
|
19
|
+
value: unknown,
|
|
20
|
+
): AgentWorkbenchEventV1 | undefined {
|
|
21
|
+
if (
|
|
22
|
+
!isRecord(value) ||
|
|
23
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
24
|
+
Number(value.sequence) < 1
|
|
25
|
+
)
|
|
26
|
+
return;
|
|
27
|
+
const type = safeCoordinate(value.type);
|
|
28
|
+
if (!type || !Object.hasOwn(labels, type)) return;
|
|
29
|
+
const projected: Record<string, JsonValue> = {
|
|
30
|
+
sequence: Number(value.sequence),
|
|
31
|
+
type,
|
|
32
|
+
summary: labels[type]!,
|
|
33
|
+
};
|
|
34
|
+
for (const key of [
|
|
35
|
+
"runId",
|
|
36
|
+
"turnId",
|
|
37
|
+
"stepId",
|
|
38
|
+
"modelAttemptId",
|
|
39
|
+
"toolCallId",
|
|
40
|
+
"toolKey",
|
|
41
|
+
"workItemId",
|
|
42
|
+
"artifactId",
|
|
43
|
+
"outcome",
|
|
44
|
+
] as const) {
|
|
45
|
+
const text = safeCoordinate(value[key]);
|
|
46
|
+
if (text) projected[key] = text;
|
|
47
|
+
}
|
|
48
|
+
if (
|
|
49
|
+
typeof value.occurredAt === "string" &&
|
|
50
|
+
Number.isFinite(Date.parse(value.occurredAt))
|
|
51
|
+
)
|
|
52
|
+
projected.occurredAt = value.occurredAt;
|
|
53
|
+
if (
|
|
54
|
+
Number.isSafeInteger(value.attemptSequence) &&
|
|
55
|
+
Number(value.attemptSequence) > 0
|
|
56
|
+
)
|
|
57
|
+
projected.attemptSequence = Number(value.attemptSequence);
|
|
58
|
+
if (isRecord(value.usage)) {
|
|
59
|
+
const usage: Record<string, number> = {};
|
|
60
|
+
for (const key of [
|
|
61
|
+
"inputTokens",
|
|
62
|
+
"outputTokens",
|
|
63
|
+
"totalTokens",
|
|
64
|
+
"costMicros",
|
|
65
|
+
] as const) {
|
|
66
|
+
const count = value.usage[key];
|
|
67
|
+
if (Number.isSafeInteger(count) && Number(count) >= 0)
|
|
68
|
+
usage[key] = Number(count);
|
|
69
|
+
}
|
|
70
|
+
projected.usage = usage;
|
|
71
|
+
}
|
|
72
|
+
return projected as unknown as AgentWorkbenchEventV1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** SSE keeps canonical answer deltas, but never forwards arbitrary Provider/Tool fields. */
|
|
76
|
+
export function projectPublicRunEvent(
|
|
77
|
+
value: unknown,
|
|
78
|
+
): Record<string, JsonValue> | undefined {
|
|
79
|
+
const projected = projectWorkbenchRunEvent(value);
|
|
80
|
+
if (!projected || !isRecord(value)) return;
|
|
81
|
+
const result = { ...projected } as Record<string, JsonValue>;
|
|
82
|
+
if (value.type === "output.delta" && typeof value.text === "string")
|
|
83
|
+
result.text = value.text.slice(0, 64_000);
|
|
84
|
+
if (value.type === "input.prepared") {
|
|
85
|
+
if (
|
|
86
|
+
Number.isSafeInteger(value.artifactCount) &&
|
|
87
|
+
Number(value.artifactCount) >= 0
|
|
88
|
+
)
|
|
89
|
+
result.artifactCount = Number(value.artifactCount);
|
|
90
|
+
if (Array.isArray(value.inputModalities))
|
|
91
|
+
result.inputModalities = value.inputModalities.filter(
|
|
92
|
+
(item): item is string =>
|
|
93
|
+
typeof item === "string" && /^[a-z-]{1,32}$/.test(item),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (value.type === "run.failed" && isRecord(value.error)) {
|
|
97
|
+
result.error = {
|
|
98
|
+
code: safeCoordinate(value.error.code) ?? "RUN_FAILED",
|
|
99
|
+
message: "运行失败,请查看受控错误代码",
|
|
100
|
+
retryable: value.error.retryable === true,
|
|
101
|
+
requestId: safeCoordinate(value.error.requestId) ?? "unavailable",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function safeCoordinate(value: unknown): string | undefined {
|
|
108
|
+
return typeof value === "string" && /^[A-Za-z0-9_.:/-]{1,200}$/.test(value)
|
|
109
|
+
? value
|
|
110
|
+
: undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
114
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
115
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
3
|
+
import type { AgentWorkbenchDependencies } from "./workbench-facade.js";
|
|
4
|
+
import { AgentWorkbenchFacade } from "./workbench-facade.js";
|
|
5
|
+
import { featureKey } from "@southwind-ai/shared";
|
|
6
|
+
import { guardContext } from "./run-facade.test-support.js";
|
|
7
|
+
import { createServerRuntime } from "../runtime.js";
|
|
8
|
+
import { manifest } from "../module-host/test-fixtures.js";
|
|
9
|
+
import { AgentWorkbenchRunReader } from "./workbench-run-reader.js";
|
|
10
|
+
import { createRunFacadeFixture } from "./run-facade.test-support.js";
|
|
11
|
+
|
|
12
|
+
function base(enabled = true) {
|
|
13
|
+
const keys = [
|
|
14
|
+
"system.agent-debug",
|
|
15
|
+
"system.agent-session",
|
|
16
|
+
"system.agent-workflow",
|
|
17
|
+
];
|
|
18
|
+
const module: ModuleManifest = manifest("system", {
|
|
19
|
+
features: keys.map((key) => ({
|
|
20
|
+
key: featureKey(key),
|
|
21
|
+
label: key,
|
|
22
|
+
module: "system",
|
|
23
|
+
enabled,
|
|
24
|
+
visible: "visible",
|
|
25
|
+
stage: "stable",
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
const runtime = createServerRuntime(
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
featureCatalog: module.features,
|
|
32
|
+
licenseResolver: { resolve: () => ({ status: "valid" }) },
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
return { modules: [module], runtime };
|
|
36
|
+
}
|
|
37
|
+
const permissions = [
|
|
38
|
+
"system.agent-debug.read",
|
|
39
|
+
"system.agent-debug.manage",
|
|
40
|
+
"system.agent-session.read",
|
|
41
|
+
"system.agent-session.manage",
|
|
42
|
+
"system.agent-workflow.read",
|
|
43
|
+
];
|
|
44
|
+
const signal = () => new AbortController().signal;
|
|
45
|
+
|
|
46
|
+
test("test Session requires debug.manage AND canonical session.manage and persistent runtime", async () => {
|
|
47
|
+
let created = 0;
|
|
48
|
+
const fixture = await createRunFacadeFixture();
|
|
49
|
+
const ports: AgentWorkbenchDependencies = {
|
|
50
|
+
...base(),
|
|
51
|
+
runs: new AgentWorkbenchRunReader(fixture.facade, fixture.remote),
|
|
52
|
+
sessions: {
|
|
53
|
+
create: async () => {
|
|
54
|
+
created += 1;
|
|
55
|
+
return {
|
|
56
|
+
session: {
|
|
57
|
+
sessionId: "session_test" as const,
|
|
58
|
+
governance: {
|
|
59
|
+
actorId: "actor-1",
|
|
60
|
+
scopeDigest: `sha256:${"a".repeat(64)}` as const,
|
|
61
|
+
},
|
|
62
|
+
status: "active",
|
|
63
|
+
createdAt: "2026-10-07T00:00:00Z",
|
|
64
|
+
updatedAt: "2026-10-07T00:00:00Z",
|
|
65
|
+
headSequence: 1,
|
|
66
|
+
},
|
|
67
|
+
events: [],
|
|
68
|
+
replayed: false,
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
list: async () => ({ items: [] }),
|
|
72
|
+
recording: async () => {
|
|
73
|
+
throw new Error("not used");
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const facade = new AgentWorkbenchFacade(ports);
|
|
78
|
+
await expect(
|
|
79
|
+
facade.createTestSession(guardContext(["system.agent-debug.manage"])),
|
|
80
|
+
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
81
|
+
expect(created).toBe(0);
|
|
82
|
+
expect(await facade.createTestSession(guardContext(permissions))).toEqual({
|
|
83
|
+
sessionId: "session_test",
|
|
84
|
+
});
|
|
85
|
+
expect(created).toBe(1);
|
|
86
|
+
await expect(
|
|
87
|
+
new AgentWorkbenchFacade(base()).createTestSession(
|
|
88
|
+
guardContext(permissions),
|
|
89
|
+
),
|
|
90
|
+
).rejects.toMatchObject({ code: "WORKBENCH_UNAVAILABLE" });
|
|
91
|
+
await expect(
|
|
92
|
+
new AgentWorkbenchFacade({ ...ports, ...base(false) }).createTestSession(
|
|
93
|
+
guardContext(permissions),
|
|
94
|
+
),
|
|
95
|
+
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("business replay re-reads authoritative Provider on every read and never calls mutations or metrics", async () => {
|
|
99
|
+
let gets = 0;
|
|
100
|
+
const fixture = await createRunFacadeFixture();
|
|
101
|
+
const facade = new AgentWorkbenchFacade({
|
|
102
|
+
...base(),
|
|
103
|
+
business: {
|
|
104
|
+
list: async () => ({ schemaVersion: 1, sessions: [], truncated: false }),
|
|
105
|
+
get: async () => {
|
|
106
|
+
gets += 1;
|
|
107
|
+
return {
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
session: {
|
|
110
|
+
id: "opaque",
|
|
111
|
+
operationKey: "business.answer",
|
|
112
|
+
title: "业务会话",
|
|
113
|
+
revision: "2",
|
|
114
|
+
updatedAt: "2026-10-07T01:00:00Z",
|
|
115
|
+
truncated: true,
|
|
116
|
+
messages: [
|
|
117
|
+
{
|
|
118
|
+
sequence: 2,
|
|
119
|
+
role: "assistant",
|
|
120
|
+
content: "来源回答",
|
|
121
|
+
createdAt: "2026-10-07T01:00:00Z",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
runRefs: [
|
|
125
|
+
{
|
|
126
|
+
runId: "missing",
|
|
127
|
+
operationKey: "business.answer",
|
|
128
|
+
messageSequence: 2,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
workflowRefs: [
|
|
132
|
+
{ workflowRunId: "missing-workflow", messageSequence: 2 },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
runs: new AgentWorkbenchRunReader(fixture.facade, fixture.remote),
|
|
139
|
+
workflows: {
|
|
140
|
+
recording: async () => {
|
|
141
|
+
throw new Error("wrong scope");
|
|
142
|
+
},
|
|
143
|
+
recordingsForSession: async () => ({ items: [], truncated: false }),
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const context = guardContext(permissions);
|
|
147
|
+
const first = await facade.recording("business", "opaque", context, signal());
|
|
148
|
+
const second = await facade.recording(
|
|
149
|
+
"business",
|
|
150
|
+
"opaque",
|
|
151
|
+
context,
|
|
152
|
+
signal(),
|
|
153
|
+
);
|
|
154
|
+
expect(gets).toBe(2);
|
|
155
|
+
expect(first.timeline.map(({ sequence }) => sequence)).toEqual([2]);
|
|
156
|
+
expect(first.runs[0]?.missing).toEqual(["RUN_NOT_ACCESSIBLE"]);
|
|
157
|
+
expect(first.missing).toContain("BUSINESS_MESSAGES_PARTIAL");
|
|
158
|
+
expect(first.missing).toContain("WORKFLOW_NOT_ACCESSIBLE");
|
|
159
|
+
expect(first.sideEffectPolicy).toBe("no-reexecution");
|
|
160
|
+
expect(second.correlationStatus).toBe("partial");
|
|
161
|
+
expect(fixture.remote.started).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("missing business correlations are explicit, read permission cannot create a test", async () => {
|
|
165
|
+
const facade = new AgentWorkbenchFacade({
|
|
166
|
+
...base(),
|
|
167
|
+
business: {
|
|
168
|
+
list: async () => ({ schemaVersion: 1, sessions: [], truncated: false }),
|
|
169
|
+
get: async () => ({
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
session: {
|
|
172
|
+
id: "opaque",
|
|
173
|
+
operationKey: "business.answer",
|
|
174
|
+
title: "业务会话",
|
|
175
|
+
revision: "2",
|
|
176
|
+
updatedAt: "2026-10-07T01:00:00Z",
|
|
177
|
+
messages: [],
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
const context = guardContext(["system.agent-debug.read"]);
|
|
183
|
+
expect(
|
|
184
|
+
(await facade.recording("business", "opaque", context, signal())).missing,
|
|
185
|
+
).toEqual(["BUSINESS_PROVIDER_CORRELATION_MISSING"]);
|
|
186
|
+
await expect(facade.createTestSession(context)).rejects.toMatchObject({
|
|
187
|
+
code: "FORBIDDEN",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
2
|
+
import type {
|
|
3
|
+
AgentWorkbenchRecordingSourceV1,
|
|
4
|
+
AgentWorkbenchRecordingSummaryV1,
|
|
5
|
+
AgentWorkbenchRecordingV1,
|
|
6
|
+
AgentWorkbenchWorkflowV1,
|
|
7
|
+
} from "@southwind-ai/shared";
|
|
8
|
+
import { evaluateAccessGuards } from "../access-guards.js";
|
|
9
|
+
import { auditActor, type RequestGuardContext } from "../guards.js";
|
|
10
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
11
|
+
import type { AgentWorkflowQueryService } from "../agent-workflow/query-service.js";
|
|
12
|
+
import type { AgentSessionFacadeV1 } from "./agent-session-facade.js";
|
|
13
|
+
import type { AgentSessionProjectionFacade } from "./session-projection-facade.js";
|
|
14
|
+
import type { AgentWorkbenchRunReader } from "./workbench-run-reader.js";
|
|
15
|
+
|
|
16
|
+
export interface AgentWorkbenchDependencies {
|
|
17
|
+
readonly modules: readonly ModuleManifest[];
|
|
18
|
+
readonly runtime: Pick<
|
|
19
|
+
ReturnType<typeof createServerRuntime>,
|
|
20
|
+
"resolveFeature" | "recordDecisions" | "auditSink"
|
|
21
|
+
>;
|
|
22
|
+
readonly sessions?: Pick<
|
|
23
|
+
AgentSessionProjectionFacade,
|
|
24
|
+
"create" | "list" | "recording"
|
|
25
|
+
>;
|
|
26
|
+
readonly business?: Pick<AgentSessionFacadeV1, "get" | "list">;
|
|
27
|
+
readonly runs?: AgentWorkbenchRunReader;
|
|
28
|
+
readonly workflows?: Pick<
|
|
29
|
+
AgentWorkflowQueryService,
|
|
30
|
+
"recording" | "recordingsForSession"
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class AgentWorkbenchFacade {
|
|
35
|
+
constructor(private readonly ports: AgentWorkbenchDependencies) {}
|
|
36
|
+
|
|
37
|
+
async createTestSession(context: RequestGuardContext) {
|
|
38
|
+
await this.requireCapability("system.agent-debug", "manage", context);
|
|
39
|
+
await this.requireCapability("system.agent-session", "manage", context);
|
|
40
|
+
if (!this.ports.sessions || !this.ports.runs) throw unavailable();
|
|
41
|
+
const created = await this.ports.sessions.create(context);
|
|
42
|
+
this.audit(context, created.session.sessionId, "create", "success");
|
|
43
|
+
return { sessionId: created.session.sessionId };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async list(
|
|
47
|
+
source: AgentWorkbenchRecordingSourceV1,
|
|
48
|
+
context: RequestGuardContext,
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
): Promise<{ items: readonly AgentWorkbenchRecordingSummaryV1[] }> {
|
|
51
|
+
await this.requireCapability("system.agent-debug", "read", context);
|
|
52
|
+
if (source === "business") {
|
|
53
|
+
if (!this.ports.business) return { items: [] };
|
|
54
|
+
const result = await this.ports.business.list(context, signal);
|
|
55
|
+
this.audit(context, source, "list", "success");
|
|
56
|
+
return {
|
|
57
|
+
items: result.sessions.map((session) => ({
|
|
58
|
+
source,
|
|
59
|
+
sessionId: session.id,
|
|
60
|
+
title: session.title,
|
|
61
|
+
updatedAt: session.updatedAt,
|
|
62
|
+
operationKey: session.operationKey,
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
await this.requireCapability("system.agent-session", "read", context);
|
|
67
|
+
if (!this.ports.sessions) throw unavailable();
|
|
68
|
+
const result = await this.ports.sessions.list(context);
|
|
69
|
+
this.audit(context, source, "list", "success");
|
|
70
|
+
return {
|
|
71
|
+
items: result.items.map((session) => ({
|
|
72
|
+
source,
|
|
73
|
+
sessionId: session.sessionId,
|
|
74
|
+
title: `调试会话 ${session.sessionId.slice(-8)}`,
|
|
75
|
+
updatedAt: session.updatedAt,
|
|
76
|
+
sessionStatus: session.status,
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async recording(
|
|
82
|
+
source: AgentWorkbenchRecordingSourceV1,
|
|
83
|
+
sessionId: string,
|
|
84
|
+
context: RequestGuardContext,
|
|
85
|
+
signal: AbortSignal,
|
|
86
|
+
): Promise<AgentWorkbenchRecordingV1> {
|
|
87
|
+
await this.requireCapability("system.agent-debug", "read", context);
|
|
88
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(15_000)]);
|
|
89
|
+
try {
|
|
90
|
+
const recording =
|
|
91
|
+
source === "business"
|
|
92
|
+
? await this.business(sessionId, context, bounded)
|
|
93
|
+
: await this.harness(sessionId, context, bounded);
|
|
94
|
+
this.audit(context, sessionId, "replay", "success");
|
|
95
|
+
return recording;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.audit(context, sessionId, "replay", "failure");
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async harness(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
context: RequestGuardContext,
|
|
105
|
+
signal: AbortSignal,
|
|
106
|
+
): Promise<AgentWorkbenchRecordingV1> {
|
|
107
|
+
await this.requireCapability("system.agent-session", "read", context);
|
|
108
|
+
if (!this.ports.sessions) throw unavailable();
|
|
109
|
+
const history = await this.ports.sessions.recording(sessionId, context);
|
|
110
|
+
const missing = history.truncated ? ["SESSION_EVENTS_PARTIAL"] : [];
|
|
111
|
+
const runs = await this.readRuns(history.runRefs, context, signal);
|
|
112
|
+
const workflows = (
|
|
113
|
+
await Promise.all(
|
|
114
|
+
history.segments.map((segment) =>
|
|
115
|
+
this.sessionWorkflows(segment.sessionId, context, missing),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
).flat();
|
|
119
|
+
if (history.runRefs.length > 200) missing.push("RUN_CORRELATION_PARTIAL");
|
|
120
|
+
if (history.runRefs.length && !runs.length)
|
|
121
|
+
missing.push("RUN_RUNTIME_UNAVAILABLE");
|
|
122
|
+
if (!history.runRefs.length && !workflows.length)
|
|
123
|
+
missing.push("NO_RUN_CORRELATION");
|
|
124
|
+
if (runs.some((run) => run.correlationStatus !== "complete"))
|
|
125
|
+
missing.push("RUN_CORRELATION_PARTIAL");
|
|
126
|
+
return {
|
|
127
|
+
source: "harness",
|
|
128
|
+
sessionId,
|
|
129
|
+
title: `调试会话 ${sessionId.slice(-8)}`,
|
|
130
|
+
updatedAt: history.session.updatedAt,
|
|
131
|
+
sessionStatus: history.session.status,
|
|
132
|
+
mode: "read-only",
|
|
133
|
+
sideEffectPolicy: "no-reexecution",
|
|
134
|
+
messages: [],
|
|
135
|
+
timeline: history.timeline,
|
|
136
|
+
segments: history.segments,
|
|
137
|
+
runs,
|
|
138
|
+
workflows,
|
|
139
|
+
missing,
|
|
140
|
+
correlationStatus: missing.length
|
|
141
|
+
? runs.length || workflows.length
|
|
142
|
+
? "partial"
|
|
143
|
+
: "missing"
|
|
144
|
+
: "complete",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async business(
|
|
149
|
+
sessionId: string,
|
|
150
|
+
context: RequestGuardContext,
|
|
151
|
+
signal: AbortSignal,
|
|
152
|
+
): Promise<AgentWorkbenchRecordingV1> {
|
|
153
|
+
if (!this.ports.business) throw unavailable();
|
|
154
|
+
// Provider get() re-authorizes source data for this actor on every historical read.
|
|
155
|
+
const { session } = await this.ports.business.get(
|
|
156
|
+
sessionId,
|
|
157
|
+
context,
|
|
158
|
+
signal,
|
|
159
|
+
);
|
|
160
|
+
const missing: string[] = [];
|
|
161
|
+
if (session.truncated) missing.push("BUSINESS_MESSAGES_PARTIAL");
|
|
162
|
+
const refs = session.runRefs ?? [];
|
|
163
|
+
const runs = await this.readRuns(refs, context, signal);
|
|
164
|
+
const workflowRefs = [
|
|
165
|
+
...new Map([
|
|
166
|
+
...(session.workflowRefs ?? []).map(
|
|
167
|
+
(ref) => [ref.workflowRunId, ref] as const,
|
|
168
|
+
),
|
|
169
|
+
...refs.flatMap((ref) =>
|
|
170
|
+
ref.workflowRunId
|
|
171
|
+
? [
|
|
172
|
+
[
|
|
173
|
+
ref.workflowRunId,
|
|
174
|
+
{
|
|
175
|
+
workflowRunId: ref.workflowRunId,
|
|
176
|
+
messageSequence: ref.messageSequence,
|
|
177
|
+
},
|
|
178
|
+
] as const,
|
|
179
|
+
]
|
|
180
|
+
: [],
|
|
181
|
+
),
|
|
182
|
+
]).values(),
|
|
183
|
+
];
|
|
184
|
+
const workflows: AgentWorkbenchWorkflowV1[] = [];
|
|
185
|
+
if (workflowRefs.length) {
|
|
186
|
+
if (
|
|
187
|
+
!this.ports.workflows ||
|
|
188
|
+
!(await this.allowed("system.agent-workflow", "read", context))
|
|
189
|
+
)
|
|
190
|
+
missing.push("WORKFLOW_NOT_ACCESSIBLE");
|
|
191
|
+
else
|
|
192
|
+
for (const ref of workflowRefs) {
|
|
193
|
+
try {
|
|
194
|
+
const detail = await this.ports.workflows.recording(
|
|
195
|
+
ref.workflowRunId,
|
|
196
|
+
context,
|
|
197
|
+
);
|
|
198
|
+
workflows.push({
|
|
199
|
+
...detail.recording,
|
|
200
|
+
...(ref.messageSequence !== undefined
|
|
201
|
+
? { messageSequence: ref.messageSequence }
|
|
202
|
+
: {}),
|
|
203
|
+
});
|
|
204
|
+
if (detail.truncated) missing.push("WORKFLOW_EVENTS_PARTIAL");
|
|
205
|
+
} catch {
|
|
206
|
+
missing.push("WORKFLOW_NOT_ACCESSIBLE");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!refs.length && !workflowRefs.length)
|
|
211
|
+
missing.push("BUSINESS_PROVIDER_CORRELATION_MISSING");
|
|
212
|
+
if (refs.length && !runs.length) missing.push("RUN_RUNTIME_UNAVAILABLE");
|
|
213
|
+
if (runs.some((run) => run.correlationStatus !== "complete"))
|
|
214
|
+
missing.push("RUN_CORRELATION_PARTIAL");
|
|
215
|
+
return {
|
|
216
|
+
source: "business",
|
|
217
|
+
sessionId,
|
|
218
|
+
title: session.title,
|
|
219
|
+
operationKey: session.operationKey,
|
|
220
|
+
updatedAt: session.updatedAt,
|
|
221
|
+
mode: "read-only",
|
|
222
|
+
sideEffectPolicy: "no-reexecution",
|
|
223
|
+
messages: session.messages,
|
|
224
|
+
// Message order is the source ledger order; Run/Workflow each retain their own sequence domains.
|
|
225
|
+
timeline: session.messages.map((message) => ({
|
|
226
|
+
sequence: message.sequence,
|
|
227
|
+
type: `message.${message.role}`,
|
|
228
|
+
summary: message.role === "user" ? "用户消息" : "业务回答",
|
|
229
|
+
occurredAt: message.createdAt,
|
|
230
|
+
})),
|
|
231
|
+
runs,
|
|
232
|
+
workflows,
|
|
233
|
+
missing: [...new Set(missing)],
|
|
234
|
+
correlationStatus: missing.length
|
|
235
|
+
? runs.length || workflows.length
|
|
236
|
+
? "partial"
|
|
237
|
+
: "missing"
|
|
238
|
+
: "complete",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async readRuns(
|
|
243
|
+
refs: readonly import("@southwind-ai/server-sdk").ModuleAgentSessionRunReferenceV1[],
|
|
244
|
+
context: RequestGuardContext,
|
|
245
|
+
signal: AbortSignal,
|
|
246
|
+
) {
|
|
247
|
+
if (!this.ports.runs) return [];
|
|
248
|
+
// Bound fanout; sequential reads prevent large business sessions from overwhelming Runtime.
|
|
249
|
+
const runs = [];
|
|
250
|
+
for (const ref of refs.slice(0, 200)) {
|
|
251
|
+
signal.throwIfAborted();
|
|
252
|
+
runs.push(await this.ports.runs.read(ref, context, signal));
|
|
253
|
+
}
|
|
254
|
+
return runs;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async sessionWorkflows(
|
|
258
|
+
sessionId: string,
|
|
259
|
+
context: RequestGuardContext,
|
|
260
|
+
missing: string[],
|
|
261
|
+
) {
|
|
262
|
+
if (!this.ports.workflows) {
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
if (!(await this.allowed("system.agent-workflow", "read", context))) {
|
|
266
|
+
missing.push("WORKFLOW_NOT_ACCESSIBLE");
|
|
267
|
+
return [];
|
|
268
|
+
}
|
|
269
|
+
const records = await this.ports.workflows.recordingsForSession(
|
|
270
|
+
sessionId,
|
|
271
|
+
context,
|
|
272
|
+
);
|
|
273
|
+
if (records.truncated) missing.push("WORKFLOW_DISCOVERY_PARTIAL");
|
|
274
|
+
return records.items;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async allowed(
|
|
278
|
+
featureKey: string,
|
|
279
|
+
action: string,
|
|
280
|
+
context: RequestGuardContext,
|
|
281
|
+
) {
|
|
282
|
+
const feature = this.ports.modules
|
|
283
|
+
.flatMap(({ features }) => features)
|
|
284
|
+
.find(({ key }) => key === featureKey);
|
|
285
|
+
if (!feature) return false;
|
|
286
|
+
return (
|
|
287
|
+
await evaluateAccessGuards(this.ports.runtime, context, {
|
|
288
|
+
feature,
|
|
289
|
+
permission: { permissionKey: `${featureKey}.${action}` },
|
|
290
|
+
})
|
|
291
|
+
).decision.allowed;
|
|
292
|
+
}
|
|
293
|
+
private async requireCapability(
|
|
294
|
+
featureKey: string,
|
|
295
|
+
action: string,
|
|
296
|
+
context: RequestGuardContext,
|
|
297
|
+
) {
|
|
298
|
+
if (!(await this.allowed(featureKey, action, context)))
|
|
299
|
+
throw new AgentWorkbenchError("FORBIDDEN", 403);
|
|
300
|
+
}
|
|
301
|
+
private audit(
|
|
302
|
+
context: RequestGuardContext,
|
|
303
|
+
id: string,
|
|
304
|
+
mode: string,
|
|
305
|
+
outcome: "success" | "failure",
|
|
306
|
+
) {
|
|
307
|
+
this.ports.runtime.auditSink.recordEvent({
|
|
308
|
+
id: crypto.randomUUID(),
|
|
309
|
+
action:
|
|
310
|
+
mode === "create"
|
|
311
|
+
? "agent.debug.session.create"
|
|
312
|
+
: mode === "replay"
|
|
313
|
+
? "agent.debug.recording.replay"
|
|
314
|
+
: "agent.debug.recording.read",
|
|
315
|
+
outcome,
|
|
316
|
+
actor: auditActor(context),
|
|
317
|
+
target: { type: "agent-session", id },
|
|
318
|
+
module: "system.agent",
|
|
319
|
+
requestId: context.requestId,
|
|
320
|
+
metadata: {
|
|
321
|
+
mode,
|
|
322
|
+
sideEffectPolicy: mode === "create" ? "new-session" : "no-reexecution",
|
|
323
|
+
},
|
|
324
|
+
occurredAt: new Date().toISOString(),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export class AgentWorkbenchError extends Error {
|
|
330
|
+
constructor(
|
|
331
|
+
readonly code: "FORBIDDEN" | "WORKBENCH_UNAVAILABLE",
|
|
332
|
+
readonly status: 403 | 503,
|
|
333
|
+
) {
|
|
334
|
+
super(code);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function unavailable() {
|
|
338
|
+
return new AgentWorkbenchError("WORKBENCH_UNAVAILABLE", 503);
|
|
339
|
+
}
|