@loopstack/custom-tool-example-module
Advanced tools
+2
-2
@@ -12,3 +12,3 @@ { | ||
| ], | ||
| "version": "0.21.0", | ||
| "version": "0.21.1", | ||
| "license": "Apache-2.0", | ||
@@ -35,3 +35,3 @@ "author": { | ||
| "@loopstack/common": "^0.25.0", | ||
| "@loopstack/create-chat-message-tool": "^0.21.0", | ||
| "@loopstack/create-chat-message-tool": "^0.21.1", | ||
| "@nestjs/common": "^11.1.14", | ||
@@ -38,0 +38,0 @@ "zod": "^4.3.6" |
+175
-117
@@ -13,8 +13,9 @@ # @loopstack/custom-tool-example-module | ||
| - How to create tools that perform specific tasks within workflows | ||
| - How to create tools that extend `BaseTool` with a `call()` method | ||
| - The difference between stateless and stateful tools | ||
| - How to use dependency injection to keep tools modular and testable | ||
| - How to wire tools into workflows using YAML configuration | ||
| - How to define workflow input, state, and output | ||
| - How to structure and export a reusable module | ||
| - How to define input schemas with Zod on the `@Tool` decorator | ||
| - How to use NestJS dependency injection in tools | ||
| - How to wire tools into workflows using `@InjectTool()` | ||
| - How to use `wait: true` on transitions for manual triggers | ||
| - How to return output from a workflow | ||
@@ -33,18 +34,18 @@ This is a great starting point before building your own custom tools. | ||
| A simple tool that maintains internal state across calls using the `@Tool` decorator and `ToolInterface`: | ||
| A simple tool that maintains internal state across calls. It extends `BaseTool` and implements a `call()` method: | ||
| ```typescript | ||
| import { BaseTool, Tool, ToolResult } from '@loopstack/common'; | ||
| @Tool({ | ||
| config: { | ||
| uiConfig: { | ||
| description: 'Counter tool.', | ||
| }, | ||
| }) | ||
| export class CounterTool implements ToolInterface { | ||
| export class CounterTool extends BaseTool { | ||
| count: number = 0; | ||
| async execute(): Promise<ToolResult> { | ||
| call(_args?: object): Promise<ToolResult<number>> { | ||
| this.count++; | ||
| return Promise.resolve({ | ||
| data: this.count, | ||
| }); | ||
| return Promise.resolve({ data: this.count }); | ||
| } | ||
@@ -54,31 +55,36 @@ } | ||
| The `count` property persists across calls within the same workflow execution, so each call increments the counter. | ||
| #### 2. Tool with Input Schema and Dependency Injection | ||
| A tool that accepts typed arguments via `@Input` and uses NestJS dependency injection for services: | ||
| A tool that accepts typed arguments via a Zod schema and uses NestJS dependency injection for services: | ||
| ```typescript | ||
| import { Inject } from '@nestjs/common'; | ||
| import { z } from 'zod'; | ||
| import { BaseTool, Tool, ToolResult } from '@loopstack/common'; | ||
| import { MathService } from '../services/math.service'; | ||
| const MathSumSchema = z | ||
| .object({ | ||
| a: z.number(), | ||
| b: z.number(), | ||
| }) | ||
| .strict(); | ||
| type MathSumArgs = z.infer<typeof MathSumSchema>; | ||
| @Tool({ | ||
| config: { | ||
| uiConfig: { | ||
| description: 'Math tool calculating the sum of two arguments by using an injected service.', | ||
| }, | ||
| schema: MathSumSchema, | ||
| }) | ||
| export class MathSumTool implements ToolInterface { | ||
| export class MathSumTool extends BaseTool { | ||
| @Inject() | ||
| private mathService: MathService; | ||
| @Input({ | ||
| schema: z | ||
| .object({ | ||
| a: z.number(), | ||
| b: z.number(), | ||
| }) | ||
| .strict(), | ||
| }) | ||
| args: MathSumArgs; | ||
| async execute(args: MathSumArgs): Promise<ToolResult<number>> { | ||
| call(args: MathSumArgs): Promise<ToolResult<number>> { | ||
| const sum = this.mathService.sum(args.a, args.b); | ||
| return Promise.resolve({ | ||
| data: sum, | ||
| }); | ||
| return Promise.resolve({ data: sum }); | ||
| } | ||
@@ -88,3 +94,3 @@ } | ||
| The injected `MathService` is a standard NestJS injectable: | ||
| The `schema` option on `@Tool` validates incoming arguments. The injected `MathService` is a standard NestJS injectable: | ||
@@ -102,3 +108,3 @@ ```typescript | ||
| The workflow class declares input, state, output, tools, and helpers: | ||
| The workflow extends `BaseWorkflow<TArgs>` with a typed argument object. The schema is defined in the `@Workflow` decorator: | ||
@@ -108,4 +114,10 @@ ```typescript | ||
| uiConfig: __dirname + '/custom-tool-example.ui.yaml', | ||
| schema: z | ||
| .object({ | ||
| a: z.number().default(1), | ||
| b: z.number().default(2), | ||
| }) | ||
| .strict(), | ||
| }) | ||
| export class CustomToolExampleWorkflow { | ||
| export class CustomToolExampleWorkflow extends BaseWorkflow<{ a: number; b: number }> { | ||
| @InjectTool() private counterTool: CounterTool; | ||
@@ -115,112 +127,158 @@ @InjectTool() private createChatMessage: CreateChatMessage; | ||
| @Input({ | ||
| schema: z | ||
| .object({ | ||
| a: z.number().default(1), | ||
| b: z.number().default(2), | ||
| }) | ||
| .strict(), | ||
| }) | ||
| args: { a: number; b: number }; | ||
| total?: number; | ||
| } | ||
| ``` | ||
| @State({ | ||
| schema: z | ||
| .object({ | ||
| total: z.number().optional(), | ||
| count1: z.number().optional(), | ||
| count2: z.number().optional(), | ||
| count3: z.number().optional(), | ||
| }) | ||
| .strict(), | ||
| }) | ||
| state: { total?: number; count1?: number; count2?: number; count3?: number }; | ||
| ### Key Concepts | ||
| @Output() | ||
| result() { | ||
| return { total: this.state.total }; | ||
| } | ||
| #### 1. Calling Custom Tools | ||
| @DefineHelper() | ||
| sum(a: number, b: number) { | ||
| return a + b; | ||
| } | ||
| Call tools via `this.tool.call(args)` inside transition methods. Store the result as an instance property: | ||
| ```typescript | ||
| @Initial({ to: 'waiting_for_user' }) | ||
| async calculate(args: { a: number; b: number }) { | ||
| const calcResult = await this.mathTool.call({ a: args.a, b: args.b }); | ||
| this.total = calcResult.data as number; | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Tool calculation result:\n${args.a} + ${args.b} = ${this.total}`, | ||
| }); | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Alternatively, using workflow method:\n${args.a} + ${args.b} = ${this.sum(args.a, args.b)}`, | ||
| }); | ||
| } | ||
| ``` | ||
| ### Workflow YAML | ||
| #### 2. Stateful Tool Behavior | ||
| #### Using Custom Tools | ||
| The counter tool increments on each call, demonstrating that tool state persists within a workflow execution: | ||
| Call custom tools and save their results to state using `assign`: | ||
| ```typescript | ||
| const c1 = await this.counterTool.call({}); | ||
| const c2 = await this.counterTool.call({}); | ||
| const c3 = await this.counterTool.call({}); | ||
| ```yaml | ||
| - id: calculation | ||
| tool: mathTool | ||
| args: | ||
| a: ${{ args.a }} | ||
| b: ${{ args.b }} | ||
| assign: | ||
| total: ${{ result.data }} | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Counter before pause: ${c1.data}, ${c2.data}, ${c3.data}\n\nPress Next to continue...`, | ||
| }); | ||
| ``` | ||
| #### Accessing State and Arguments | ||
| #### 3. Wait Transitions | ||
| Reference workflow arguments with `args.<name>` and state with `state.<name>`: | ||
| Use `wait: true` on a transition to pause the workflow until it is manually triggered (e.g., by user input): | ||
| ```yaml | ||
| - tool: createChatMessage | ||
| args: | ||
| role: 'assistant' | ||
| content: | | ||
| Tool calculation result: | ||
| {{ args.a }} + {{ args.b }} = {{ state.total }} | ||
| ```typescript | ||
| @Transition({ from: 'waiting_for_user', to: 'resumed', wait: true }) | ||
| async userContinue() {} | ||
| ``` | ||
| #### Using Helper Functions | ||
| The workflow pauses at the `waiting_for_user` state until an external signal triggers the `userContinue` transition. | ||
| Call workflow helpers in templates: | ||
| #### 4. Workflow Output | ||
| ```yaml | ||
| - tool: createChatMessage | ||
| args: | ||
| role: 'assistant' | ||
| content: | | ||
| Alternatively, using workflow getter function: | ||
| {{ args.a }} + {{ args.b }} = {{ sum args.a args.b }} | ||
| A `@Final` method can return data as the workflow output: | ||
| ```typescript | ||
| @Final({ from: 'resumed' }) | ||
| async continueCount(): Promise<{ total: number | undefined }> { | ||
| const c4 = await this.counterTool.call({}); | ||
| const c5 = await this.counterTool.call({}); | ||
| const c6 = await this.counterTool.call({}); | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Counter after resume: ${c4.data}, ${c5.data}, ${c6.data}\n\nIf state persisted, this should be 4, 5, 6.`, | ||
| }); | ||
| return { total: this.total }; | ||
| } | ||
| ``` | ||
| #### Stateful Tool Behavior | ||
| After resuming, the counter continues from where it left off (4, 5, 6), demonstrating that tool state survives a `wait` pause. | ||
| The counter tool increments on each call, demonstrating stateful tools: | ||
| #### 5. Private Helper Methods | ||
| ```yaml | ||
| - id: count1 | ||
| tool: counterTool | ||
| assign: | ||
| count1: ${{ result.data }} | ||
| - id: count2 | ||
| tool: counterTool | ||
| assign: | ||
| count2: ${{ result.data }} | ||
| - id: count3 | ||
| tool: counterTool | ||
| assign: | ||
| count3: ${{ result.data }} | ||
| - tool: createChatMessage | ||
| args: | ||
| role: 'assistant' | ||
| content: | | ||
| Counter tool should count: | ||
| Define private methods for reusable logic within the workflow: | ||
| {{ state.count1 }}, {{ state.count2 }}, {{ state.count3 }} | ||
| ```typescript | ||
| private sum(a: number, b: number) { | ||
| return a + b; | ||
| } | ||
| ``` | ||
| ### Workflow Output | ||
| ### Complete Workflow | ||
| The `@Output()` decorator defines the data returned when the workflow completes: | ||
| ```typescript | ||
| import { z } from 'zod'; | ||
| import { BaseWorkflow, Final, Initial, InjectTool, Transition, Workflow } from '@loopstack/common'; | ||
| import { CreateChatMessage } from '@loopstack/create-chat-message-tool'; | ||
| import { MathSumTool } from '../tools'; | ||
| import { CounterTool } from '../tools'; | ||
| ```typescript | ||
| @Output() | ||
| result() { | ||
| return { total: this.state.total }; | ||
| @Workflow({ | ||
| uiConfig: __dirname + '/custom-tool-example.ui.yaml', | ||
| schema: z | ||
| .object({ | ||
| a: z.number().default(1), | ||
| b: z.number().default(2), | ||
| }) | ||
| .strict(), | ||
| }) | ||
| export class CustomToolExampleWorkflow extends BaseWorkflow<{ a: number; b: number }> { | ||
| @InjectTool() private counterTool: CounterTool; | ||
| @InjectTool() private createChatMessage: CreateChatMessage; | ||
| @InjectTool() private mathTool: MathSumTool; | ||
| total?: number; | ||
| @Initial({ to: 'waiting_for_user' }) | ||
| async calculate(args: { a: number; b: number }) { | ||
| const calcResult = await this.mathTool.call({ a: args.a, b: args.b }); | ||
| this.total = calcResult.data as number; | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Tool calculation result:\n${args.a} + ${args.b} = ${this.total}`, | ||
| }); | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Alternatively, using workflow method:\n${args.a} + ${args.b} = ${this.sum(args.a, args.b)}`, | ||
| }); | ||
| const c1 = await this.counterTool.call({}); | ||
| const c2 = await this.counterTool.call({}); | ||
| const c3 = await this.counterTool.call({}); | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Counter before pause: ${c1.data}, ${c2.data}, ${c3.data}\n\nPress Next to continue...`, | ||
| }); | ||
| } | ||
| @Transition({ from: 'waiting_for_user', to: 'resumed', wait: true }) | ||
| async userContinue() {} | ||
| @Final({ from: 'resumed' }) | ||
| async continueCount(): Promise<{ total: number | undefined }> { | ||
| const c4 = await this.counterTool.call({}); | ||
| const c5 = await this.counterTool.call({}); | ||
| const c6 = await this.counterTool.call({}); | ||
| await this.createChatMessage.call({ | ||
| role: 'assistant', | ||
| content: `Counter after resume: ${c4.data}, ${c5.data}, ${c6.data}\n\nIf state persisted, this should be 4, 5, 6.`, | ||
| }); | ||
| return { total: this.total }; | ||
| } | ||
| private sum(a: number, b: number) { | ||
| return a + b; | ||
| } | ||
| } | ||
@@ -233,3 +291,3 @@ ``` | ||
| - `@loopstack/core` - Core framework functionality | ||
| - `@loopstack/common` - Base classes, decorators, and tool injection | ||
| - `@loopstack/create-chat-message-tool` - Provides `CreateChatMessage` tool | ||
@@ -236,0 +294,0 @@ |
45587
7.77%298
24.17%