@ct-agents/worker 0.0.1
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/package.json +29 -0
- package/src/index.ts +1134 -0
- package/src/resources/database/index.ts +1 -0
- package/src/resources/database/postgres.ts +138 -0
- package/src/resources/index.ts +2 -0
- package/src/resources/platform-proxy.ts +169 -0
- package/src/sandbox/docker.ts +587 -0
- package/src/sandbox/index.ts +5 -0
- package/src/sandbox/local-process.ts +334 -0
- package/src/sandbox/manager.ts +141 -0
- package/src/sandbox/resources-def.ts +59 -0
- package/src/sandbox/resources.ts +30 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1134 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextResolveQueue,
|
|
3
|
+
ContextResolveResult,
|
|
4
|
+
ContextResolveWorkItem,
|
|
5
|
+
Database,
|
|
6
|
+
EnvironmentId,
|
|
7
|
+
EnvironmentExecutionMode,
|
|
8
|
+
EnvironmentHostedPolicy,
|
|
9
|
+
EnvironmentResourceSlotRegistrationDto,
|
|
10
|
+
ExecutionScope,
|
|
11
|
+
HarnessId,
|
|
12
|
+
ResourceSlots,
|
|
13
|
+
SessionEvent,
|
|
14
|
+
SessionId,
|
|
15
|
+
SessionRecord,
|
|
16
|
+
ToolExecution,
|
|
17
|
+
ToolExecutionResult,
|
|
18
|
+
ToolDescriptor,
|
|
19
|
+
ToolHandler,
|
|
20
|
+
ToolHandlerContext,
|
|
21
|
+
ToolRegistry,
|
|
22
|
+
ToolWorkItem,
|
|
23
|
+
WorkQueue,
|
|
24
|
+
WorkStats,
|
|
25
|
+
} from '@ct-agents/protocol';
|
|
26
|
+
import type { MemoryResource, SkillResource } from '@ct-agents/protocol';
|
|
27
|
+
import { createLoadMemoryToolHandler, createUpdateMemoryToolHandler } from '@ct-agents/memory';
|
|
28
|
+
import { createLoadSkillToolHandler } from '@ct-agents/prompts';
|
|
29
|
+
import { createSandboxTools } from '@ct-agents/tools';
|
|
30
|
+
import { createAskUserToolHandler } from '@ct-agents/tools/builtin-tools';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import {
|
|
33
|
+
createPlatformDatabaseProxyResource,
|
|
34
|
+
createPlatformResourceProxy,
|
|
35
|
+
} from './resources/platform-proxy.js';
|
|
36
|
+
|
|
37
|
+
export type ResolveResources = (item: ToolWorkItem) => Promise<ResourceSlots> | ResourceSlots;
|
|
38
|
+
export type ResolvePromptContext = (item: ContextResolveWorkItem) =>
|
|
39
|
+
Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
export type ResourceOptionsParser = {
|
|
42
|
+
parse(value: unknown): Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type EnvironmentWorkerResourceFactoryContext = {
|
|
46
|
+
item: ToolWorkItem;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type EnvironmentWorkerResourceImplementation<
|
|
50
|
+
TResource = unknown,
|
|
51
|
+
TOptions extends Record<string, unknown> = Record<string, unknown>,
|
|
52
|
+
> = {
|
|
53
|
+
title: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
options?: {
|
|
56
|
+
parse(value: unknown): TOptions;
|
|
57
|
+
};
|
|
58
|
+
factory: (
|
|
59
|
+
options: TOptions,
|
|
60
|
+
context: EnvironmentWorkerResourceFactoryContext,
|
|
61
|
+
) => Promise<TResource> | TResource;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type EnvironmentWorkerResourceImplementations = Record<
|
|
65
|
+
string,
|
|
66
|
+
Record<string, EnvironmentWorkerResourceImplementation>
|
|
67
|
+
>;
|
|
68
|
+
|
|
69
|
+
export type EnvironmentWorkerPlatformResourceProxy = {
|
|
70
|
+
baseUrl: string;
|
|
71
|
+
environmentKey: string;
|
|
72
|
+
fetchImpl?: typeof fetch;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
requestTimeoutMs?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type WorkerSandboxManager = {
|
|
78
|
+
sweepIdle(ttlMs: number): Promise<number>;
|
|
79
|
+
disposeAll(): Promise<void>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type EnvironmentWorkerInput = {
|
|
83
|
+
appId: string;
|
|
84
|
+
environmentId: string;
|
|
85
|
+
workQueue: WorkQueue;
|
|
86
|
+
toolRegistry: ToolRegistry;
|
|
87
|
+
resources?: ResourceSlots;
|
|
88
|
+
resolveResources?: ResolveResources;
|
|
89
|
+
resourceImpls?: EnvironmentWorkerResourceImplementations;
|
|
90
|
+
platformResourceProxy?: EnvironmentWorkerPlatformResourceProxy;
|
|
91
|
+
maxBatchSize?: number;
|
|
92
|
+
harness?: {
|
|
93
|
+
id: string;
|
|
94
|
+
version: number;
|
|
95
|
+
metadata?: Record<string, unknown>;
|
|
96
|
+
};
|
|
97
|
+
executionMode?: EnvironmentExecutionMode;
|
|
98
|
+
hostedPolicy?: EnvironmentHostedPolicy;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type DrainOnceOptions = {
|
|
102
|
+
blockMs?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type RunEnvironmentWorkerLoopInput = EnvironmentWorkerInput & {
|
|
106
|
+
abortSignal?: AbortSignal;
|
|
107
|
+
blockMs?: number;
|
|
108
|
+
idleDelayMs?: number;
|
|
109
|
+
errorDelayMs?: number;
|
|
110
|
+
reclaimOlderThanMs?: number;
|
|
111
|
+
reclaimIntervalMs?: number;
|
|
112
|
+
sandboxManager?: WorkerSandboxManager;
|
|
113
|
+
sandboxIdleTtlMs?: number;
|
|
114
|
+
onError?: (error: unknown) => void | Promise<void>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type ContextResolverWorkerInput = {
|
|
118
|
+
appId: string;
|
|
119
|
+
environmentId: string;
|
|
120
|
+
contextResolveQueue: ContextResolveQueue;
|
|
121
|
+
resolvePromptContext: ResolvePromptContext;
|
|
122
|
+
maxBatchSize?: number;
|
|
123
|
+
executionMode?: EnvironmentExecutionMode;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type RunContextResolverWorkerLoopInput = ContextResolverWorkerInput & {
|
|
127
|
+
abortSignal?: AbortSignal;
|
|
128
|
+
idleDelayMs?: number;
|
|
129
|
+
errorDelayMs?: number;
|
|
130
|
+
reclaimOlderThanMs?: number;
|
|
131
|
+
reclaimIntervalMs?: number;
|
|
132
|
+
onError?: (error: unknown) => void | Promise<void>;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const DEFAULT_RECLAIM_OLDER_THAN_MS = 300_000;
|
|
136
|
+
export const DEFAULT_RECLAIM_INTERVAL_MS = 60_000;
|
|
137
|
+
export const DEFAULT_CLAIM_BLOCK_MS = 15_000;
|
|
138
|
+
|
|
139
|
+
const PLATFORM_PROXY_RESOURCE_SLOTS = new Set(['database', 'skills', 'memory']);
|
|
140
|
+
|
|
141
|
+
export function createPlatformBuiltinToolHandlers(): ToolHandler[] {
|
|
142
|
+
return [
|
|
143
|
+
createAskUserToolHandler(),
|
|
144
|
+
createLoadSkillToolHandler(),
|
|
145
|
+
createLoadMemoryToolHandler(),
|
|
146
|
+
createUpdateMemoryToolHandler(),
|
|
147
|
+
...createSandboxTools(),
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function withPlatformBuiltinTools(registry: ToolRegistry): ToolRegistry {
|
|
152
|
+
return new PlatformBuiltinToolRegistry(registry, createPlatformBuiltinToolHandlers());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeNonNegativeInteger(value: number | undefined, fallback: number): number {
|
|
156
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
157
|
+
return fallback;
|
|
158
|
+
}
|
|
159
|
+
return Math.max(0, Math.trunc(value));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class PlatformBuiltinToolRegistry implements ToolRegistry {
|
|
163
|
+
private readonly builtinHandlers = new Map<string, ToolHandler<unknown, unknown>>();
|
|
164
|
+
|
|
165
|
+
public constructor(
|
|
166
|
+
private readonly delegate: ToolRegistry,
|
|
167
|
+
handlers: ToolHandler[],
|
|
168
|
+
) {
|
|
169
|
+
for (const handler of handlers) {
|
|
170
|
+
this.builtinHandlers.set(handler.descriptor.name, handler as ToolHandler<unknown, unknown>);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public getTool(toolName: string): ToolHandler<unknown, unknown> {
|
|
175
|
+
return this.builtinHandlers.get(toolName) ?? this.delegate.getTool(toolName);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public listTools(): ToolDescriptor[] {
|
|
179
|
+
const descriptors = new Map<string, ToolDescriptor>();
|
|
180
|
+
for (const handler of this.builtinHandlers.values()) {
|
|
181
|
+
descriptors.set(handler.descriptor.name, handler.descriptor);
|
|
182
|
+
}
|
|
183
|
+
for (const descriptor of this.delegate.listTools()) {
|
|
184
|
+
if (!descriptors.has(descriptor.name)) {
|
|
185
|
+
descriptors.set(descriptor.name, descriptor);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return Array.from(descriptors.values());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
public addTool(handler: ToolHandler): void {
|
|
192
|
+
this.delegate.addTool?.(handler);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 独立执行面 worker。
|
|
198
|
+
*
|
|
199
|
+
* worker 只通过 WorkQueue 认领当前 app/environment 的业务 tool 工作项,并在本地
|
|
200
|
+
* ToolRegistry/ResourceSlots 中执行 handler。它不持消费端 key,也不直接创建或读取 session。
|
|
201
|
+
*/
|
|
202
|
+
export class EnvironmentWorker {
|
|
203
|
+
private readonly input: EnvironmentWorkerInput;
|
|
204
|
+
|
|
205
|
+
public constructor(input: EnvironmentWorkerInput) {
|
|
206
|
+
this.input = {
|
|
207
|
+
...input,
|
|
208
|
+
toolRegistry: withPlatformBuiltinTools(input.toolRegistry),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
public async drainOnce(options: DrainOnceOptions = {}): Promise<number> {
|
|
213
|
+
const executionMode = this.input.executionMode ?? 'self_hosted';
|
|
214
|
+
const items = await this.input.workQueue.claim({
|
|
215
|
+
appId: this.input.appId,
|
|
216
|
+
environmentId: this.input.environmentId,
|
|
217
|
+
executionMode,
|
|
218
|
+
max: this.input.maxBatchSize ?? 1,
|
|
219
|
+
blockMs: options.blockMs,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await Promise.all(items.map((item) => this.handleItem(item)));
|
|
223
|
+
return items.length;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async handleItem(item: ToolWorkItem): Promise<void> {
|
|
227
|
+
let result: ToolExecutionResult;
|
|
228
|
+
const disposers: Array<() => Promise<void>> = [];
|
|
229
|
+
try {
|
|
230
|
+
const handler = this.input.toolRegistry.getTool(item.toolName);
|
|
231
|
+
const execution = await this.executeHandler(handler, item, disposers);
|
|
232
|
+
result = toToolExecutionResult(item.workId, handler, execution);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
const accepted = await this.input.workQueue.complete({
|
|
235
|
+
workId: item.workId,
|
|
236
|
+
status: 'failed',
|
|
237
|
+
error: toWorkerErrorPayload(error),
|
|
238
|
+
}, this.buildCompleteScope(item));
|
|
239
|
+
if (!accepted) {
|
|
240
|
+
throw new Error(`work complete 未被接受:${item.workId}`);
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
} finally {
|
|
244
|
+
await Promise.all(disposers.map(async (dispose) => {
|
|
245
|
+
await dispose().catch(() => undefined);
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const accepted = await this.input.workQueue.complete(result, this.buildCompleteScope(item));
|
|
250
|
+
if (!accepted) {
|
|
251
|
+
throw new Error(`work complete 未被接受:${item.workId}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async executeHandler(
|
|
256
|
+
handler: ToolHandler<unknown, unknown>,
|
|
257
|
+
item: ToolWorkItem,
|
|
258
|
+
disposers: Array<() => Promise<void>>,
|
|
259
|
+
): Promise<ToolExecution<unknown>> {
|
|
260
|
+
const parsedInput = handler.inputSchema.parse(item.input);
|
|
261
|
+
if ((item.mode ?? 'execute') === 'resume') {
|
|
262
|
+
if (!handler.resume) {
|
|
263
|
+
throw new Error(`工具不支持 resume:${item.toolName}`);
|
|
264
|
+
}
|
|
265
|
+
if (!item.resume) {
|
|
266
|
+
throw new Error(`resume 工作项缺少 action 快照:${item.workId}`);
|
|
267
|
+
}
|
|
268
|
+
return handler.resume({
|
|
269
|
+
originalInput: parsedInput,
|
|
270
|
+
actionEvent: item.resume.actionEvent,
|
|
271
|
+
resolvedEvent: item.resume.resolvedEvent,
|
|
272
|
+
}, await this.buildContext(item, disposers));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return handler.execute(parsedInput, await this.buildContext(item, disposers));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private async buildContext(
|
|
279
|
+
item: ToolWorkItem,
|
|
280
|
+
disposers: Array<() => Promise<void>>,
|
|
281
|
+
): Promise<ToolHandlerContext> {
|
|
282
|
+
const harness = this.input.harness ?? {
|
|
283
|
+
id: 'environment-worker',
|
|
284
|
+
version: 1,
|
|
285
|
+
metadata: {},
|
|
286
|
+
};
|
|
287
|
+
const baseResources = this.input.resolveResources
|
|
288
|
+
? await this.input.resolveResources(item)
|
|
289
|
+
: this.input.resources ?? {};
|
|
290
|
+
const boundResources = await this.instantiateBoundResources(item, disposers);
|
|
291
|
+
const resources = {
|
|
292
|
+
...baseResources,
|
|
293
|
+
...boundResources,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
session: buildSyntheticSession(item, harness.id, harness.version),
|
|
298
|
+
harness: {
|
|
299
|
+
id: harness.id,
|
|
300
|
+
version: harness.version,
|
|
301
|
+
metadata: harness.metadata ?? {},
|
|
302
|
+
},
|
|
303
|
+
executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
|
|
304
|
+
hostedPolicy: this.input.hostedPolicy,
|
|
305
|
+
resources,
|
|
306
|
+
toolCallEvent: buildToolCallEvent(item),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async instantiateBoundResources(
|
|
311
|
+
item: ToolWorkItem,
|
|
312
|
+
disposers: Array<() => Promise<void>>,
|
|
313
|
+
): Promise<ResourceSlots> {
|
|
314
|
+
if (!isRecord(item.resourceBindings)) {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const resources: ResourceSlots = {};
|
|
319
|
+
for (const [slotName, bindingValue] of Object.entries(item.resourceBindings)) {
|
|
320
|
+
const binding = parseResourceBinding(slotName, bindingValue);
|
|
321
|
+
const implementations = this.input.resourceImpls?.[slotName];
|
|
322
|
+
const implementation = implementations?.[binding.implementationId];
|
|
323
|
+
if (!implementation) {
|
|
324
|
+
if (PLATFORM_PROXY_RESOURCE_SLOTS.has(slotName)) {
|
|
325
|
+
resources[slotName] = this.createPlatformResourceProxy(slotName, binding.implementationId, item);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!implementations) {
|
|
329
|
+
throw new Error(`worker 缺少 resource slot factory:${slotName}`);
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`worker 缺少 resource implementation factory:${slotName}.${binding.implementationId}`);
|
|
332
|
+
}
|
|
333
|
+
const parsedOptions = implementation.options
|
|
334
|
+
? implementation.options.parse(binding.options)
|
|
335
|
+
: binding.options;
|
|
336
|
+
const instance = await implementation.factory(parsedOptions, { item });
|
|
337
|
+
resources[slotName] = instance;
|
|
338
|
+
if (hasDispose(instance)) {
|
|
339
|
+
disposers.push(async () => {
|
|
340
|
+
await instance.dispose();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return resources;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private createPlatformResourceProxy(
|
|
348
|
+
slotName: string,
|
|
349
|
+
implementationId: string,
|
|
350
|
+
item: ToolWorkItem,
|
|
351
|
+
): unknown {
|
|
352
|
+
const proxy = this.input.platformResourceProxy;
|
|
353
|
+
if (!proxy) {
|
|
354
|
+
throw new Error(`worker 缺少 platform resource proxy 配置:${slotName}.${implementationId}`);
|
|
355
|
+
}
|
|
356
|
+
const audit = {
|
|
357
|
+
workId: item.workId,
|
|
358
|
+
sessionId: item.sessionId,
|
|
359
|
+
toolCallEventId: item.toolCallEventId,
|
|
360
|
+
};
|
|
361
|
+
const common = {
|
|
362
|
+
baseUrl: proxy.baseUrl,
|
|
363
|
+
environmentId: this.input.environmentId,
|
|
364
|
+
environmentKey: proxy.environmentKey,
|
|
365
|
+
implementationId,
|
|
366
|
+
audit,
|
|
367
|
+
fetchImpl: proxy.fetchImpl,
|
|
368
|
+
signal: proxy.signal,
|
|
369
|
+
requestTimeoutMs: proxy.requestTimeoutMs,
|
|
370
|
+
};
|
|
371
|
+
if (slotName === 'database') {
|
|
372
|
+
return createPlatformDatabaseProxyResource(common) as Database;
|
|
373
|
+
}
|
|
374
|
+
if (slotName === 'skills') {
|
|
375
|
+
return createPlatformResourceProxy<SkillResource>({
|
|
376
|
+
...common,
|
|
377
|
+
slot: slotName,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (slotName === 'memory') {
|
|
381
|
+
return createPlatformResourceProxy<MemoryResource>({
|
|
382
|
+
...common,
|
|
383
|
+
slot: slotName,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
throw new Error(`platform resource proxy 不支持 slot:${slotName}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private buildCompleteScope(item: ToolWorkItem) {
|
|
390
|
+
return {
|
|
391
|
+
appId: this.input.appId,
|
|
392
|
+
environmentId: this.input.environmentId,
|
|
393
|
+
executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 独立 context resolver worker。
|
|
400
|
+
*
|
|
401
|
+
* 它只处理 context.resolve 工作项,把接入方或平台托管 Resource 物化成 prompt snapshot。
|
|
402
|
+
* 该闭环不执行 tool handler,也不会写 tool.called/tool-result 事件。
|
|
403
|
+
*/
|
|
404
|
+
export class ContextResolverWorker {
|
|
405
|
+
public constructor(private readonly input: ContextResolverWorkerInput) {}
|
|
406
|
+
|
|
407
|
+
public async drainOnce(): Promise<number> {
|
|
408
|
+
const executionMode = this.input.executionMode ?? 'self_hosted';
|
|
409
|
+
const items = await this.input.contextResolveQueue.claim({
|
|
410
|
+
appId: this.input.appId,
|
|
411
|
+
environmentId: this.input.environmentId,
|
|
412
|
+
executionMode,
|
|
413
|
+
max: this.input.maxBatchSize ?? 1,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
await Promise.all(items.map((item) => this.handleItem(item)));
|
|
417
|
+
return items.length;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private async handleItem(item: ContextResolveWorkItem): Promise<void> {
|
|
421
|
+
let result: ContextResolveResult;
|
|
422
|
+
try {
|
|
423
|
+
result = {
|
|
424
|
+
workId: item.workId,
|
|
425
|
+
...(item.leaseId ? { leaseId: item.leaseId } : {}),
|
|
426
|
+
status: 'completed',
|
|
427
|
+
snapshot: await this.input.resolvePromptContext(item),
|
|
428
|
+
};
|
|
429
|
+
} catch (error) {
|
|
430
|
+
result = {
|
|
431
|
+
workId: item.workId,
|
|
432
|
+
...(item.leaseId ? { leaseId: item.leaseId } : {}),
|
|
433
|
+
status: 'failed',
|
|
434
|
+
error: {
|
|
435
|
+
message: normalizeContextResolverError(error),
|
|
436
|
+
retryable: false,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const accepted = await this.input.contextResolveQueue.complete(result, this.buildCompleteScope(item));
|
|
442
|
+
if (!accepted) {
|
|
443
|
+
throw new Error(`context work complete 未被接受:${item.workId}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private buildCompleteScope(item: ContextResolveWorkItem) {
|
|
448
|
+
return {
|
|
449
|
+
appId: this.input.appId,
|
|
450
|
+
environmentId: this.input.environmentId,
|
|
451
|
+
executionMode: this.input.executionMode ?? item.executionMode ?? 'self_hosted',
|
|
452
|
+
leaseId: item.leaseId,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function runContextResolverWorkerLoop(input: RunContextResolverWorkerLoopInput): Promise<void> {
|
|
458
|
+
const worker = new ContextResolverWorker(input);
|
|
459
|
+
const idleDelayMs = input.idleDelayMs ?? 250;
|
|
460
|
+
const errorDelayMs = input.errorDelayMs ?? 1000;
|
|
461
|
+
const reclaimOlderThanMs = input.reclaimOlderThanMs ?? DEFAULT_RECLAIM_OLDER_THAN_MS;
|
|
462
|
+
const reclaimIntervalMs = normalizeNonNegativeInteger(input.reclaimIntervalMs, DEFAULT_RECLAIM_INTERVAL_MS);
|
|
463
|
+
let nextReclaimAtMs = 0;
|
|
464
|
+
|
|
465
|
+
while (!input.abortSignal?.aborted) {
|
|
466
|
+
try {
|
|
467
|
+
if (Date.now() >= nextReclaimAtMs) {
|
|
468
|
+
await input.contextResolveQueue.reclaim({
|
|
469
|
+
appId: input.appId,
|
|
470
|
+
environmentId: input.environmentId,
|
|
471
|
+
executionMode: input.executionMode ?? 'self_hosted',
|
|
472
|
+
olderThanMs: reclaimOlderThanMs,
|
|
473
|
+
});
|
|
474
|
+
nextReclaimAtMs = Date.now() + reclaimIntervalMs;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const drained = await worker.drainOnce();
|
|
478
|
+
if (drained === 0) {
|
|
479
|
+
await delay(idleDelayMs, input.abortSignal);
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
await input.onError?.(error);
|
|
483
|
+
await delay(errorDelayMs, input.abortSignal);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function runEnvironmentWorkerLoop(input: RunEnvironmentWorkerLoopInput): Promise<void> {
|
|
489
|
+
const worker = new EnvironmentWorker(input);
|
|
490
|
+
const idleDelayMs = input.idleDelayMs ?? 250;
|
|
491
|
+
const errorDelayMs = input.errorDelayMs ?? 1000;
|
|
492
|
+
const reclaimOlderThanMs = input.reclaimOlderThanMs ?? DEFAULT_RECLAIM_OLDER_THAN_MS;
|
|
493
|
+
const reclaimIntervalMs = normalizeNonNegativeInteger(input.reclaimIntervalMs, DEFAULT_RECLAIM_INTERVAL_MS);
|
|
494
|
+
const blockMs = normalizeNonNegativeInteger(input.blockMs, DEFAULT_CLAIM_BLOCK_MS);
|
|
495
|
+
let nextReclaimAtMs = 0;
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
while (!input.abortSignal?.aborted) {
|
|
499
|
+
try {
|
|
500
|
+
if (Date.now() >= nextReclaimAtMs) {
|
|
501
|
+
await input.workQueue.reclaim({
|
|
502
|
+
appId: input.appId,
|
|
503
|
+
environmentId: input.environmentId,
|
|
504
|
+
executionMode: input.executionMode ?? 'self_hosted',
|
|
505
|
+
olderThanMs: reclaimOlderThanMs,
|
|
506
|
+
});
|
|
507
|
+
nextReclaimAtMs = Date.now() + reclaimIntervalMs;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (input.sandboxManager && input.sandboxIdleTtlMs !== undefined) {
|
|
511
|
+
await input.sandboxManager.sweepIdle(input.sandboxIdleTtlMs);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const drained = await worker.drainOnce({ blockMs });
|
|
515
|
+
if (drained === 0) {
|
|
516
|
+
await delay(idleDelayMs, input.abortSignal);
|
|
517
|
+
}
|
|
518
|
+
} catch (error) {
|
|
519
|
+
await input.onError?.(error);
|
|
520
|
+
await delay(errorDelayMs, input.abortSignal);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
} finally {
|
|
524
|
+
await input.sandboxManager?.disposeAll();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export type HttpWorkQueueClientInput = {
|
|
529
|
+
baseUrl: string;
|
|
530
|
+
environmentId: string;
|
|
531
|
+
environmentKey: string;
|
|
532
|
+
appId?: string;
|
|
533
|
+
fetchImpl?: typeof fetch;
|
|
534
|
+
signal?: AbortSignal;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
export type RegisterEnvironmentCatalogInput = {
|
|
538
|
+
baseUrl: string;
|
|
539
|
+
environmentKey: string;
|
|
540
|
+
tools: ToolDescriptor[];
|
|
541
|
+
resourceSlots?: EnvironmentResourceSlotRegistrationDto[];
|
|
542
|
+
fetchImpl?: typeof fetch;
|
|
543
|
+
signal?: AbortSignal;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export async function registerEnvironmentCatalog(input: RegisterEnvironmentCatalogInput): Promise<{
|
|
547
|
+
registered: number;
|
|
548
|
+
resourceSlots: number;
|
|
549
|
+
}> {
|
|
550
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
551
|
+
const headers = new Headers();
|
|
552
|
+
headers.set('x-api-key', input.environmentKey);
|
|
553
|
+
headers.set('content-type', 'application/json');
|
|
554
|
+
const body = {
|
|
555
|
+
tools: input.tools,
|
|
556
|
+
...(input.resourceSlots ? { resourceSlots: input.resourceSlots } : {}),
|
|
557
|
+
};
|
|
558
|
+
const response = await fetchImpl(`${trimTrailingSlash(input.baseUrl)}/v1/tools/registrations`, {
|
|
559
|
+
method: 'POST',
|
|
560
|
+
headers,
|
|
561
|
+
body: JSON.stringify(body),
|
|
562
|
+
signal: input.signal,
|
|
563
|
+
});
|
|
564
|
+
const data = await parseApiEnvelope(response, '平台工具目录上报失败');
|
|
565
|
+
const record = expectRecord(data, '工具目录上报响应缺少 data');
|
|
566
|
+
return {
|
|
567
|
+
registered: expectNumber(record.registered, 'registered'),
|
|
568
|
+
resourceSlots: expectNumber(record.resourceSlots, 'resourceSlots'),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function zodObjectToJsonSchema(schema: z.ZodType<unknown>): Record<string, unknown> {
|
|
573
|
+
const jsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
|
|
574
|
+
const normalized = normalizeCatalogJsonSchema(jsonSchema, true);
|
|
575
|
+
if (normalized.type !== 'object') {
|
|
576
|
+
throw new Error('resource options schema 必须使用 z.object(...) 作为根 schema');
|
|
577
|
+
}
|
|
578
|
+
return normalized;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function normalizeCatalogJsonSchema(schema: Record<string, unknown>, isRoot = false): Record<string, unknown> {
|
|
582
|
+
if (!isRoot && shouldDowngradeToJsonControl(schema)) {
|
|
583
|
+
return preserveCatalogUiMetadata(schema, { type: 'json' });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const normalized: Record<string, unknown> = { ...schema };
|
|
587
|
+
if (Array.isArray(schema.enum) && schema.enum.every((item) => typeof item === 'string')) {
|
|
588
|
+
normalized.enumValues = [...schema.enum];
|
|
589
|
+
}
|
|
590
|
+
if (isRecord(schema.properties)) {
|
|
591
|
+
normalized.properties = Object.fromEntries(
|
|
592
|
+
Object.entries(schema.properties).map(([key, child]) => [
|
|
593
|
+
key,
|
|
594
|
+
isRecord(child) ? normalizeCatalogJsonSchema(child) : child,
|
|
595
|
+
]),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
return normalized;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function shouldDowngradeToJsonControl(schema: Record<string, unknown>): boolean {
|
|
602
|
+
return schema.type === 'array'
|
|
603
|
+
|| isRecord(schema.propertyNames)
|
|
604
|
+
|| Array.isArray(schema.anyOf)
|
|
605
|
+
|| Array.isArray(schema.oneOf);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function preserveCatalogUiMetadata(
|
|
609
|
+
source: Record<string, unknown>,
|
|
610
|
+
target: Record<string, unknown>,
|
|
611
|
+
): Record<string, unknown> {
|
|
612
|
+
for (const key of ['title', 'description', 'default', 'examples', 'secret', 'multiline']) {
|
|
613
|
+
if (source[key] !== undefined) {
|
|
614
|
+
target[key] = source[key];
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return target;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export class HttpWorkQueueClient implements WorkQueue {
|
|
621
|
+
private readonly baseUrl: string;
|
|
622
|
+
private readonly fetchImpl: typeof fetch;
|
|
623
|
+
|
|
624
|
+
public constructor(private readonly input: HttpWorkQueueClientInput) {
|
|
625
|
+
this.baseUrl = trimTrailingSlash(input.baseUrl);
|
|
626
|
+
this.fetchImpl = input.fetchImpl ?? fetch;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
public async enqueue(): Promise<void> {
|
|
630
|
+
throw new Error('HTTP worker client 不能 enqueue;enqueue 只能由平台 Environment 执行');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
public async claim(input: { appId: string; environmentId: string; blockMs?: number; max?: number; executionMode?: EnvironmentExecutionMode }) {
|
|
634
|
+
this.assertScope(input.appId, input.environmentId);
|
|
635
|
+
const data = await this.requestJson('/work/claim', {
|
|
636
|
+
method: 'POST',
|
|
637
|
+
signal: this.input.signal,
|
|
638
|
+
body: JSON.stringify({
|
|
639
|
+
blockMs: input.blockMs,
|
|
640
|
+
max: input.max,
|
|
641
|
+
executionMode: input.executionMode,
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
const record = expectRecord(data, 'claim 响应缺少 data');
|
|
646
|
+
const items = record.items;
|
|
647
|
+
if (!Array.isArray(items)) {
|
|
648
|
+
throw new Error('claim 响应缺少 items 数组');
|
|
649
|
+
}
|
|
650
|
+
return items.map(parseToolWorkItem);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
public async complete(result: ToolExecutionResult): Promise<boolean> {
|
|
654
|
+
const data = await this.requestJson('/work/complete', {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
body: JSON.stringify(result),
|
|
657
|
+
});
|
|
658
|
+
const record = expectRecord(data, 'complete 响应缺少 data');
|
|
659
|
+
if (record.accepted !== true) {
|
|
660
|
+
throw new Error(`work complete 未被接受:${result.workId}`);
|
|
661
|
+
}
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
public async reclaim(input: { appId: string; environmentId: string; olderThanMs: number; executionMode?: EnvironmentExecutionMode }): Promise<number> {
|
|
666
|
+
this.assertScope(input.appId, input.environmentId);
|
|
667
|
+
const data = await this.requestJson('/work/reclaim', {
|
|
668
|
+
method: 'POST',
|
|
669
|
+
signal: this.input.signal,
|
|
670
|
+
body: JSON.stringify({ olderThanMs: input.olderThanMs, executionMode: input.executionMode }),
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const record = expectRecord(data, 'reclaim 响应缺少 data');
|
|
674
|
+
return typeof record.reclaimed === 'number' ? record.reclaimed : 0;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
public async stats(input: { appId: string; environmentId: string; executionMode?: EnvironmentExecutionMode }): Promise<WorkStats> {
|
|
678
|
+
this.assertScope(input.appId, input.environmentId);
|
|
679
|
+
const query = input.executionMode ? `?executionMode=${encodeURIComponent(input.executionMode)}` : '';
|
|
680
|
+
const data = await this.requestJson(`/work/stats${query}`, { method: 'GET', signal: this.input.signal });
|
|
681
|
+
const record = expectRecord(data, 'stats 响应缺少 data');
|
|
682
|
+
return parseWorkStats(record.stats);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private assertScope(appId: string, environmentId: string): void {
|
|
686
|
+
if (this.input.appId && appId !== this.input.appId) {
|
|
687
|
+
throw new Error(`worker appId 不匹配:${appId}`);
|
|
688
|
+
}
|
|
689
|
+
if (environmentId !== this.input.environmentId) {
|
|
690
|
+
throw new Error(`worker environmentId 不匹配:${environmentId}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private async requestJson(path: string, init: RequestInit): Promise<unknown> {
|
|
695
|
+
const headers = new Headers(init.headers);
|
|
696
|
+
headers.set('x-api-key', this.input.environmentKey);
|
|
697
|
+
if (init.body && !headers.has('content-type')) {
|
|
698
|
+
headers.set('content-type', 'application/json');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const response = await this.fetchImpl(this.buildUrl(path), {
|
|
702
|
+
...init,
|
|
703
|
+
headers,
|
|
704
|
+
});
|
|
705
|
+
return parseApiEnvelope(response, `平台工作队列响应不是 JSON 对象:${response.status}`);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private buildUrl(path: string): string {
|
|
709
|
+
return `${this.baseUrl}/v1/environments/${encodeURIComponent(this.input.environmentId)}${path}`;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export class HttpContextResolveQueueClient implements ContextResolveQueue {
|
|
714
|
+
private readonly baseUrl: string;
|
|
715
|
+
private readonly fetchImpl: typeof fetch;
|
|
716
|
+
|
|
717
|
+
public constructor(private readonly input: HttpWorkQueueClientInput) {
|
|
718
|
+
this.baseUrl = trimTrailingSlash(input.baseUrl);
|
|
719
|
+
this.fetchImpl = input.fetchImpl ?? fetch;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
public async enqueue(): Promise<void> {
|
|
723
|
+
throw new Error('HTTP context resolver client 不能 enqueue;enqueue 只能由平台 Harness 执行');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
public async claim(input: { appId: string; environmentId: string; max?: number }) {
|
|
727
|
+
this.assertScope(input.appId, input.environmentId);
|
|
728
|
+
const data = await this.requestJson('/context-work/claim', {
|
|
729
|
+
method: 'POST',
|
|
730
|
+
signal: this.input.signal,
|
|
731
|
+
body: JSON.stringify({
|
|
732
|
+
max: input.max,
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
const record = expectRecord(data, 'context claim 响应缺少 data');
|
|
737
|
+
const items = record.items;
|
|
738
|
+
if (!Array.isArray(items)) {
|
|
739
|
+
throw new Error('context claim 响应缺少 items 数组');
|
|
740
|
+
}
|
|
741
|
+
return items.map(parseContextResolveWorkItem);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
public async complete(result: ContextResolveResult): Promise<boolean> {
|
|
745
|
+
const data = await this.requestJson('/context-work/complete', {
|
|
746
|
+
method: 'POST',
|
|
747
|
+
body: JSON.stringify(result),
|
|
748
|
+
});
|
|
749
|
+
const record = expectRecord(data, 'context complete 响应缺少 data');
|
|
750
|
+
if (record.accepted !== true) {
|
|
751
|
+
throw new Error(`context complete 未被接受:${result.workId}`);
|
|
752
|
+
}
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
public async reclaim(input: { appId: string; environmentId: string; olderThanMs: number }): Promise<number> {
|
|
757
|
+
this.assertScope(input.appId, input.environmentId);
|
|
758
|
+
const data = await this.requestJson('/context-work/reclaim', {
|
|
759
|
+
method: 'POST',
|
|
760
|
+
signal: this.input.signal,
|
|
761
|
+
body: JSON.stringify({ olderThanMs: input.olderThanMs }),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const record = expectRecord(data, 'context reclaim 响应缺少 data');
|
|
765
|
+
return typeof record.reclaimed === 'number' ? record.reclaimed : 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
public async stats(input: { appId: string; environmentId: string }) {
|
|
769
|
+
this.assertScope(input.appId, input.environmentId);
|
|
770
|
+
const data = await this.requestJson('/context-work/stats', { method: 'GET', signal: this.input.signal });
|
|
771
|
+
const record = expectRecord(data, 'context stats 响应缺少 data');
|
|
772
|
+
return parseContextResolveStats(record.stats);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private assertScope(appId: string, environmentId: string): void {
|
|
776
|
+
if (this.input.appId && appId !== this.input.appId) {
|
|
777
|
+
throw new Error(`context resolver appId 不匹配:${appId}`);
|
|
778
|
+
}
|
|
779
|
+
if (environmentId !== this.input.environmentId) {
|
|
780
|
+
throw new Error(`context resolver environmentId 不匹配:${environmentId}`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
private async requestJson(path: string, init: RequestInit): Promise<unknown> {
|
|
785
|
+
const headers = new Headers(init.headers);
|
|
786
|
+
headers.set('x-api-key', this.input.environmentKey);
|
|
787
|
+
if (init.body && !headers.has('content-type')) {
|
|
788
|
+
headers.set('content-type', 'application/json');
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const response = await this.fetchImpl(this.buildUrl(path), {
|
|
792
|
+
...init,
|
|
793
|
+
headers,
|
|
794
|
+
});
|
|
795
|
+
return parseApiEnvelope(response, `平台 context 工作队列响应不是 JSON 对象:${response.status}`);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private buildUrl(path: string): string {
|
|
799
|
+
return `${this.baseUrl}/v1/environments/${encodeURIComponent(this.input.environmentId)}${path}`;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function parseApiEnvelope(response: Response, fallbackMessage: string): Promise<unknown> {
|
|
804
|
+
const body = await response.json().catch(() => null) as unknown;
|
|
805
|
+
const envelope = expectRecord(body, fallbackMessage);
|
|
806
|
+
if (!response.ok || envelope.success !== true) {
|
|
807
|
+
const error = isRecord(envelope.error) ? envelope.error : {};
|
|
808
|
+
const message = typeof error.message === 'string'
|
|
809
|
+
? error.message
|
|
810
|
+
: `平台请求失败:${response.status}`;
|
|
811
|
+
throw new Error(message);
|
|
812
|
+
}
|
|
813
|
+
return envelope.data;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function toToolExecutionResult(
|
|
817
|
+
workId: string,
|
|
818
|
+
handler: ToolHandler<unknown, unknown>,
|
|
819
|
+
execution: ToolExecution<unknown>,
|
|
820
|
+
): ToolExecutionResult {
|
|
821
|
+
if (execution.status === 'failed') {
|
|
822
|
+
return {
|
|
823
|
+
workId,
|
|
824
|
+
status: 'failed',
|
|
825
|
+
error: execution.error,
|
|
826
|
+
effects: execution.effects,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (execution.status === 'paused') {
|
|
831
|
+
return {
|
|
832
|
+
workId,
|
|
833
|
+
status: 'paused',
|
|
834
|
+
effects: execution.effects,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return {
|
|
839
|
+
workId,
|
|
840
|
+
status: 'completed',
|
|
841
|
+
modelResult: handler.resultSchema.parse(execution.modelResult),
|
|
842
|
+
effects: execution.effects,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function buildSyntheticSession(item: ToolWorkItem, harnessId: string, harnessVersion: number): SessionRecord {
|
|
847
|
+
const now = new Date().toISOString();
|
|
848
|
+
return {
|
|
849
|
+
id: item.sessionId as SessionId,
|
|
850
|
+
appId: item.appId,
|
|
851
|
+
scope: item.scope,
|
|
852
|
+
rootSessionId: item.rootSessionId as SessionId,
|
|
853
|
+
harnessId: harnessId as HarnessId,
|
|
854
|
+
harnessVersion,
|
|
855
|
+
environmentId: item.environmentId as EnvironmentId,
|
|
856
|
+
environmentUpdatedAt: now,
|
|
857
|
+
title: item.sessionId,
|
|
858
|
+
metadata: {},
|
|
859
|
+
createdAt: now,
|
|
860
|
+
updatedAt: now,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function buildToolCallEvent(item: ToolWorkItem): SessionEvent<'tool.called'> {
|
|
865
|
+
return {
|
|
866
|
+
id: item.toolCallEventId,
|
|
867
|
+
seq: 0,
|
|
868
|
+
type: 'tool.called',
|
|
869
|
+
actor: { type: 'assistant', id: 'environment-worker' },
|
|
870
|
+
createdAt: new Date().toISOString(),
|
|
871
|
+
payload: {
|
|
872
|
+
toolName: item.toolName,
|
|
873
|
+
input: item.input,
|
|
874
|
+
},
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function parseResourceBinding(slotName: string, value: unknown): {
|
|
879
|
+
implementationId: string;
|
|
880
|
+
options: Record<string, unknown>;
|
|
881
|
+
} {
|
|
882
|
+
const record = expectRecord(value, `resource binding ${slotName} 必须是对象`);
|
|
883
|
+
const implementationId = expectString(record.implementationId, `${slotName}.implementationId`);
|
|
884
|
+
const options = record.options === undefined ? {} : expectRecord(record.options, `${slotName}.options 必须是对象`);
|
|
885
|
+
return {
|
|
886
|
+
implementationId,
|
|
887
|
+
options,
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function hasDispose(value: unknown): value is { dispose(): Promise<void> | void } {
|
|
892
|
+
return isRecord(value) && typeof value.dispose === 'function';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function parseToolWorkItem(value: unknown): ToolWorkItem {
|
|
896
|
+
const record = expectRecord(value, 'work item 不是对象');
|
|
897
|
+
const workId = expectString(record.workId, 'workId');
|
|
898
|
+
const leaseId = typeof record.leaseId === 'string' && record.leaseId.trim()
|
|
899
|
+
? record.leaseId.trim()
|
|
900
|
+
: undefined;
|
|
901
|
+
const appId = expectString(record.appId, 'appId');
|
|
902
|
+
const environmentId = expectString(record.environmentId, 'environmentId');
|
|
903
|
+
const sessionId = expectString(record.sessionId, 'sessionId');
|
|
904
|
+
const rootSessionId = expectString(record.rootSessionId, 'rootSessionId');
|
|
905
|
+
const scope = parseExecutionScope(record.scope);
|
|
906
|
+
const toolCallEventId = expectString(record.toolCallEventId, 'toolCallEventId');
|
|
907
|
+
const toolName = expectString(record.toolName, 'toolName');
|
|
908
|
+
const mode = parseToolWorkItemMode(record.mode);
|
|
909
|
+
const executionMode = parseExecutionMode(record.executionMode);
|
|
910
|
+
const item: ToolWorkItem = {
|
|
911
|
+
workId,
|
|
912
|
+
...(leaseId ? { leaseId } : {}),
|
|
913
|
+
appId,
|
|
914
|
+
environmentId,
|
|
915
|
+
sessionId,
|
|
916
|
+
rootSessionId,
|
|
917
|
+
scope,
|
|
918
|
+
toolCallEventId,
|
|
919
|
+
toolName,
|
|
920
|
+
...(executionMode ? { executionMode } : {}),
|
|
921
|
+
...(mode ? { mode } : {}),
|
|
922
|
+
input: record.input,
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
if (mode === 'resume') {
|
|
926
|
+
item.resume = parseToolResumeWorkPayload(record.resume);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (isRecord(record.resourceBindings)) {
|
|
930
|
+
item.resourceBindings = record.resourceBindings;
|
|
931
|
+
}
|
|
932
|
+
return item;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function parseToolWorkItemMode(value: unknown): ToolWorkItem['mode'] | undefined {
|
|
936
|
+
if (value === undefined || value === null || value === 'execute') {
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
if (value === 'resume') {
|
|
940
|
+
return 'resume';
|
|
941
|
+
}
|
|
942
|
+
throw new Error('work item mode 必须是 execute 或 resume');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function parseExecutionMode(value: unknown): ToolWorkItem['executionMode'] | undefined {
|
|
946
|
+
if (value === undefined || value === null) {
|
|
947
|
+
return undefined;
|
|
948
|
+
}
|
|
949
|
+
if (value === 'hosted' || value === 'self_hosted') {
|
|
950
|
+
return value;
|
|
951
|
+
}
|
|
952
|
+
throw new Error('work item executionMode 必须是 hosted 或 self_hosted');
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function parseToolResumeWorkPayload(value: unknown): NonNullable<ToolWorkItem['resume']> {
|
|
956
|
+
const record = expectRecord(value, 'resume 工作项缺少 resume payload');
|
|
957
|
+
const actionEvent = parseResumeEvent(record.actionEvent, 'agent.action.requested', 'resume.actionEvent');
|
|
958
|
+
const resolvedEvent = parseResumeEvent(record.resolvedEvent, 'user.action.resolved', 'resume.resolvedEvent');
|
|
959
|
+
return {
|
|
960
|
+
actionEvent,
|
|
961
|
+
resolvedEvent,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function parseResumeEvent<TType extends NonNullable<ToolWorkItem['resume']>['actionEvent']['type'] | NonNullable<ToolWorkItem['resume']>['resolvedEvent']['type']>(
|
|
966
|
+
value: unknown,
|
|
967
|
+
type: TType,
|
|
968
|
+
field: string,
|
|
969
|
+
): Extract<NonNullable<ToolWorkItem['resume']>['actionEvent'] | NonNullable<ToolWorkItem['resume']>['resolvedEvent'], { type: TType }> {
|
|
970
|
+
const event = expectRecord(value, `${field} 必须是对象`);
|
|
971
|
+
if (event.type !== type) {
|
|
972
|
+
throw new Error(`${field} 类型必须是 ${type}`);
|
|
973
|
+
}
|
|
974
|
+
expectString(event.id, `${field}.id`);
|
|
975
|
+
if (typeof event.seq !== 'number' || !Number.isFinite(event.seq)) {
|
|
976
|
+
throw new Error(`${field}.seq 必须是有限数字`);
|
|
977
|
+
}
|
|
978
|
+
expectRecord(event.actor, `${field}.actor 必须是对象`);
|
|
979
|
+
expectString(event.createdAt, `${field}.createdAt`);
|
|
980
|
+
expectRecord(event.payload, `${field}.payload 必须是对象`);
|
|
981
|
+
|
|
982
|
+
if (type === 'agent.action.requested') {
|
|
983
|
+
return event as unknown as Extract<NonNullable<ToolWorkItem['resume']>['actionEvent'], { type: TType }>;
|
|
984
|
+
}
|
|
985
|
+
return event as unknown as Extract<NonNullable<ToolWorkItem['resume']>['resolvedEvent'], { type: TType }>;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function parseExecutionScope(value: unknown): ExecutionScope {
|
|
989
|
+
const record = expectRecord(value, 'scope 必须是对象');
|
|
990
|
+
const entries = Object.entries(record).map(([key, entry]) => {
|
|
991
|
+
if (typeof entry !== 'string') {
|
|
992
|
+
throw new Error(`scope.${key} 必须是字符串`);
|
|
993
|
+
}
|
|
994
|
+
return [key, entry] as const;
|
|
995
|
+
});
|
|
996
|
+
return Object.fromEntries(entries);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function parseContextResolveWorkItem(value: unknown): ContextResolveWorkItem {
|
|
1000
|
+
const record = expectRecord(value, 'context work item 不是对象');
|
|
1001
|
+
const workId = expectString(record.workId, 'workId');
|
|
1002
|
+
const leaseId = typeof record.leaseId === 'string' && record.leaseId.trim()
|
|
1003
|
+
? record.leaseId.trim()
|
|
1004
|
+
: undefined;
|
|
1005
|
+
const appId = expectString(record.appId, 'appId');
|
|
1006
|
+
const environmentId = expectString(record.environmentId, 'environmentId');
|
|
1007
|
+
const sessionId = expectString(record.sessionId, 'sessionId');
|
|
1008
|
+
const rootSessionId = expectString(record.rootSessionId, 'rootSessionId');
|
|
1009
|
+
const request = expectRecord(record.request, 'request 必须是对象');
|
|
1010
|
+
const kind = expectString(request.kind, 'request.kind');
|
|
1011
|
+
if (kind !== 'prompt_context') {
|
|
1012
|
+
throw new Error('request.kind 必须是 prompt_context');
|
|
1013
|
+
}
|
|
1014
|
+
const rawKeys = request.keys;
|
|
1015
|
+
const keys = Array.isArray(rawKeys)
|
|
1016
|
+
? rawKeys
|
|
1017
|
+
.filter((key): key is string => typeof key === 'string')
|
|
1018
|
+
.map((key) => key.trim())
|
|
1019
|
+
.filter(Boolean)
|
|
1020
|
+
: [];
|
|
1021
|
+
if (!Array.isArray(rawKeys) || keys.length === 0 || keys.length !== rawKeys.length) {
|
|
1022
|
+
throw new Error('request.keys 必须是非空字符串数组');
|
|
1023
|
+
}
|
|
1024
|
+
const executionMode = parseExecutionMode(record.executionMode);
|
|
1025
|
+
return {
|
|
1026
|
+
workId,
|
|
1027
|
+
...(leaseId ? { leaseId } : {}),
|
|
1028
|
+
appId,
|
|
1029
|
+
environmentId,
|
|
1030
|
+
sessionId,
|
|
1031
|
+
rootSessionId,
|
|
1032
|
+
scope: parseExecutionScope(record.scope),
|
|
1033
|
+
request: {
|
|
1034
|
+
kind: 'prompt_context',
|
|
1035
|
+
keys,
|
|
1036
|
+
},
|
|
1037
|
+
...(executionMode ? { executionMode } : {}),
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function parseWorkStats(value: unknown): WorkStats {
|
|
1042
|
+
const record = expectRecord(value, 'stats 不是对象');
|
|
1043
|
+
const oldestQueuedAt = record.oldestQueuedAt;
|
|
1044
|
+
return {
|
|
1045
|
+
depth: expectNumber(record.depth, 'depth'),
|
|
1046
|
+
pending: expectNumber(record.pending, 'pending'),
|
|
1047
|
+
oldestQueuedAt: typeof oldestQueuedAt === 'string' ? oldestQueuedAt : null,
|
|
1048
|
+
workersPolling: expectNumber(record.workersPolling, 'workersPolling'),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function parseContextResolveStats(value: unknown) {
|
|
1053
|
+
const record = expectRecord(value, 'context stats 不是对象');
|
|
1054
|
+
const oldestQueuedAt = record.oldestQueuedAt;
|
|
1055
|
+
return {
|
|
1056
|
+
depth: expectNumber(record.depth, 'depth'),
|
|
1057
|
+
pending: expectNumber(record.pending, 'pending'),
|
|
1058
|
+
oldestQueuedAt: typeof oldestQueuedAt === 'string' ? oldestQueuedAt : null,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function normalizeWorkerError(error: unknown): string {
|
|
1063
|
+
if (error instanceof Error) {
|
|
1064
|
+
return `工具执行失败:${error.message}`;
|
|
1065
|
+
}
|
|
1066
|
+
return `工具执行失败:${String(error)}`;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function toWorkerErrorPayload(error: unknown): NonNullable<ToolExecutionResult['error']> {
|
|
1070
|
+
const record = isRecord(error) ? error : {};
|
|
1071
|
+
const code = typeof record.code === 'string' ? record.code : undefined;
|
|
1072
|
+
const status = typeof record.status === 'number' && Number.isSafeInteger(record.status)
|
|
1073
|
+
? record.status
|
|
1074
|
+
: undefined;
|
|
1075
|
+
const retryable = status === undefined
|
|
1076
|
+
? false
|
|
1077
|
+
: status === 408 || status === 429 || status === 504 || status >= 500;
|
|
1078
|
+
return {
|
|
1079
|
+
...(code ? { code } : {}),
|
|
1080
|
+
message: normalizeWorkerError(error),
|
|
1081
|
+
retryable,
|
|
1082
|
+
...(status !== undefined ? { details: { status } } : {}),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function normalizeContextResolverError(error: unknown): string {
|
|
1087
|
+
if (error instanceof Error) {
|
|
1088
|
+
return `context resolve 失败:${error.message}`;
|
|
1089
|
+
}
|
|
1090
|
+
return `context resolve 失败:${String(error)}`;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function trimTrailingSlash(value: string): string {
|
|
1094
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
function expectString(value: unknown, field: string): string {
|
|
1098
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
1099
|
+
throw new Error(`字段 ${field} 必须是非空字符串`);
|
|
1100
|
+
}
|
|
1101
|
+
return value;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function expectNumber(value: unknown, field: string): number {
|
|
1105
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
1106
|
+
throw new Error(`字段 ${field} 必须是有限数字`);
|
|
1107
|
+
}
|
|
1108
|
+
return value;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function expectRecord(value: unknown, message: string): Record<string, unknown> {
|
|
1112
|
+
if (!isRecord(value)) {
|
|
1113
|
+
throw new Error(message);
|
|
1114
|
+
}
|
|
1115
|
+
return value;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1119
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
1123
|
+
if (ms <= 0 || signal?.aborted) {
|
|
1124
|
+
return Promise.resolve();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return new Promise((resolve) => {
|
|
1128
|
+
const timer = setTimeout(resolve, ms);
|
|
1129
|
+
signal?.addEventListener('abort', () => {
|
|
1130
|
+
clearTimeout(timer);
|
|
1131
|
+
resolve();
|
|
1132
|
+
}, { once: true });
|
|
1133
|
+
});
|
|
1134
|
+
}
|