diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abad4af..4d05811 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - node-version: ['20', '22'] + node-version: ['22'] steps: - name: Checkout repository diff --git a/README.md b/README.md index 98bbe71..05ba717 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ pnpm add @blade-ai/agent-sdk 已发布包面向 npm 分发;这个仓库本身使用 `pnpm` 进行依赖安装、构建、测试、发布和文档开发。 +> **ESM-only**:本包为纯 ESM(`"type": "module"`),仅通过 `import` 使用,不支持 CommonJS `require()`(否则会报 `ERR_PACKAGE_PATH_NOT_EXPORTED`)。请确保项目为 ESM(package.json 设 `"type": "module"`)或使用支持 ESM 的运行时/打包器。 + ## 快速开始 ```ts diff --git a/docs/tools.md b/docs/tools.md index 88f39f8..606e1cd 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -13,7 +13,7 @@ SDK 提供三种方式创建自定义工具,从简单到完整: 最简单的工具定义方式,原样返回传入的定义。适合直接传给 `SessionOptions.tools`。 ```ts -import { defineTool } from '@blade-ai/agent-sdk'; +import { defineTool, ToolKind } from '@blade-ai/agent-sdk'; const searchTool = defineTool({ name: 'SearchDocs', @@ -26,30 +26,35 @@ const searchTool = defineTool({ }, required: ['query'], }, - kind: 'readonly', + kind: ToolKind.ReadOnly, execute: async (params) => { const results = await searchDocuments(params.query, params.limit ?? 10); return { success: true, llmContent: JSON.stringify(results), - displayContent: `找到 ${results.length} 条结果`, + // 可选:结构化数据(必须是 JSON 值),供调用方消费 + data: { count: results.length }, }; }, }); ``` +::: tip +`kind` 使用 `ToolKind` 枚举(`ToolKind.ReadOnly` / `ToolKind.Write` / `ToolKind.Execute`),从 `@blade-ai/agent-sdk` 导入。TypeScript 下不接受裸字符串字面量。 +::: + ## createTool 使用 Zod Schema 的工厂函数,提供完整的类型推断和运行时验证。 ```ts import { z } from 'zod'; -import { createTool } from '@blade-ai/agent-sdk'; +import { createTool, ToolKind } from '@blade-ai/agent-sdk'; const deployTool = createTool({ name: 'Deploy', displayName: 'Deploy', - kind: 'execute', + kind: ToolKind.Execute, description: { short: '部署应用到指定环境', long: '支持 staging 和 production 环境的自动部署', @@ -64,7 +69,6 @@ const deployTool = createTool({ return { success: true, llmContent: `已部署 v${params.version} 到 ${params.environment}`, - displayContent: `✅ 部署成功: v${params.version} → ${params.environment}`, }; }, }); @@ -194,17 +198,60 @@ interface ToolDescription { ### ToolResult +`ToolResult` 是成功与失败两种结果的判别联合: + ```ts -interface ToolResult { - success: boolean; +type ToolResult = ToolSuccessResult | ToolFailureResult; + +interface ToolSuccessResult { + success: true; + llmContent: string | object; // 返回给 LLM 的内容 + data?: JsonValue; // 可选:结构化数据(必须是 JSON 值) + metadata?: ToolResultMetadata; +} + +interface ToolFailureResult { + success: false; llmContent: string | object; - displayContent: string; - error?: ToolError; - metadata?: Record; + error: ToolError; // 失败时必填 + metadata?: ToolResultMetadata; } ``` -`llmContent` 和 `displayContent` 分离设计让你可以给 LLM 提供结构化数据,同时给用户展示可读的摘要。 +`llmContent` 是给 LLM 消费的内容;如需返回结构化数据用 `data`(其类型为 `JsonValue`,因此自定义对象数组等需保证可 JSON 序列化)。失败时 `success: false` 且必须带 `error`。 + +::: warning data 必须可 JSON 序列化 +`data` 会被序列化落盘(结果产物存储),因此其类型约束为 `JsonValue`。若你的领域类型是 `interface`(无索引签名),赋给 `data` 时可能报「缺少索引签名」。解决办法是让该类型满足 `JsonValue`(字段均为 JSON 值),不要用 `as unknown` 强绕过——那会把「运行时序列化失败」的风险藏起来。 +::: + +### 为工具参数与 data 提供类型 + +`defineTool` 支持两个可选泛型:`TParams`(参数类型)与 `TData`(`data` 字段类型,须 `extends JsonValue`)。指定后 `execute` 的 `params` 与返回的 `data` 都会得到精确类型,无需在 `execute` 内部做 `as` 断言: + +```ts +import { defineTool, ToolKind } from '@blade-ai/agent-sdk'; + +const tool = defineTool<{ query: string; limit?: number }, { count: number }>({ + name: 'SearchDocs', + description: '搜索文档库', + kind: ToolKind.ReadOnly, + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + }, + required: ['query'], + }, + execute: async (params) => { + // params.query: string, params.limit?: number —— 无需 cast + const results = await searchDocuments(params.query, params.limit ?? 10); + return { success: true, llmContent: JSON.stringify(results), data: { count: results.length } }; + }, +}); +``` + +带具体 `TParams` 的工具可以直接放进 `SessionOptions.tools`,无需断言。 ### ExecutionContext diff --git a/package.json b/package.json index d11a962..e9bb860 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Blade AI Agent SDK", "type": "module", "engines": { - "node": ">=20.0.0" + "node": ">=22.14.0" }, "packageManager": "pnpm@10.6.0", "main": "./dist/index.js", diff --git a/scripts/__tests__/node-version-policy.test.ts b/scripts/__tests__/node-version-policy.test.ts new file mode 100644 index 0000000..9c42bc5 --- /dev/null +++ b/scripts/__tests__/node-version-policy.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; + +describe('Node.js version policy', () => { + it('runs CI verification only on the supported Node.js release line', () => { + const workflow = parse( + readFileSync(resolve('.github/workflows/ci.yml'), 'utf8'), + ); + + expect(workflow.jobs.verify.strategy.matrix['node-version']).toEqual(['22']); + }); + + it('advertises the same runtime floor in package metadata', () => { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')); + + expect(packageJson.engines.node).toBe('>=22.14.0'); + }); +}); diff --git a/src/__tests__/deepseek-agent.live.test.ts b/src/__tests__/deepseek-agent.live.test.ts index 23380e6..de54c35 100644 --- a/src/__tests__/deepseek-agent.live.test.ts +++ b/src/__tests__/deepseek-agent.live.test.ts @@ -19,6 +19,7 @@ import { PermissionMode, prompt, type StreamMessage, + ToolErrorType, } from '../index.js'; // ─── 配置 ───────────────────────────────────────────────── @@ -219,7 +220,11 @@ describeDeepSeek('2. Thinking + Tool Use 组合场景', () => { } return { success: false as const, - error: `API Error: endpoint "${params.endpoint}" returned 503 Service Unavailable`, + llmContent: `API Error: endpoint "${params.endpoint}" returned 503 Service Unavailable`, + error: { + type: ToolErrorType.EXECUTION_ERROR, + message: `API Error: endpoint "${params.endpoint}" returned 503 Service Unavailable`, + }, }; }, }); @@ -412,7 +417,8 @@ describeDeepSeek('5. 结构化输出兼容性', () => { // 验证输出是合法 JSON const jsonMatch = res.result.match(/\{[\s\S]*\}/); expect(jsonMatch).not.toBeNull(); - const parsed = JSON.parse(jsonMatch![0]); + if (!jsonMatch) throw new Error('Expected JSON object in response'); + const parsed = JSON.parse(jsonMatch[0]); expect(parsed.languages).toBeDefined(); expect(parsed.languages.length).toBeGreaterThanOrEqual(3); expect(parsed.languages[0].name).toBeDefined(); @@ -509,7 +515,7 @@ describeDeepSeek('7. 并发与边界情况', () => { let aborted = false; try { - for await (const msg of session.stream({ includeThinking: true })) { + for await (const _msg of session.stream({ includeThinking: true })) { chunkCount++; if (chunkCount > 200) break; // 安全阀 } diff --git a/src/index.ts b/src/index.ts index 724ceee..2beab1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,6 +181,8 @@ export { ToolKind } from './tools/types/ToolKind.js'; export { AgentId, MessageId, SessionId, ToolUseId } from './types/branded.js'; // --- Constants & types --- export type { + JsonObject, + JsonValue, McpServerConfig, OutputFormat, SandboxSettings, diff --git a/src/services/__tests__/deepseek-deep.live.test.ts b/src/services/__tests__/deepseek-deep.live.test.ts index 5f0dd8d..24757ed 100644 --- a/src/services/__tests__/deepseek-deep.live.test.ts +++ b/src/services/__tests__/deepseek-deep.live.test.ts @@ -17,6 +17,7 @@ * 13. Schema sanitization(strictTools 模式) * 14. Reasoning 模型(如果可用) */ +import type { JSONSchema7 } from 'json-schema'; import { describe, expect, it } from 'vitest'; import type { Message } from '../ChatServiceInterface.js'; import { createChatServiceAsync } from '../ChatServiceInterface.js'; @@ -60,10 +61,12 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { ]); expect(response.content).toContain('测试成功'); - expect(response.usage).toBeDefined(); - expect(response.usage!.promptTokens).toBeGreaterThan(0); - expect(response.usage!.completionTokens).toBeGreaterThan(0); - expect(response.usage!.totalTokens).toBeGreaterThan(0); + const { usage } = response; + expect(usage).toBeDefined(); + if (!usage) throw new Error('Expected usage to be returned'); + expect(usage.promptTokens).toBeGreaterThan(0); + expect(usage.completionTokens).toBeGreaterThan(0); + expect(usage.totalTokens).toBeGreaterThan(0); }, timeout); it('流式响应完整接收', async () => { @@ -78,7 +81,7 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { let content = ''; let chunkCount = 0; - let finalUsage: typeof undefined | { totalTokens: number } = undefined; + let finalUsage: { totalTokens: number } | undefined; for await (const chunk of service.streamChat([ { role: 'user', content: '从 1 数到 5,用逗号分隔' }, @@ -95,7 +98,8 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { expect(content).toMatch(/1.*2.*3.*4.*5/); expect(chunkCount).toBeGreaterThan(1); // 确认是多 chunk 流式 expect(finalUsage).toBeDefined(); - expect(finalUsage!.totalTokens).toBeGreaterThan(0); + if (!finalUsage) throw new Error('Expected final usage to be returned'); + expect(finalUsage.totalTokens).toBeGreaterThan(0); }, timeout); it('处理中文特殊字符和 emoji', async () => { @@ -114,7 +118,7 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { expect(response.content).toContain('你好'); // 至少包含部分特殊字符 - expect(response.content).toMatch(/[🌍αβ∑∞]/); + expect(response.content).toMatch(/[🌍αβ∑∞]/u); }, timeout); it('多轮对话上下文保持', async () => { @@ -150,7 +154,7 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { required: ['city', 'unit'], additionalProperties: false, }, - }; + } satisfies { name: string; description: string; parameters: JSONSchema7 }; const calculatorTool = { name: 'calculate', @@ -163,7 +167,7 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { required: ['expression'], additionalProperties: false, }, - }; + } satisfies { name: string; description: string; parameters: JSONSchema7 }; it('单工具调用,参数正确解析', async () => { const service = await createChatServiceAsync({ @@ -180,10 +184,12 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { [weatherTool], ); - expect(response.toolCalls).toBeDefined(); - expect(response.toolCalls!.length).toBeGreaterThanOrEqual(1); + const toolCalls = response.toolCalls ?? []; + expect(toolCalls.length).toBeGreaterThanOrEqual(1); - const call = response.toolCalls![0]; + const call = toolCalls[0]; + expect(call).toBeDefined(); + if (!call) throw new Error('Expected a weather tool call'); expect(call.type).toBe('function'); expect(call.function.name).toBe('get_weather'); @@ -207,10 +213,12 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { [weatherTool, calculatorTool], ); - expect(response.toolCalls).toBeDefined(); - expect(response.toolCalls!.length).toBeGreaterThanOrEqual(1); + const toolCalls = response.toolCalls ?? []; + expect(toolCalls.length).toBeGreaterThanOrEqual(1); - const call = response.toolCalls![0]; + const call = toolCalls[0]; + expect(call).toBeDefined(); + if (!call) throw new Error('Expected a calculator tool call'); expect(call.function.name).toBe('calculate'); const args = JSON.parse(call.function.arguments); expect(args.expression).toMatch(/123.*456/); @@ -226,14 +234,14 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { temperature: 0, }); - let toolCalls: Array<{ id?: string; function?: { name?: string; arguments?: string } }> = []; + const toolCalls: Array<{ id?: string; function?: { name?: string; arguments?: string } }> = []; for await (const chunk of service.streamChat( [{ role: 'user', content: '查询上海天气,用华氏度' }], [weatherTool], )) { if (chunk.toolCalls) { for (const tc of chunk.toolCalls) { - toolCalls.push(tc as any); + toolCalls.push(tc); } } } @@ -261,8 +269,11 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { [weatherTool], ); - expect(firstResponse.toolCalls).toBeDefined(); - const toolCall = firstResponse.toolCalls![0]; + const firstToolCalls = firstResponse.toolCalls ?? []; + expect(firstToolCalls.length).toBeGreaterThanOrEqual(1); + const toolCall = firstToolCalls[0]; + expect(toolCall).toBeDefined(); + if (!toolCall) throw new Error('Expected a tool call before returning tool result'); // 回传 tool result 让模型生成最终回答 const messages: Message[] = [ @@ -305,7 +316,9 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { { role: 'user', content: 'say hello' }, ]); - const usage = response.usage!; + const { usage } = response; + expect(usage).toBeDefined(); + if (!usage) throw new Error('Expected usage to be returned'); expect(usage.promptTokens).toBeGreaterThan(0); expect(usage.completionTokens).toBeGreaterThan(0); expect(usage.totalTokens).toBe(usage.promptTokens + usage.completionTokens); @@ -381,7 +394,7 @@ describeLive('DeepSeek V4 Pro 深度集成测试', () => { let aborted = false; try { - for await (const chunk of service.streamChat( + for await (const _chunk of service.streamChat( [{ role: 'user', content: '从 1 数到 10000' }], undefined, controller.signal, @@ -639,7 +652,8 @@ describe('DeepSeek 离线逻辑测试', () => { }); expect(schema.properties).toBeDefined(); - const nameSchema = (schema.properties as any).name; + const properties = schema.properties as Record>; + const nameSchema = properties.name; expect(nameSchema.minLength).toBeUndefined(); expect(nameSchema.maxLength).toBeUndefined(); }); @@ -666,7 +680,7 @@ describe('DeepSeek 离线逻辑测试', () => { }, }); - const props = schema.properties as any; + const props = schema.properties as Record>; expect(props.date.format).toBeUndefined(); expect(props.email.format).toBe('email'); }); @@ -684,7 +698,7 @@ describe('DeepSeek 离线逻辑测试', () => { }, }); - const valueSchema = (schema.properties as any).value; + const valueSchema = (schema.properties as Record>).value; expect(valueSchema.anyOf).toBeDefined(); expect(valueSchema.oneOf).toBeUndefined(); }); @@ -702,7 +716,8 @@ describe('DeepSeek 离线逻辑测试', () => { }, }); - const nestedField = (schema.properties as any).nested.properties.field; + const nested = (schema.properties as Record> }>).nested; + const nestedField = nested.properties.field; expect(nestedField.minLength).toBeUndefined(); }); }); @@ -750,11 +765,12 @@ describe('DeepSeek 离线逻辑测试', () => { }, 'deepseek-v4-pro'); expect(cost).toBeDefined(); - expect(cost!.inputCacheHitTokens).toBe(200); - expect(cost!.inputCacheMissTokens).toBe(800); - expect(cost!.outputTokens).toBe(500); - expect(cost!.totalCost).toBeGreaterThan(0); - expect(cost!.currency).toBe('USD'); + if (!cost) throw new Error('Expected known DeepSeek model cost'); + expect(cost.inputCacheHitTokens).toBe(200); + expect(cost.inputCacheMissTokens).toBe(800); + expect(cost.outputTokens).toBe(500); + expect(cost.totalCost).toBeGreaterThan(0); + expect(cost.currency).toBe('USD'); }); it('未知模型返回 undefined', () => { @@ -792,6 +808,8 @@ describe('DeepSeek 离线逻辑测试', () => { describe('withDeepSeekDefaults', () => { it('填充默认值', () => { const config = withDeepSeekDefaults({ + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', provider: 'deepseek', model: 'deepseek-v4-pro', apiKey: 'test', @@ -807,6 +825,8 @@ describe('DeepSeek 离线逻辑测试', () => { it('非 deepseek provider 不修改', () => { const original = { + id: 'gpt-4', + name: 'GPT-4', provider: 'openai' as const, model: 'gpt-4', apiKey: 'test', @@ -818,6 +838,8 @@ describe('DeepSeek 离线逻辑测试', () => { it('reasoner 别名启用 thinking', () => { const config = withDeepSeekDefaults({ + id: 'deepseek-reasoner', + name: 'DeepSeek Reasoner', provider: 'deepseek', model: 'deepseek-reasoner', apiKey: 'test', @@ -845,10 +867,15 @@ describe('DeepSeek 离线逻辑测试', () => { { strictTools: true }, ); - expect(tools).toBeDefined(); - expect(tools![0].strict).toBe(true); + const preparedTools = tools ?? []; + expect(preparedTools.length).toBe(1); + const tool = preparedTools[0]; + expect(tool).toBeDefined(); + if (!tool) throw new Error('Expected prepared strict tool'); + expect(tool.strict).toBe(true); // minLength 应被移除 - expect((tools![0].parameters.properties as any)?.x?.minLength).toBeUndefined(); + const properties = tool.parameters.properties as Record>; + expect(properties.x?.minLength).toBeUndefined(); }); it('非 strict 模式不修改 schema', () => { @@ -866,9 +893,14 @@ describe('DeepSeek 离线逻辑测试', () => { { strictTools: false }, ); - expect(tools).toBeDefined(); - expect(tools![0].strict).toBeUndefined(); - expect((tools![0].parameters.properties as any)?.x?.minLength).toBe(1); + const preparedTools = tools ?? []; + expect(preparedTools.length).toBe(1); + const tool = preparedTools[0]; + expect(tool).toBeDefined(); + if (!tool) throw new Error('Expected prepared non-strict tool'); + expect(tool.strict).toBeUndefined(); + const properties = tool.parameters.properties as Record>; + expect(properties.x?.minLength).toBe(1); }); it('空工具数组返回 undefined', () => { diff --git a/src/session/__tests__/SessionModelConfig.test.ts b/src/session/__tests__/SessionModelConfig.test.ts index 6f84386..08862b6 100644 --- a/src/session/__tests__/SessionModelConfig.test.ts +++ b/src/session/__tests__/SessionModelConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -const createAgent = vi.fn(async () => ({ +const createAgent = vi.fn(async (_config: unknown, _options?: unknown) => ({ async setModel() {}, })); diff --git a/src/session/types.ts b/src/session/types.ts index 2a9d970..8bb2748 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -175,7 +175,10 @@ export interface SessionOptions { disallowedTools?: string[]; toolSourcePolicy?: ToolCatalogSourcePolicy; mcpServers?: Record; - tools?: ToolDefinition[]; + // 使用 ToolDefinition 以容纳不同 TParams 的自定义工具:execute 的参数位是逆变的, + // 若写成 ToolDefinition[],则 defineTool<{...}>() 得到的强类型工具无法赋值进来, + // 迫使调用方在 execute 内部做 cast。never 只用于数组元素的参数位,不泄漏到调用方的 execute。 + tools?: ToolDefinition[]; permissionMode?: PermissionMode; permissionHandler?: PermissionHandler; diff --git a/src/tools/core/createTool.ts b/src/tools/core/createTool.ts index f7c7a37..b239d44 100644 --- a/src/tools/core/createTool.ts +++ b/src/tools/core/createTool.ts @@ -1,5 +1,5 @@ import type { z } from 'zod'; -import type { JsonObject } from '../../types/common.js'; +import type { JsonObject, JsonValue } from '../../types/common.js'; import type { ExecutionContext, Tool, @@ -355,8 +355,8 @@ function isPathLikeKey(key: string): boolean { * }); * ``` */ -export function defineTool( - definition: ToolDefinition -): ToolDefinition { +export function defineTool( + definition: ToolDefinition +): ToolDefinition { return definition; } diff --git a/src/tools/types/ToolDefinition.ts b/src/tools/types/ToolDefinition.ts index d7e867c..abb0c39 100644 --- a/src/tools/types/ToolDefinition.ts +++ b/src/tools/types/ToolDefinition.ts @@ -1,6 +1,6 @@ import type { JSONSchema7 } from 'json-schema'; import type { z } from 'zod'; -import type { JsonObject } from '../../types/common.js'; +import type { JsonObject, JsonValue } from '../../types/common.js'; import type { PermissionResult } from '../../types/permissions.js'; import type { ExecutionContext } from './ExecutionTypes.js'; import type { ToolBehavior, ToolKind } from './ToolKind.js'; @@ -54,7 +54,7 @@ export interface PreparedPermissionMatcher { abstractRule?: string; } -export interface ToolDefinition { +export interface ToolDefinition { name: string; aliases?: string[]; displayName?: string; @@ -64,7 +64,7 @@ export interface ToolDefinition { category?: string; tags?: string[]; exposure?: ToolExposureConfig; - execute: (params: TParams, context: ExecutionContext) => Promise; + execute: (params: TParams, context: ExecutionContext) => Promise>; } export interface ToolConfig {