@ct-agents/worker 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ct-agents/worker",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -12,10 +12,10 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "zod": "4.4.3",
15
- "@ct-agents/memory": "0.0.1",
16
- "@ct-agents/prompts": "0.0.1",
17
- "@ct-agents/tools": "0.0.1",
18
- "@ct-agents/protocol": "0.0.1"
15
+ "@ct-agents/memory": "0.0.2",
16
+ "@ct-agents/prompts": "0.0.2",
17
+ "@ct-agents/tools": "0.0.2",
18
+ "@ct-agents/protocol": "0.0.2"
19
19
  },
20
20
  "optionalDependencies": {
21
21
  "dockerode": "4.0.9"
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import type { MemoryResource, SkillResource } from '@ct-agents/protocol';
27
27
  import { createLoadMemoryToolHandler, createUpdateMemoryToolHandler } from '@ct-agents/memory';
28
28
  import { createLoadSkillToolHandler } from '@ct-agents/prompts';
29
29
  import { createSandboxTools } from '@ct-agents/tools';
30
- import { createAskUserToolHandler } from '@ct-agents/tools/builtin-tools';
30
+ import { createAskUserToolHandler, createWebSearchToolHandler } from '@ct-agents/tools/builtin-tools';
31
31
  import { z } from 'zod';
32
32
  import {
33
33
  createPlatformDatabaseProxyResource,
@@ -44,6 +44,7 @@ export type ResourceOptionsParser = {
44
44
 
45
45
  export type EnvironmentWorkerResourceFactoryContext = {
46
46
  item: ToolWorkItem;
47
+ abortSignal?: AbortSignal;
47
48
  };
48
49
 
49
50
  export type EnvironmentWorkerResourceImplementation<
@@ -88,6 +89,7 @@ export type EnvironmentWorkerInput = {
88
89
  resolveResources?: ResolveResources;
89
90
  resourceImpls?: EnvironmentWorkerResourceImplementations;
90
91
  platformResourceProxy?: EnvironmentWorkerPlatformResourceProxy;
92
+ abortSignal?: AbortSignal;
91
93
  maxBatchSize?: number;
92
94
  harness?: {
93
95
  id: string;
@@ -144,6 +146,7 @@ export function createPlatformBuiltinToolHandlers(): ToolHandler[] {
144
146
  createLoadSkillToolHandler(),
145
147
  createLoadMemoryToolHandler(),
146
148
  createUpdateMemoryToolHandler(),
149
+ createWebSearchToolHandler(),
147
150
  ...createSandboxTools(),
148
151
  ];
149
152
  }
@@ -304,6 +307,7 @@ export class EnvironmentWorker {
304
307
  hostedPolicy: this.input.hostedPolicy,
305
308
  resources,
306
309
  toolCallEvent: buildToolCallEvent(item),
310
+ abortSignal: this.input.abortSignal,
307
311
  };
308
312
  }
309
313
 
@@ -333,7 +337,10 @@ export class EnvironmentWorker {
333
337
  const parsedOptions = implementation.options
334
338
  ? implementation.options.parse(binding.options)
335
339
  : binding.options;
336
- const instance = await implementation.factory(parsedOptions, { item });
340
+ const instance = await implementation.factory(parsedOptions, {
341
+ item,
342
+ ...(this.input.abortSignal ? { abortSignal: this.input.abortSignal } : {}),
343
+ });
337
344
  resources[slotName] = instance;
338
345
  if (hasDispose(instance)) {
339
346
  disposers.push(async () => {
@@ -1,2 +1,3 @@
1
1
  export * from './database/index.js';
2
2
  export * from './platform-proxy.js';
3
+ export * from './web-search/index.js';
@@ -0,0 +1 @@
1
+ export * from './tavily.js';
@@ -0,0 +1,408 @@
1
+ import { z } from 'zod';
2
+ import type {
3
+ ResourceDefinition,
4
+ WebSearchErrorCode,
5
+ WebSearchInput,
6
+ WebSearchResource,
7
+ WebSearchResult,
8
+ WebSearchWarning,
9
+ } from '@ct-agents/protocol';
10
+ import type {
11
+ EnvironmentWorkerResourceImplementation,
12
+ } from '../../index.js';
13
+
14
+ const TAVILY_SEARCH_ENDPOINT = 'https://api.tavily.com/search';
15
+ const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
16
+ const MAX_SNIPPET_BYTES = 8 * 1024;
17
+ const MAX_CONTENT_BYTES = 64 * 1024;
18
+ const MAX_TOTAL_TEXT_BYTES = 512 * 1024;
19
+ const MAX_TITLE_BYTES = 8 * 1024;
20
+ const MAX_URL_BYTES = 16 * 1024;
21
+ const MAX_PUBLISHED_AT_BYTES = 1024;
22
+ const MAX_REQUEST_ID_BYTES = 1024;
23
+ const RESULT_BUDGET_RESERVE_BYTES = 4 * 1024;
24
+
25
+ export type TavilyWebSearchOptions = {
26
+ apiKey: string;
27
+ timeoutMs: number;
28
+ };
29
+
30
+ export const tavilyWebSearchOptionsSchema = z.object({
31
+ apiKey: z.string().trim().min(1).meta({ secret: true }),
32
+ timeoutMs: z.number().int().min(1_000).max(120_000).default(30_000),
33
+ }).strict();
34
+
35
+ const tavilyResultSchema = z.object({
36
+ title: z.string(),
37
+ url: z.url(),
38
+ content: z.string().nullable().optional(),
39
+ raw_content: z.string().nullable().optional(),
40
+ score: z.number().optional(),
41
+ published_date: z.string().nullable().optional(),
42
+ favicon: z.string().nullable().optional(),
43
+ }).strict();
44
+
45
+ const tavilyResponseSchema = z.object({
46
+ query: z.string().optional(),
47
+ answer: z.string().nullable().optional(),
48
+ images: z.array(z.unknown()).optional(),
49
+ follow_up_questions: z.array(z.string()).nullable().optional(),
50
+ request_id: z.string().max(MAX_REQUEST_ID_BYTES).optional(),
51
+ response_time: z.number().optional(),
52
+ results: z.array(tavilyResultSchema),
53
+ }).strict();
54
+
55
+ export type TavilyWebSearchDependencies = {
56
+ fetchImpl?: typeof fetch;
57
+ now?: () => number;
58
+ dateNow?: () => Date;
59
+ };
60
+
61
+ function durationSince(startedAt: number, now: () => number): number {
62
+ return Math.max(0, Math.round(now() - startedAt));
63
+ }
64
+
65
+ function failure(
66
+ input: WebSearchInput,
67
+ startedAt: number,
68
+ now: () => number,
69
+ dateNow: () => Date,
70
+ code: WebSearchErrorCode,
71
+ message: string,
72
+ retryable: boolean,
73
+ providerRequestId?: string,
74
+ ): WebSearchResult {
75
+ return {
76
+ success: false,
77
+ query: input.query,
78
+ searchedAt: dateNow().toISOString(),
79
+ durationMs: durationSince(startedAt, now),
80
+ ...(providerRequestId ? { providerRequestId } : {}),
81
+ error: { code, message, retryable },
82
+ };
83
+ }
84
+
85
+ function httpFailure(
86
+ status: number,
87
+ ): { code: WebSearchErrorCode; message: string; retryable: boolean } {
88
+ if (status === 400) return { code: 'WEB_SEARCH_INVALID_REQUEST', message: '搜索请求被供应商拒绝,请检查输入', retryable: false };
89
+ if (status === 401) return { code: 'WEB_SEARCH_AUTH_FAILED', message: '搜索服务认证失败,请检查 Environment 密钥', retryable: false };
90
+ if (status === 429) return { code: 'WEB_SEARCH_RATE_LIMITED', message: '搜索服务请求过于频繁,请稍后重试', retryable: true };
91
+ if (status === 432 || status === 433) return { code: 'WEB_SEARCH_QUOTA_EXCEEDED', message: '搜索服务额度不足,请检查订阅', retryable: false };
92
+ if (status >= 500) return { code: 'WEB_SEARCH_PROVIDER_UNAVAILABLE', message: '搜索服务暂时不可用,请稍后重试', retryable: true };
93
+ return { code: 'WEB_SEARCH_PROVIDER_ERROR', message: '搜索服务返回了非预期响应', retryable: false };
94
+ }
95
+
96
+ function buildRequest(input: WebSearchInput): { body: Record<string, unknown>; warnings: WebSearchWarning[] } {
97
+ const topic = input.topic ?? 'general';
98
+ const warnings: WebSearchWarning[] = [];
99
+ const body: Record<string, unknown> = {
100
+ query: input.query,
101
+ topic,
102
+ search_depth: (input.depth ?? 'standard') === 'deep' ? 'advanced' : 'basic',
103
+ max_results: input.maxResults ?? 5,
104
+ include_raw_content: input.contentFormat && input.contentFormat !== 'none' ? input.contentFormat : false,
105
+ include_answer: false,
106
+ include_images: false,
107
+ include_image_descriptions: false,
108
+ include_favicon: false,
109
+ include_usage: false,
110
+ auto_parameters: false,
111
+ };
112
+ if (input.includeDomains) body.include_domains = input.includeDomains;
113
+ if (input.excludeDomains) body.exclude_domains = input.excludeDomains;
114
+ if (input.timeFilter?.type === 'relative') {
115
+ if (topic === 'finance') {
116
+ warnings.push({ code: 'TIME_FILTER_NOT_APPLIED', message: 'finance 主题不支持相对时间过滤,已忽略该条件' });
117
+ } else {
118
+ body.time_range = input.timeFilter.range;
119
+ }
120
+ } else if (input.timeFilter?.type === 'absolute') {
121
+ if (input.timeFilter.from) body.start_date = input.timeFilter.from;
122
+ if (input.timeFilter.to) body.end_date = input.timeFilter.to;
123
+ }
124
+ if (input.region) {
125
+ if (topic === 'general') {
126
+ body.country = new Intl.DisplayNames(['en'], { type: 'region' }).of(input.region) ?? input.region;
127
+ } else {
128
+ warnings.push({ code: 'REGION_NOT_APPLIED', message: `${topic} 主题不支持地区过滤,已忽略该条件` });
129
+ }
130
+ }
131
+ return { body, warnings };
132
+ }
133
+
134
+ async function readBoundedBody(response: Response): Promise<string> {
135
+ const declaredLength = Number(response.headers.get('content-length'));
136
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
137
+ await cancelResponseBody(response);
138
+ throw new ResponseTooLargeError();
139
+ }
140
+ if (!response.body) return '';
141
+ const reader = response.body.getReader();
142
+ const chunks: Uint8Array[] = [];
143
+ let size = 0;
144
+ try {
145
+ while (true) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ size += value.byteLength;
149
+ if (size > MAX_RESPONSE_BYTES) {
150
+ await reader.cancel().catch(() => undefined);
151
+ throw new ResponseTooLargeError();
152
+ }
153
+ chunks.push(value);
154
+ }
155
+ } finally {
156
+ reader.releaseLock();
157
+ }
158
+ const bytes = new Uint8Array(size);
159
+ let offset = 0;
160
+ for (const chunk of chunks) {
161
+ bytes.set(chunk, offset);
162
+ offset += chunk.byteLength;
163
+ }
164
+ try {
165
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
166
+ } catch {
167
+ throw new InvalidResponseEncodingError();
168
+ }
169
+ }
170
+
171
+ class ResponseTooLargeError extends Error {}
172
+ class InvalidResponseEncodingError extends Error {}
173
+ class ProviderSecurityError extends Error {}
174
+
175
+ async function cancelResponseBody(response: Response): Promise<void> {
176
+ try {
177
+ await response.body?.cancel();
178
+ } catch {
179
+ // 释放连接是 best-effort,不能覆盖原始 HTTP 或响应预算错误。
180
+ }
181
+ }
182
+
183
+ function containsSecret(value: string | null | undefined, apiKey: string): boolean {
184
+ return typeof value === 'string' && value.includes(apiKey);
185
+ }
186
+
187
+ function assertNoSecret(value: string | null | undefined, apiKey: string): void {
188
+ if (containsSecret(value, apiKey)) {
189
+ throw new ProviderSecurityError('供应商响应包含搜索服务密钥');
190
+ }
191
+ }
192
+
193
+ function normalizeProviderRequestId(value: string | null | undefined, apiKey: string): string | undefined {
194
+ if (!value) return undefined;
195
+ assertNoSecret(value, apiKey);
196
+ if (new TextEncoder().encode(value).byteLength > MAX_REQUEST_ID_BYTES) {
197
+ throw new ProviderSecurityError('供应商 request id 超过限制');
198
+ }
199
+ return value;
200
+ }
201
+
202
+ function truncateUtf8(value: string, maxBytes: number): { value: string; truncated: boolean; bytes: number } {
203
+ const encoded = new TextEncoder().encode(value);
204
+ if (encoded.byteLength <= maxBytes) return { value, truncated: false, bytes: encoded.byteLength };
205
+ let end = Math.max(0, maxBytes);
206
+ const decoder = new TextDecoder('utf-8', { fatal: true });
207
+ while (end > 0) {
208
+ try {
209
+ const result = decoder.decode(encoded.subarray(0, end));
210
+ return { value: result, truncated: true, bytes: end };
211
+ } catch {
212
+ end -= 1;
213
+ }
214
+ }
215
+ return { value: '', truncated: true, bytes: 0 };
216
+ }
217
+
218
+ function normalizeSuccess(
219
+ input: WebSearchInput,
220
+ response: z.infer<typeof tavilyResponseSchema>,
221
+ warnings: WebSearchWarning[],
222
+ startedAt: number,
223
+ now: () => number,
224
+ dateNow: () => Date,
225
+ apiKey: string,
226
+ ): WebSearchResult {
227
+ const searchedAt = dateNow().toISOString();
228
+ const encoder = new TextEncoder();
229
+ const fixedBytes = encoder.encode(input.query).byteLength
230
+ + encoder.encode(searchedAt).byteLength
231
+ + encoder.encode('tavily').byteLength
232
+ + RESULT_BUDGET_RESERVE_BYTES;
233
+ const budget = {
234
+ remaining: Math.max(0, MAX_TOTAL_TEXT_BYTES - fixedBytes),
235
+ truncated: false,
236
+ };
237
+ const takeText = (value: string, maxBytes: number) => {
238
+ const result = truncateUtf8(value, Math.min(maxBytes, budget.remaining));
239
+ budget.remaining -= result.bytes;
240
+ budget.truncated ||= result.truncated;
241
+ return result.value;
242
+ };
243
+
244
+ const rawRequestId = normalizeProviderRequestId(response.request_id, apiKey);
245
+ const providerRequestId = rawRequestId ? takeText(rawRequestId, MAX_REQUEST_ID_BYTES) : undefined;
246
+ const maxResults = input.maxResults ?? 5;
247
+ if (response.results.length > maxResults) budget.truncated = true;
248
+ const results = response.results.slice(0, maxResults).flatMap((item) => {
249
+ assertNoSecret(item.title, apiKey);
250
+ assertNoSecret(item.url, apiKey);
251
+ assertNoSecret(item.content, apiKey);
252
+ assertNoSecret(item.raw_content, apiKey);
253
+ assertNoSecret(item.published_date, apiKey);
254
+ const url = new URL(item.url);
255
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('搜索结果 URL 协议无效');
256
+ const source = url.hostname.toLowerCase();
257
+ const urlBytes = encoder.encode(item.url).byteLength;
258
+ const sourceBytes = encoder.encode(source).byteLength;
259
+ if (urlBytes > MAX_URL_BYTES || urlBytes + sourceBytes > budget.remaining) {
260
+ budget.truncated = true;
261
+ return [];
262
+ }
263
+ budget.remaining -= urlBytes + sourceBytes;
264
+ const title = takeText(item.title, MAX_TITLE_BYTES);
265
+ const publishedAt = item.published_date ? takeText(item.published_date, MAX_PUBLISHED_AT_BYTES) : undefined;
266
+ const snippet = takeText(item.content ?? '', MAX_SNIPPET_BYTES);
267
+ const content = input.contentFormat && input.contentFormat !== 'none' && item.raw_content !== null && item.raw_content !== undefined
268
+ ? takeText(item.raw_content, MAX_CONTENT_BYTES)
269
+ : undefined;
270
+ return [{
271
+ title,
272
+ url: item.url,
273
+ source,
274
+ ...(publishedAt ? { publishedAt } : {}),
275
+ snippet,
276
+ ...(content !== undefined ? { content } : {}),
277
+ ...(item.score !== undefined ? { relevanceScore: item.score } : {}),
278
+ }];
279
+ });
280
+ const finalWarnings = [...warnings];
281
+ if (budget.truncated) finalWarnings.push({ code: 'CONTENT_TRUNCATED', message: '搜索结果内容已按平台响应预算裁剪' });
282
+ return {
283
+ success: true,
284
+ query: input.query,
285
+ searchedAt,
286
+ provider: 'tavily',
287
+ ...(providerRequestId ? { providerRequestId } : {}),
288
+ durationMs: durationSince(startedAt, now),
289
+ results,
290
+ ...(finalWarnings.length > 0 ? { warnings: finalWarnings } : {}),
291
+ };
292
+ }
293
+
294
+ export function createTavilyWebSearchResource(
295
+ rawOptions: { apiKey: string; timeoutMs?: number },
296
+ dependencies: TavilyWebSearchDependencies = {},
297
+ ): WebSearchResource {
298
+ const options = tavilyWebSearchOptionsSchema.parse(rawOptions);
299
+ const fetchImpl = dependencies.fetchImpl ?? fetch;
300
+ const now = dependencies.now ?? (() => performance.now());
301
+ const dateNow = dependencies.dateNow ?? (() => new Date());
302
+ return {
303
+ async search(input, context) {
304
+ const startedAt = now();
305
+ const { body, warnings } = buildRequest(input);
306
+ const controller = new AbortController();
307
+ let abortSource: 'timeout' | 'upstream' | undefined;
308
+ const abortRequest = (source: 'timeout' | 'upstream') => {
309
+ if (controller.signal.aborted) return;
310
+ abortSource = source;
311
+ controller.abort();
312
+ };
313
+ const onUpstreamAbort = () => abortRequest('upstream');
314
+ if (context?.abortSignal?.aborted) {
315
+ throw new DOMException('搜索请求已中断', 'AbortError');
316
+ }
317
+ context?.abortSignal?.addEventListener('abort', onUpstreamAbort, { once: true });
318
+ const timer = setTimeout(() => abortRequest('timeout'), options.timeoutMs);
319
+ try {
320
+ const response = await fetchImpl(TAVILY_SEARCH_ENDPOINT, {
321
+ method: 'POST',
322
+ redirect: 'error',
323
+ headers: {
324
+ authorization: `Bearer ${options.apiKey}`,
325
+ 'content-type': 'application/json',
326
+ },
327
+ body: JSON.stringify(body),
328
+ signal: controller.signal,
329
+ });
330
+ let headerRequestId: string | undefined;
331
+ try {
332
+ headerRequestId = normalizeProviderRequestId(response.headers.get('x-request-id'), options.apiKey);
333
+ } catch {
334
+ await cancelResponseBody(response);
335
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_ERROR', '搜索服务响应包含不安全的 request id', false);
336
+ }
337
+ if (!response.ok) {
338
+ await cancelResponseBody(response);
339
+ const mapped = httpFailure(response.status);
340
+ return failure(input, startedAt, now, dateNow, mapped.code, mapped.message, mapped.retryable, headerRequestId);
341
+ }
342
+ const text = await readBoundedBody(response);
343
+ let json: unknown;
344
+ try {
345
+ json = JSON.parse(text) as unknown;
346
+ } catch {
347
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_ERROR', '搜索服务返回了无法解析的响应', false, headerRequestId);
348
+ }
349
+ const parsed = tavilyResponseSchema.safeParse(json);
350
+ if (!parsed.success) {
351
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_ERROR', '搜索服务响应格式不符合预期', false, headerRequestId);
352
+ }
353
+ try {
354
+ return normalizeSuccess(input, parsed.data, warnings, startedAt, now, dateNow, options.apiKey);
355
+ } catch {
356
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_ERROR', '搜索服务响应包含无效结果', false, headerRequestId);
357
+ }
358
+ } catch (error) {
359
+ if (error instanceof ResponseTooLargeError) {
360
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_RESPONSE_TOO_LARGE', '搜索服务响应超过 2 MiB 限制', false);
361
+ }
362
+ if (error instanceof InvalidResponseEncodingError) {
363
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_ERROR', '搜索服务响应不是有效的 UTF-8', false);
364
+ }
365
+ if (abortSource === 'upstream') {
366
+ throw new DOMException('搜索请求已中断', 'AbortError');
367
+ }
368
+ if (abortSource === 'timeout') {
369
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_TIMEOUT', '搜索服务请求超时,请稍后重试', true);
370
+ }
371
+ return failure(input, startedAt, now, dateNow, 'WEB_SEARCH_PROVIDER_UNAVAILABLE', '无法连接搜索服务,请稍后重试', true);
372
+ } finally {
373
+ clearTimeout(timer);
374
+ context?.abortSignal?.removeEventListener('abort', onUpstreamAbort);
375
+ }
376
+ },
377
+ };
378
+ }
379
+
380
+ export function createTavilyWebSearchResourceImplementation(
381
+ dependencies: TavilyWebSearchDependencies = {},
382
+ ): EnvironmentWorkerResourceImplementation<WebSearchResource, TavilyWebSearchOptions> {
383
+ return {
384
+ title: 'Tavily',
385
+ description: '通过 Tavily 官方 Search API 搜索公开互联网内容。',
386
+ options: tavilyWebSearchOptionsSchema,
387
+ factory: (options) => createTavilyWebSearchResource(options, dependencies),
388
+ };
389
+ }
390
+
391
+ export function createTavilyWebSearchResourceDefinition(
392
+ dependencies: TavilyWebSearchDependencies = {},
393
+ ): ResourceDefinition<WebSearchResource, TavilyWebSearchOptions> {
394
+ return {
395
+ id: 'webSearch',
396
+ title: '互联网搜索',
397
+ description: '供应商无关的互联网搜索资源。',
398
+ implementations: [{
399
+ id: 'tavily',
400
+ title: 'Tavily',
401
+ description: '通过 Tavily 官方 Search API 搜索公开互联网内容。',
402
+ supportedExecutionModes: ['hosted', 'self_hosted'],
403
+ optionsSchema: tavilyWebSearchOptionsSchema,
404
+ optionsJsonSchema: z.toJSONSchema(tavilyWebSearchOptionsSchema),
405
+ factory: (options) => createTavilyWebSearchResource(tavilyWebSearchOptionsSchema.parse(options), dependencies),
406
+ }],
407
+ };
408
+ }