![[DeepSeek Harness深度拆解-11]深入探讨工具定义、约束和执行防御](http://pic.xiahunao.cn/yaotu/[DeepSeek Harness深度拆解-11]深入探讨工具定义、约束和执行防御)
DeepSeek Harness深度拆解-10:工具如何定义和注册主要以实例的方式演示了如何定义和注册一个表示工具定义的ToolDefinition如何利用注册的ToolRestriction来控制工具针对LLM的可见性以及如何利用注册的ToolGuard作为执行工具前的一道防线决定当前的工具是否真应该执行。在本篇文章我们将深入ToolDefinition、ToolRestriction和ToolGuard分别是个怎样的对象以及defineTool函数是如何创建ToolDefinition的。1. 工具的定义调用ToolRuntime的register方法注册的工具体现为ToolDefinition接口表示的工具定义。该接口是对ToolSchema接口的扩展后者是对工具Schema的描述也是最终LLM需要的那部分内容。exportinterfaceToolSchema{name:stringdescription:stringparameters:Recordstring,unknown}ToolSchema接口提供的三个字段分别表示工具的名称、描述和输入参数的Schema。ToolDefinition在这三者基础上提供了如下字段和方法。exportinterfaceToolDefinitionextendsToolSchema{readonlyoutput:ToolOutputDefinitionexecute(args:unknown,exec:ToolRunContext):PromiseunknownfinalizeContent?(exec:ReadonlyToolExecution,result:ReadonlyToolExecutionResult):ContentBlock[]|undefinedtimeoutMs?:numberisConcurrencySafe?(args:unknown):booleanpresentCall?(args:unknown):ToolCallView|undefinedpresentResult?(args:unknown,result:ToolResult):ToolResultView|undefined}各字段和方法说明如下output 针对工具输出的定义execute用来执行工具的异步方法两个参数分别表示执行工具提供的参数和工具执行上下文finalizeContent对工具执行的结果体现为一个ToolExecutionResult对象作最后处理用来修正最终作为工具结果消息内容的ContentBlock列表timeoutMs设置的工具执行超时时限单位毫秒isConcurrencySafe判断这一次工具调用能否与同一批次里的其他工具调用并行执行presentCallpresentResult 为了将工具执行和执行结果在UI生呈现出来这两个方法分别以ToolCallView和ToolResultView形式返回对应的视图。1.1 ToolOutputDefinitionoutput字段返回的针对工具输出的定义对应如下这个ToolOutputDefinition接口该接口提供的成员解决了在工具执行并返回了一个JSON之后这个值如何被校验如何变成模型内容以及如何被展示。exportinterfaceToolOutputDefinition{readonlyschema:JsonSchemaNoderender(args:unknown,value:JsonValue):ContentBlock[]presentationMeta?(args:unknown,value:JsonValue):JsonValue}三个成员对应上述的三项任务schema输出Schema用来验证工具函数返回的结果是否满足既定的结构render如何将作为工具结果的JSON转换成能够被LLM识别的标准消息内容ContentBlock列表presentationMeta将结果转换成利于呈现的Json。1.2 ToolRunContextToolDefinition利用其execute方法执行工具承载的业务逻辑。args参数代表执行工具的参数一般由LLM提供。exec参数对应的ToolRunContext接口是对工具执行上下文的描述该接口派生于ToolExecution接口后者又是对ToolExecutionInput接口的扩展。顾名思义ToolExecutionInput接口表示工具执行的输入具体的输入是根据LLM返回的assistent消息携带的某个ToolCall生成的。exportinterfaceToolExecutionInput{readonlycallId:ToolCallIdreadonlyrootCallId?:ToolCallIdreadonlyname:stringreadonlyarguments:unknownreadonlyagent?:Agentreadonlyparent?:ToolExecutionTokenreadonlysignal:AbortSignal}exporttypeToolCallIdBrandedToolCallIdexporttypeToolExecutionTokensymbol{readonly[toolExecutionTokenBrand]:true}declareconsttoolExecutionTokenBrand:uniquesymbol描述工具输入的各个成员说明如下callId工具调用的唯一标识rootCallId和parent 工具调用存在类似于调用栈或者调用链的概念比如针对SubAgent的调用或者当前工具以PTCProgrammatic Tool CallingDSH针对CodeAct的实现模式被调用此时rootCallId用来指向最外层的工具调用parent返回的ToolExecutionToken一个Symbol指向父节点name工具名称arguments传入工具函数的参数agent: 当前的Agentsignal利用这个AbortSignal对象得到调用者的中止意图。派生于ToolExecutionInput的ToolExecution接口描述的是注册中心管道里的一次待执行工具调用。它是ToolExecutionInput的成品版经过注册中心加工后所有关键身份和参数都已冻结变成只读。对于ToolExecutionInput来说其rootCallId字段作为输入是可以缺省的但是作为一个描述工具执行的对象来说则是必需的成员。顺便说一些这里的继承好没道理Execution怎么能继承Input呢难道不应该使用组合代替继承吗ToolExecutionInput的parent指向的就是ToolExecution的token。exportinterfaceToolExecutionextendsToolExecutionInput{readonlyrootCallId:ToolCallIdreadonlytoken:ToolExecutionToken}ToolRunContext接口在ToolExecution基础上定了额外两个方法deferContext方法的作用是在对话历史位于当前工具结果消息之后添加一个用户消息角色为user作为上下文concludeTurn方法则直接向Agent Loop发送结束此轮Agent调用的信号。exportinterfaceToolRunContextextendsToolExecution{deferContext(context:UserMessage):voidconcludeTurn():void}2. defineTool函数我们一般会利用如下这个泛型的defineTool函数来根据指定的DefineToolOptionsS, O对象来创建工具注册。泛型参数S和O分别表示针对针对参数和输出的Schema规范是分别对ParameterSchemaSpec和ValueSchemaSpec接口的扩展。exportfunctiondefineToolconstSextendsParameterSchemaSpec,constOextendsValueSchemaSpec(options:DefineToolOptionsS,O,):ToolDefinition2.1 ValueSchemaSpecValueSchemaSpec描述的是单值对象的Schema。由于Schema由对应的类型决定ValueSchemaSpec类型是基于type字段的一组可判别联合类型discriminated union。针对字符串、数字、整数、布尔值、Null、数组、对象和Json对应的类型定义如下OneOfValueSchemaSpec表达的意思是在给出的一组多于两个的ValueSchemaSpec选项中满足其中一项。它们都利用扩展的ValueSchemaAnnotations接口拥有共同的字段description、title、default和examples。exporttypeValueSchemaSpec|StringValueSchemaSpec|NumberValueSchemaSpec|IntegerValueSchemaSpec|BooleanValueSchemaSpec|NullValueSchemaSpec|ArrayValueSchemaSpec|ObjectValueSchemaSpec|JsonValueSchemaSpec|OneOfValueSchemaSpecexportinterfaceStringValueSchemaSpecextendsValueSchemaAnnotations{type:stringenum?:readonlystring[]const?:string}exportinterfaceNumberValueSchemaSpecextendsValueSchemaAnnotations{type:numberenum?:readonlynumber[]const?:number}exportinterfaceIntegerValueSchemaSpecextendsValueSchemaAnnotations{type:integerenum?:readonlynumber[]const?:number}exportinterfaceBooleanValueSchemaSpecextendsValueSchemaAnnotations{type:booleanenum?:readonlyboolean[]const?:boolean}exportinterfaceNullValueSchemaSpecextendsValueSchemaAnnotations{type:nullenum?:readonlynull[]const?:null}exportinterfaceArrayValueSchemaSpecextendsValueSchemaAnnotations{type:arrayitems?:ValueSchemaSpec}exportinterfaceObjectValueSchemaSpecextendsValueSchemaAnnotations{type:objectproperties?:ParameterSchemaSpec additionalProperties:boolean}exportinterfaceJsonValueSchemaSpecextendsValueSchemaAnnotations{type:json}exportinterfaceOneOfValueSchemaSpecextendsValueSchemaAnnotations{oneOf:readonly[ValueSchemaSpec,ValueSchemaSpec,...ValueSchemaSpec[]]}exportinterfaceValueSchemaAnnotations{description?:stringtitle?:stringdefault?:JsonValue examples?:JsonValue}2.2 ParameterPropertySpec表示参数Schema规范本质上是一个将代表参数名称作为Key,代表对应参数Schema规范的ParameterPropertySpec作为Value的字典并利用[key: symbol]: never不允许任何Symbol作为Key。ParameterPropertySpec则是在ValueSchemaSpec解除添加一个表示参数是否必须提供的required字段默认为true。exporttypeParameterSchemaSpec{[key:string]:ParameterPropertySpec[key:symbol]:never}exporttypeParameterPropertySpecValueSchemaSpec{required?:true}2.3 DefineToolOptionsdefineTool函数最终使用符合如下这个DefineToolOptionsS,O接口结构的对象来创建作为工具定义的ToolDefinition所以我们可以看出两个接口的定义基本上是一一对应的。不同之处在于通过通过对派生于ParameterSchemaSpec和ValueSchemaSpec的两个泛型参数的引入让我们在定义参数和返回值的Schema时可能更加明确地根据类型进行对应的Schema设置。exportinterfaceDefineToolOptionsSextendsParameterSchemaSpec,OextendsValueSchemaSpec{readonlyname:stringreadonlydescription:stringreadonlyparameters:Sreadonlyoutput:{readonlyschema:Orender(args:InferArgsS,value:InferValueNoInferO):ContentBlock[]presentationMeta?(args:InferArgsS,value:InferValueNoInferO):JsonValue}readonlytimeoutMs?:numberisConcurrencySafe?(args:InferArgsS):booleanexecute(args:InferArgsS,exec:ToolRunContext):PromiseInferValueNoInferOfinalizeContent?(exec:ReadonlyToolExecution,result:ReadonlyToolExecutionResult):ContentBlock[]|undefinedpresentCall?(args:InferArgsS):ToolCallView|undefinedpresentResult?(args:InferArgsS,result:ToolResult):ToolResultView|undefined}3. 工具约束与执行前的防御DeepSeek Harness深度拆解-10工具如何定义和注册除了演示如何定义并注册工具外还分别演示了通过调用ctx.tools的restrict来注册工具约束以及通过调用guard方法来注册一个工具之前会执行的回调。3.1 ToolRestriction前者注册的工具约束通过如下这个ToolRestriction接口表示。它本质上利用allow和deny两个字段分别提供了基于工具名称的白名单和黑名单。exportinterfaceToolRestriction{readonlyallow?:readonlystring[]readonlydeny?:readonlystring[]}exportclassToolRuntimeextendsService{restrict(filter:ToolRestriction):()void}不论是工具定义还是工具约束和工具守卫都是按照Scope进行注册的Scope在DSH中是非常重要的概念我的文章DeepSeek Harness深度拆解-09:基于Scope的隔离机制是如何实现的具有对此的详细介绍。这样设计使我们可以为专属的Agent注册工具而不是只能注册全局可用的工具出现工具爆炸的问题。注册的ToolRestriction本质上是对工具的过滤或者控制工具的可见性是否需要传递给LLM但是它过滤的对象是全局工具不是Scope自己注册的工具。而且restrict方法不能在没有Scope绑定的Context中执行换言之只能基于某个Scope比如Agent定义过滤规则不能定义一个全局的过滤规则。3.2 ToolGuard通过调用ctx.tools的guard方法注册的是一个ToolGuard类型。如代码所示这是一个将ToolExecution作为输入返回string | undefined的函数。正如其命名所示的那样注册的这个回调函数就是为工具执行设置的门卫它会在工具函数被调用之前对工具的可执行性做一次检查。如果回调函数放行会直接返回undefined否则会返回一个描述拒绝执行的文本作为返回值。exporttypeToolGuard(execution:ReadonlyToolExecution)string|undefinedexportclassToolRuntimeextendsService{guard(guard:ToolGuard):()void}4. 基于Scope的工具注册提供给LLM的候选工具的差异会对LLM做出的推理质量产生很大的影响所以我们不能无脑地将全局注册的工具发给LLM。DSH针对工具的注册采用基于Scope的分层策略原则上我们可用按照我们定义的任意形式的逻辑层次结构来定义Scope比如正对Agent来定义Scope就是一种场景的方式下面的程序针对这种注册方式的演示import{Context}fromdeepseek-ai/cordisimport{createScope}fromdeepseek-ai/dsh-scopeimport{ToolRuntime,defineTool}fromdeepseek-ai/dsh-toolsimport{SystemPrompt}fromdeepseek-ai/dsh-system-promptconstctxnewContext()ctx.plugin(ToolRuntime)ctx.plugin(SystemPrompt)functionregisterTool(ctx:Context,name:string):()void{returnctx.tools.register(defineTool({name:name,description:test tool${name},parameters:{},output:{schema:{type:string},render:(_args,value:string)[{type:text,text:value}],},asyncexecute(args:any,_){return${args}}}))}constfiberctx.inject([tools],(ctx){constagent1ScopeKey{id:agent1},agent2ScopeKey{id:agent2}constagent1createScope(ctx,agent1ScopeKey),agent2createScope(ctx,agent2ScopeKey)registerTool(ctx,foo)registerTool(agent1.ctx,bar)registerTool(agent2.ctx,baz)console.log(ctx.tools.schemas(agent1ScopeKey))console.log(ctx.tools.schemas(agent2ScopeKey))})awaitfiber.await()如代码所示我们直接调用根Context的plugin方法将ToolRuntime服务以插件的方式注册。由于ToolRuntime依赖于SystemPrompt提供的基于构建系统提示词的服务所以我们需要一并注册。接下来在一个通过调用inject方法利用它注入依赖服务tools注册的插件函数中我们模拟针对两个Agentagent1和agent2构建了对应的Scope然后分别调用registerTool函数在根Context和这两个Scope对应的Context上注册了三个工具分别命名为foo、bar和baz。我们最后分别以两个Scope的Key作为参数调用ctx.tools的schemas方法它会返回最终发送给LLM的候选工具Schema。输出的两段JSON如下所示[{name:foo,description:test tool foo,parameters:{type:object,properties:{}}},{name:bar,description:test tool bar,parameters:{type:object,properties:{}}}][{name:foo,description:test tool foo,parameters:{type:object,properties:{}}},{name:baz,description:test tool baz,parameters:{type:object,properties:{}}}]可以看出注册在根Context上的工具foo会被所有Agent共享在针对Agent对应Scope中注册的工具bar和baz则专属于对应的Agent。