agent, tools, toolbox, tool loop, AI environment
This commit is contained in:
parent
f8dbb2e08a
commit
3e31d4d501
@ -119,7 +119,7 @@ pnpm dev
|
|||||||
|
|
||||||
# Drone worker (in a workspace directory)
|
# Drone worker (in a workspace directory)
|
||||||
cd ~/my-gadget-workspace
|
cd ~/my-gadget-workspace
|
||||||
pnpm --filter @gadget/drone dev
|
pnpm --filter gadget-drone dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|||||||
860
docs/agent-toolbox.md
Normal file
860
docs/agent-toolbox.md
Normal file
@ -0,0 +1,860 @@
|
|||||||
|
# Agent Toolbox Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Agent Toolbox is a foundational component of the Gadget platform that enables AI agents to perform real-world actions through a standardized, extensible tool system. Tools are functions that agents can invoke to interact with external services, manipulate files, search the web, and execute various operations.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Design Philosophy
|
||||||
|
|
||||||
|
The toolbox system is built on these core principles:
|
||||||
|
|
||||||
|
1. **Provider Agnostic**: Tools work with any AI provider (Ollama, OpenAI, etc.)
|
||||||
|
2. **Type Safety**: Full TypeScript typing from definition to execution
|
||||||
|
3. **Extensibility**: Easy to add new tools without modifying core infrastructure
|
||||||
|
4. **Security**: Tools require explicit credentials configured in the environment
|
||||||
|
5. **Error Handling**: Comprehensive error reporting with recovery hints
|
||||||
|
|
||||||
|
### Component Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ AiToolbox │ ← Manages tool registration and lookup
|
||||||
|
│ - env │ ← Holds IAiEnvironment with credentials
|
||||||
|
│ - tools │ ← Map of all registered tools
|
||||||
|
│ - modeSets │ ← Tools organized by ChatSessionMode
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ register()
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ AiTool │ ← Abstract base class for all tools
|
||||||
|
│ - name │ ← Unique tool identifier
|
||||||
|
│ - category │ ← Tool category (search, file, etc.)
|
||||||
|
│ - definition │ ← JSON Schema for AI provider
|
||||||
|
│ - execute() │ ← Tool implementation
|
||||||
|
│ - toolbox │ ← Access to environment & other tools
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ extends
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GoogleSearch │ ← Example tool implementation
|
||||||
|
│ GrepTool │ ← Future tools...
|
||||||
|
│ ... │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Interfaces
|
||||||
|
|
||||||
|
### IAiEnvironment
|
||||||
|
|
||||||
|
The `IAiEnvironment` interface carries configuration and credentials from the application layer (which has access to YAML configs) down to the `@gadget/ai` package (which cannot read configs directly).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IAiEnvironment {
|
||||||
|
NODE_ENV: string;
|
||||||
|
services?: {
|
||||||
|
google?: {
|
||||||
|
cse?: {
|
||||||
|
apiKey?: string;
|
||||||
|
engineId?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
github?: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
slack?: {
|
||||||
|
token?: string;
|
||||||
|
signingSecret?: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
|
||||||
|
- **Services Object**: Credentials are organized by service (google, github, slack, etc.)
|
||||||
|
- **Optional Everything**: All fields are optional to support partial configurations
|
||||||
|
- **Extensible**: The `[key: string]: unknown` index signature allows future services without breaking changes
|
||||||
|
- **Type Safety**: Known services have typed interfaces
|
||||||
|
|
||||||
|
### AiToolbox
|
||||||
|
|
||||||
|
The toolbox manages tool registration, organization, and retrieval:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class AiToolbox {
|
||||||
|
constructor(env: IAiEnvironment);
|
||||||
|
|
||||||
|
// Register a tool for use by agents
|
||||||
|
register(tool: AiTool, modes?: string[]): void;
|
||||||
|
|
||||||
|
// Get a tool by name (for system tools)
|
||||||
|
getTool(name: string): AiTool | undefined;
|
||||||
|
|
||||||
|
// Get all tools for a specific mode
|
||||||
|
getModeSet(mode: string): ToolSet | undefined;
|
||||||
|
|
||||||
|
// Access environment credentials
|
||||||
|
get env(): IAiEnvironment;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Registration Modes:**
|
||||||
|
|
||||||
|
- **System Tools** (no modes): Called by the platform itself (e.g., auto-naming chat sessions)
|
||||||
|
- **Agent Tools** (with modes): Available to AI agents in specific ChatSessionModes (e.g., "code", "research", "debug")
|
||||||
|
|
||||||
|
### AiTool
|
||||||
|
|
||||||
|
All tools extend this abstract base class:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export abstract class AiTool {
|
||||||
|
protected _toolbox: AiToolbox;
|
||||||
|
|
||||||
|
// Unique identifier for the tool
|
||||||
|
abstract get name(): string;
|
||||||
|
|
||||||
|
// Category for organization (search, file, code, etc.)
|
||||||
|
abstract get category(): string;
|
||||||
|
|
||||||
|
// JSON Schema definition sent to AI provider
|
||||||
|
abstract get definition(): IToolDefinition;
|
||||||
|
|
||||||
|
// Execute the tool with provided arguments
|
||||||
|
abstract execute(args: IToolArguments, logger: IAiLogger): Promise<string>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IToolDefinition
|
||||||
|
|
||||||
|
The tool definition follows the JSON Schema format expected by AI providers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IToolDefinition {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: IToolArguments;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IToolArguments {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Execution Flow
|
||||||
|
|
||||||
|
The tool execution flow demonstrates the abstraction layer between the common tool interface and provider-specific implementations:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Application Layer (gadget-drone / gadget-code) │
|
||||||
|
│ - Reads YAML config │
|
||||||
|
│ - Constructs IAiEnvironment │
|
||||||
|
│ - Creates AiToolbox │
|
||||||
|
│ - Registers tools │
|
||||||
|
└────────────────────┬────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Pass tools to AI API
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ @gadget/ai - Provider Abstraction Layer │
|
||||||
|
│ - AiApi.chat() receives tools array │
|
||||||
|
│ - Transforms IToolDefinition → Provider format │
|
||||||
|
│ - Sends to AI provider (Ollama/OpenAI) │
|
||||||
|
│ - Receives tool_calls from response │
|
||||||
|
│ - Executes tools via executeToolCalls() │
|
||||||
|
│ - Feeds results back to AI for next iteration │
|
||||||
|
└────────────────────┬────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Provider-specific SDK calls
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ AI Provider (Ollama / OpenAI) │
|
||||||
|
│ - Receives tool definitions in native format │
|
||||||
|
│ - Decides when to call tools based on conversation │
|
||||||
|
│ - Returns tool_calls in response │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transformation Process
|
||||||
|
|
||||||
|
1. **Tool Definition Transformation**
|
||||||
|
|
||||||
|
Each provider has its own tool format. The `@gadget/ai` package transforms our common `IToolDefinition` to the provider's expected format:
|
||||||
|
|
||||||
|
**OpenAI:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const openaiTool: ChatCompletionTool = {
|
||||||
|
type: tool.definition.type,
|
||||||
|
function: {
|
||||||
|
name: tool.definition.function.name,
|
||||||
|
description: tool.definition.function.description,
|
||||||
|
parameters: tool.definition.function.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ollama:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const ollamaTool: Tool = {
|
||||||
|
type: tool.definition.type,
|
||||||
|
function: {
|
||||||
|
name: tool.definition.function.name,
|
||||||
|
description: tool.definition.function.description,
|
||||||
|
parameters: tool.definition.function.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Tool Call Execution**
|
||||||
|
|
||||||
|
When the AI provider returns tool calls, they're executed by the base `AiApi` class:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
protected async executeToolCalls(
|
||||||
|
toolCalls: IToolCall[],
|
||||||
|
tools: AiTool[],
|
||||||
|
): Promise<IToolCallResult[]> {
|
||||||
|
const results: IToolCallResult[] = [];
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
// Find the tool by name
|
||||||
|
const tool = tools.find(t => t.name === toolCall.function.name);
|
||||||
|
|
||||||
|
// Parse arguments and execute
|
||||||
|
const args = JSON.parse(toolCall.function.arguments);
|
||||||
|
const result = await tool.execute(args, this.log);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
functionName: toolCall.function.name,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Result Transformation**
|
||||||
|
|
||||||
|
Tool results are converted back to the provider's message format and appended to the conversation:
|
||||||
|
|
||||||
|
**OpenAI:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: result.callId,
|
||||||
|
content: result.error || result.result,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ollama:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
content: result.error || result.result,
|
||||||
|
tool_name: result.functionName,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Iterative Execution
|
||||||
|
|
||||||
|
Both OpenAI and Ollama implementations support multiple rounds of tool calls:
|
||||||
|
|
||||||
|
1. AI receives tools and conversation
|
||||||
|
2. AI returns tool calls (or final response)
|
||||||
|
3. Tools are executed, results collected
|
||||||
|
4. Results appended to conversation as tool messages
|
||||||
|
5. Loop back to step 1 (up to `maxToolIterations`, default: 5)
|
||||||
|
6. Return final response or max iterations reached
|
||||||
|
|
||||||
|
This allows complex multi-step operations where the AI can:
|
||||||
|
|
||||||
|
- Call multiple tools in parallel
|
||||||
|
- Use results from one tool to inform the next
|
||||||
|
- Refine its approach based on tool output
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Setup
|
||||||
|
|
||||||
|
Tools requiring credentials need them configured in your YAML config files.
|
||||||
|
|
||||||
|
#### gadget-code.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Add to gadget-code.yaml
|
||||||
|
google:
|
||||||
|
cse:
|
||||||
|
apiKey: "${GOOGLE_CSE_API_KEY}"
|
||||||
|
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### gadget-drone.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Add to gadget-drone.yaml
|
||||||
|
google:
|
||||||
|
cse:
|
||||||
|
apiKey: "${GOOGLE_CSE_API_KEY}"
|
||||||
|
engineId: "${GOOGLE_CSE_ENGINE_ID}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the actual values in your shell or deployment environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GOOGLE_CSE_API_KEY="your-api-key-here"
|
||||||
|
export GOOGLE_CSE_ENGINE_ID="your-engine-id-here"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Usage
|
||||||
|
|
||||||
|
In consumer applications, construct the environment and pass to the AI API:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// gadget-drone/src/services/ai.ts
|
||||||
|
import { createAiApi, IAiEnvironment } from "@gadget/ai";
|
||||||
|
import env from "../config/env.js";
|
||||||
|
|
||||||
|
const aiEnv: IAiEnvironment = {
|
||||||
|
NODE_ENV: env.NODE_ENV,
|
||||||
|
services: {
|
||||||
|
google: {
|
||||||
|
cse: {
|
||||||
|
apiKey: env.google.cse.apiKey,
|
||||||
|
engineId: env.google.cse.engineId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = createAiApi(aiEnv, providerConfig, logger);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Tool: Google Search
|
||||||
|
|
||||||
|
The `GoogleSearchTool` demonstrates a complete tool implementation.
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
**Purpose**: Perform Google Custom Search Engine queries to find relevant web content.
|
||||||
|
|
||||||
|
**Category**: `search`
|
||||||
|
|
||||||
|
**Credentials Required**:
|
||||||
|
|
||||||
|
- Google CSE API Key
|
||||||
|
- Google CSE Engine ID
|
||||||
|
|
||||||
|
### Tool Definition
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_google",
|
||||||
|
description: "Perform a Google search for relevant information on the web.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "The search query string.",
|
||||||
|
},
|
||||||
|
num_results: {
|
||||||
|
type: "number",
|
||||||
|
description: "Number of search results to return (default: 10, max: 10).",
|
||||||
|
},
|
||||||
|
siteSearch: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional site to restrict the search to (e.g. github.com).",
|
||||||
|
},
|
||||||
|
dateRestrict: {
|
||||||
|
type: "string",
|
||||||
|
description: "Restrict results by date range (d1, d7, d30, d365).",
|
||||||
|
},
|
||||||
|
fileType: {
|
||||||
|
type: "string",
|
||||||
|
description: "Restrict results to file type (pdf, doc, xls, ppt).",
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["relevance", "date"],
|
||||||
|
description: "Sort order for results.",
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
type: "number",
|
||||||
|
description: "Starting index for pagination (default: 1).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
**Basic Search:**
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Agent: "Let me search for the latest TypeScript 5.0 features."
|
||||||
|
Tool Call: search_google({ query: "TypeScript 5.0 new features" })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site-Restricted Search:**
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Agent: "I'll search the React documentation for useEffect best practices."
|
||||||
|
Tool Call: search_google({
|
||||||
|
query: "useEffect best practices",
|
||||||
|
siteSearch: "react.dev"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Date-Restricted Search:**
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Agent: "Let me find recent news about AI regulation from the past week."
|
||||||
|
Tool Call: search_google({
|
||||||
|
query: "AI regulation news",
|
||||||
|
dateRestrict: "d7",
|
||||||
|
sort: "date"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**File Type Search:**
|
||||||
|
|
||||||
|
```
|
||||||
|
AI Agent: "I'll search for Python style guide PDFs."
|
||||||
|
Tool Call: search_google({
|
||||||
|
query: "Python style guide",
|
||||||
|
fileType: "pdf"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Details
|
||||||
|
|
||||||
|
**File**: `packages/ai/src/tools/search/google.ts`
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
|
||||||
|
1. **`execute(args, logger)`**: Validates input, calls search, formats results
|
||||||
|
2. **`search(query, options)`**: Makes the Google CSE API call
|
||||||
|
3. **`parseCseError(error)`**: Converts API errors to formatted tool errors
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
|
||||||
|
The tool provides detailed error messages for common issues:
|
||||||
|
|
||||||
|
- **401 Unauthorized**: Invalid API key
|
||||||
|
- **403 Forbidden**: Engine ID or permission issues
|
||||||
|
- **429 Rate Limited**: Quota exceeded with retry-after hint
|
||||||
|
- **Network Errors**: Connection failures
|
||||||
|
- **Missing Parameters**: Clear guidance on required fields
|
||||||
|
|
||||||
|
**Result Formatting:**
|
||||||
|
|
||||||
|
Search results are formatted as human-readable text:
|
||||||
|
|
||||||
|
```
|
||||||
|
Here are some relevant search results I found:
|
||||||
|
|
||||||
|
Title: TypeScript 5.0 Release Notes
|
||||||
|
Link: https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/
|
||||||
|
Source: devblogs.microsoft.com
|
||||||
|
Snippet: TypeScript 5.0 introduces decorators, const type parameters, and more...
|
||||||
|
|
||||||
|
Title: What's New in TypeScript 5.0
|
||||||
|
Link: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html
|
||||||
|
Snippet: A comprehensive guide to all the new features in TypeScript 5.0...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### IToolError Interface
|
||||||
|
|
||||||
|
All tool errors follow a standardized format:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IToolError {
|
||||||
|
code: ToolErrorCode;
|
||||||
|
message: string;
|
||||||
|
parameter?: string;
|
||||||
|
expected?: string;
|
||||||
|
example?: string;
|
||||||
|
recoveryHint?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Codes
|
||||||
|
|
||||||
|
Available error codes in `ToolErrorCode`:
|
||||||
|
|
||||||
|
- `MISSING_PARAMETER` - Required parameter not provided
|
||||||
|
- `INVALID_PARAMETER` - Parameter value is invalid
|
||||||
|
- `NOT_FOUND` - Resource not found
|
||||||
|
- `PERMISSION_DENIED` - Insufficient permissions
|
||||||
|
- `OPERATION_FAILED` - General operation failure
|
||||||
|
- `OPERATION_NOT_ALLOWED` - Operation not permitted
|
||||||
|
- `VALIDATION_ERROR` - Input validation failed
|
||||||
|
- `RATE_LIMITED` - Rate limit exceeded
|
||||||
|
- `LIMIT_EXCEEDED` - Quota or limit exceeded
|
||||||
|
- `INVALID_CRON_SPEC` - Invalid cron expression
|
||||||
|
- `INVALID_OPERATION` - Operation not supported
|
||||||
|
- `TIMEOUT` - Operation timed out
|
||||||
|
- `INVALID_TOOL_ARGUMENTS` - Tool arguments invalid
|
||||||
|
- `SUBAGENT_FAILED` - Subagent execution failed
|
||||||
|
- `TOOL_EXECUTION_FAILED` - General tool execution error
|
||||||
|
- `UNAUTHORIZED` - Authentication required
|
||||||
|
- `FORBIDDEN` - Access denied
|
||||||
|
- `RATE_LIMIT_EXCEEDED` - Rate limit exceeded
|
||||||
|
- `NETWORK_ERROR` - Network connectivity issue
|
||||||
|
- `SECURITY_VIOLATION` - Security policy violation
|
||||||
|
|
||||||
|
### formatError Function
|
||||||
|
|
||||||
|
The `formatError` function creates consistent error messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const error: IToolError = {
|
||||||
|
code: "MISSING_PARAMETER",
|
||||||
|
message: "The 'query' parameter is required.",
|
||||||
|
parameter: "query",
|
||||||
|
expected: "A non-empty string containing the search query.",
|
||||||
|
example: 'search_google(query: "latest AI news")',
|
||||||
|
recoveryHint:
|
||||||
|
"Provide a 'query' parameter with your search terms and try again.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return formatError(error);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
TOOL ERROR: MISSING_PARAMETER
|
||||||
|
The 'query' parameter is required.
|
||||||
|
PARAMETER: query
|
||||||
|
EXPECTED: A non-empty string containing the search query.
|
||||||
|
EXAMPLE: search_google(query: "latest AI news")
|
||||||
|
RECOVERY HINT: Provide a 'query' parameter with your search terms and try again.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating New Tools
|
||||||
|
|
||||||
|
### Step 1: Create Tool Class
|
||||||
|
|
||||||
|
Extend `AiTool` and implement the required methods:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AiTool, IToolArguments, IToolDefinition } from "../tool.js";
|
||||||
|
import { IAiLogger } from "../../api.js";
|
||||||
|
import { formatError } from "../tool-error.js";
|
||||||
|
|
||||||
|
export class MyNewTool extends AiTool {
|
||||||
|
get name(): string {
|
||||||
|
return "my_tool_name";
|
||||||
|
}
|
||||||
|
|
||||||
|
get category(): string {
|
||||||
|
return "category_name"; // e.g., "search", "file", "code"
|
||||||
|
}
|
||||||
|
|
||||||
|
get definition(): IToolDefinition {
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: this.name,
|
||||||
|
description: "Clear description of what the tool does.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
param1: {
|
||||||
|
type: "string",
|
||||||
|
description: "Description of param1",
|
||||||
|
},
|
||||||
|
param2: {
|
||||||
|
type: "number",
|
||||||
|
description: "Description of param2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["param1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||||
|
// Validate parameters
|
||||||
|
if (!args.param1) {
|
||||||
|
return formatError({
|
||||||
|
code: "MISSING_PARAMETER",
|
||||||
|
message: "param1 is required",
|
||||||
|
parameter: "param1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access credentials if needed
|
||||||
|
const apiKey = this.toolbox.env.services?.myService?.apiKey;
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("API key not configured in environment");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the operation
|
||||||
|
logger.debug("executing my tool", { args });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.doSomething(args.param1);
|
||||||
|
return `Operation successful: ${result}`;
|
||||||
|
} catch (error) {
|
||||||
|
return formatError({
|
||||||
|
code: "OPERATION_FAILED",
|
||||||
|
message: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doSomething(param1: string): Promise<string> {
|
||||||
|
// Implementation here
|
||||||
|
return "result";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add Credentials (if needed)
|
||||||
|
|
||||||
|
Update `IAiEnvironment` in `packages/ai/src/config/env.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface IAiEnvironment {
|
||||||
|
NODE_ENV: string;
|
||||||
|
services?: {
|
||||||
|
google?: {
|
||||||
|
cse?: {
|
||||||
|
apiKey?: string;
|
||||||
|
engineId?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
myService?: {
|
||||||
|
apiKey?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update config types in `packages/config/src/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface GadgetDroneConfig {
|
||||||
|
// ... existing fields
|
||||||
|
myService?: {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update consumer config readers to populate the environment.
|
||||||
|
|
||||||
|
### Step 3: Register the Tool
|
||||||
|
|
||||||
|
In gadget-drone startup code (to be implemented):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { AiToolbox } from "@gadget/ai";
|
||||||
|
import { MyNewTool } from "@gadget/ai/tools/my-tool.js";
|
||||||
|
|
||||||
|
const toolbox = new AiToolbox(aiEnv);
|
||||||
|
|
||||||
|
// Register as system tool (no modes)
|
||||||
|
toolbox.register(new MyNewTool());
|
||||||
|
|
||||||
|
// Or register for specific modes
|
||||||
|
toolbox.register(new MyNewTool(), ["code", "debug"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Tools
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
Test tool validation and execution:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { GoogleSearchTool } from "../src/tools/search/google.js";
|
||||||
|
import { AiToolbox } from "../src/toolbox.js";
|
||||||
|
import { createEmptyEnvironment } from "../src/config/env.js";
|
||||||
|
|
||||||
|
describe("GoogleSearchTool", () => {
|
||||||
|
const env = createEmptyEnvironment();
|
||||||
|
const toolbox = new AiToolbox(env);
|
||||||
|
const tool = new GoogleSearchTool(toolbox);
|
||||||
|
|
||||||
|
it("should have correct name", () => {
|
||||||
|
expect(tool.name).toBe("search_google");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have correct category", () => {
|
||||||
|
expect(tool.category).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have query in required parameters", () => {
|
||||||
|
const params = tool.definition.function.parameters;
|
||||||
|
expect(params.required).toContain("query");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return error for missing query", async () => {
|
||||||
|
const result = await tool.execute(
|
||||||
|
{},
|
||||||
|
{ debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
expect(result).toContain("TOOL ERROR: MISSING_PARAMETER");
|
||||||
|
expect(result).toContain("query");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Test with actual credentials (use mock API):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { GoogleSearchTool } from "../src/tools/search/google.js";
|
||||||
|
import { AiToolbox } from "../src/toolbox.js";
|
||||||
|
|
||||||
|
describe("GoogleSearchTool integration", () => {
|
||||||
|
it("should call Google CSE API with correct parameters", async () => {
|
||||||
|
const env = {
|
||||||
|
NODE_ENV: "test",
|
||||||
|
services: {
|
||||||
|
google: {
|
||||||
|
cse: {
|
||||||
|
apiKey: "test-key",
|
||||||
|
engineId: "test-engine",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolbox = new AiToolbox(env);
|
||||||
|
const tool = new GoogleSearchTool(toolbox);
|
||||||
|
|
||||||
|
// Mock the googleapis client
|
||||||
|
// ... mock setup ...
|
||||||
|
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ query: "test query", num_results: 5 },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain("search results");
|
||||||
|
// Verify API was called with correct params
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Validate Early, Validate Often
|
||||||
|
|
||||||
|
Check all required parameters at the start of `execute()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||||
|
if (!args.query || typeof args.query !== "string") {
|
||||||
|
return formatError({
|
||||||
|
code: "INVALID_PARAMETER",
|
||||||
|
message: "query must be a string",
|
||||||
|
parameter: "query",
|
||||||
|
expected: "A string containing the search query",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ... rest of implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Use Structured Errors
|
||||||
|
|
||||||
|
Always use `formatError()` for consistent error reporting:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
return formatError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "File not found",
|
||||||
|
parameter: "path",
|
||||||
|
recoveryHint: "Check that the file path exists and is accessible",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
throw new Error("File not found");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Log Appropriately
|
||||||
|
|
||||||
|
Use the logger for debugging and auditing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
logger.debug("starting tool execution", { args });
|
||||||
|
logger.info("tool completed successfully", { resultLength: result.length });
|
||||||
|
logger.warn("rate limit approaching", { remaining: 10 });
|
||||||
|
logger.error("tool failed", { error: error.message });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Credentials Safely
|
||||||
|
|
||||||
|
Never log credentials or include them in error messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error("API key not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Bad - exposes credential
|
||||||
|
logger.debug("using API key", { apiKey });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Document Thoroughly
|
||||||
|
|
||||||
|
Include in your tool:
|
||||||
|
|
||||||
|
- Clear description in the definition
|
||||||
|
- Parameter descriptions with examples
|
||||||
|
- Error scenarios and recovery hints
|
||||||
|
- Usage examples in comments
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Planned improvements to the toolbox system:
|
||||||
|
|
||||||
|
1. **Parallel Execution**: Execute independent tool calls in parallel
|
||||||
|
2. **Caching**: Cache tool results for repeated queries
|
||||||
|
3. **Rate Limiting**: Built-in rate limiting per tool
|
||||||
|
4. **Tool Metadata**: Version, author, usage statistics
|
||||||
|
5. **Result Streaming**: Stream large results back to AI
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Configuration Guide](./configuration.md) - Setting up tool credentials
|
||||||
|
- [Architecture Overview](./architecture.md) - System architecture
|
||||||
|
- [AI API Reference](../packages/ai/README.md) - `@gadget/ai` package documentation
|
||||||
@ -535,7 +535,7 @@ pnpm dev
|
|||||||
|
|
||||||
# Terminal 3: Drone (separate workspace directory)
|
# Terminal 3: Drone (separate workspace directory)
|
||||||
cd ~/my-gadget-workspace
|
cd ~/my-gadget-workspace
|
||||||
pnpm --filter @gadget/drone dev
|
pnpm --filter gadget-drone dev
|
||||||
# Registers with platform, waits for work orders
|
# Registers with platform, waits for work orders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -258,7 +258,7 @@ For production deployment, the packages would be published to npm and installed
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install from npm (when published)
|
# Install from npm (when published)
|
||||||
npm install -g gadget-code @gadget/drone
|
npm install -g gadget-code gadget-drone
|
||||||
|
|
||||||
# Run from anywhere
|
# Run from anywhere
|
||||||
gadget-code-web
|
gadget-code-web
|
||||||
|
|||||||
@ -5,10 +5,12 @@
|
|||||||
import env from "./config/env.js";
|
import env from "./config/env.js";
|
||||||
const aiEnv: IAiEnvironment = {
|
const aiEnv: IAiEnvironment = {
|
||||||
NODE_ENV: env.NODE_ENV || "develop",
|
NODE_ENV: env.NODE_ENV || "develop",
|
||||||
google: {
|
services: {
|
||||||
cse: {
|
google: {
|
||||||
apiKey: env.google.cse.apiKey,
|
cse: {
|
||||||
engineId: env.google.cse.engineId,
|
apiKey: env.google.cse.apiKey,
|
||||||
|
engineId: env.google.cse.engineId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@gadget/drone",
|
"name": "gadget-drone",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Gadget Code drone process",
|
"description": "Gadget Code drone process",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -2,10 +2,14 @@
|
|||||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||||
// Licensed under the Apache License, Version 2.0
|
// Licensed under the Apache License, Version 2.0
|
||||||
|
|
||||||
import { Types } from "@gadget/api";
|
import env from "../config/env.ts";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
import { Socket } from "socket.io-client";
|
import { Socket } from "socket.io-client";
|
||||||
import {
|
import {
|
||||||
|
GoogleSearchTool,
|
||||||
IAiChatOptions,
|
IAiChatOptions,
|
||||||
|
IAiEnvironment,
|
||||||
IAiStreamChunk,
|
IAiStreamChunk,
|
||||||
type IContextChatMessage,
|
type IContextChatMessage,
|
||||||
} from "@gadget/ai";
|
} from "@gadget/ai";
|
||||||
@ -16,11 +20,13 @@ import {
|
|||||||
IUser,
|
IUser,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
|
ChatSessionMode,
|
||||||
} from "@gadget/api";
|
} from "@gadget/api";
|
||||||
|
|
||||||
import AiService from "./ai.ts";
|
import AiService from "./ai.ts";
|
||||||
|
|
||||||
import { GadgetService } from "../lib/service.ts";
|
import { GadgetService } from "../lib/service.ts";
|
||||||
|
import { AiToolbox } from "../../../packages/ai/dist/toolbox.js";
|
||||||
|
|
||||||
export interface IToolCall {
|
export interface IToolCall {
|
||||||
name: string;
|
name: string;
|
||||||
@ -44,6 +50,8 @@ interface IAgentWorkflow {
|
|||||||
type DroneSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
type DroneSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
class AgentService extends GadgetService {
|
class AgentService extends GadgetService {
|
||||||
|
private toolbox: AiToolbox | undefined;
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return "AgentService";
|
return "AgentService";
|
||||||
}
|
}
|
||||||
@ -52,6 +60,7 @@ class AgentService extends GadgetService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
|
this.createAgentToolbox();
|
||||||
this.log.info("started");
|
this.log.info("started");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +72,8 @@ class AgentService extends GadgetService {
|
|||||||
workOrder: IAgentWorkOrder,
|
workOrder: IAgentWorkOrder,
|
||||||
socket: DroneSocket,
|
socket: DroneSocket,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
assert(this.toolbox, "service uninitialized");
|
||||||
|
|
||||||
const { turn } = workOrder;
|
const { turn } = workOrder;
|
||||||
const task: IAgentWorkflow = {
|
const task: IAgentWorkflow = {
|
||||||
chatOptions: {},
|
chatOptions: {},
|
||||||
@ -83,6 +94,7 @@ class AgentService extends GadgetService {
|
|||||||
systemPrompt: turn.prompts.system,
|
systemPrompt: turn.prompts.system,
|
||||||
context: task.context,
|
context: task.context,
|
||||||
userPrompt: turn.prompts.user,
|
userPrompt: turn.prompts.user,
|
||||||
|
tools: Array.from(this.toolbox.getModeSet(turn.mode) || []),
|
||||||
};
|
};
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
socket.emit(
|
socket.emit(
|
||||||
@ -235,6 +247,30 @@ class AgentService extends GadgetService {
|
|||||||
pruneSessionContext(messages: IContextChatMessage[]): void {
|
pruneSessionContext(messages: IContextChatMessage[]): void {
|
||||||
// TODO
|
// TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createAgentToolbox(): void {
|
||||||
|
const aiEnv: IAiEnvironment = {
|
||||||
|
NODE_ENV: env.NODE_ENV || "develop",
|
||||||
|
services: {
|
||||||
|
google: {
|
||||||
|
cse: {
|
||||||
|
apiKey: env.google.cse.apiKey,
|
||||||
|
engineId: env.google.cse.engineId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.toolbox = new AiToolbox(aiEnv);
|
||||||
|
|
||||||
|
let tool = new GoogleSearchTool(this.toolbox);
|
||||||
|
this.toolbox.register(tool, [
|
||||||
|
ChatSessionMode.Plan,
|
||||||
|
ChatSessionMode.Build,
|
||||||
|
ChatSessionMode.Test,
|
||||||
|
ChatSessionMode.Ship,
|
||||||
|
ChatSessionMode.Develop,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AgentService();
|
export default new AgentService();
|
||||||
|
|||||||
@ -130,10 +130,12 @@ class AiService extends GadgetService {
|
|||||||
getApi(provider: AiProviderConfig) {
|
getApi(provider: AiProviderConfig) {
|
||||||
const aiEnv: IAiEnvironment = {
|
const aiEnv: IAiEnvironment = {
|
||||||
NODE_ENV: env.NODE_ENV,
|
NODE_ENV: env.NODE_ENV,
|
||||||
google: {
|
services: {
|
||||||
cse: {
|
google: {
|
||||||
apiKey: env.google.cse.apiKey,
|
cse: {
|
||||||
engineId: env.google.cse.engineId,
|
apiKey: env.google.cse.apiKey,
|
||||||
|
engineId: env.google.cse.engineId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,7 +13,10 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc --watch"
|
"dev": "tsc --watch",
|
||||||
|
"clean": "rm -rf dist/",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "echo \"No tests configured yet\""
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"gadget",
|
"gadget",
|
||||||
|
|||||||
@ -62,13 +62,6 @@ export interface IContextChatMessage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiChatOptions {
|
|
||||||
systemPrompt?: string;
|
|
||||||
userPrompt?: string;
|
|
||||||
context?: IContextChatMessage[];
|
|
||||||
tools?: AiTool[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IToolCall {
|
export interface IToolCall {
|
||||||
callId: string;
|
callId: string;
|
||||||
function: {
|
function: {
|
||||||
@ -77,6 +70,21 @@ export interface IToolCall {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IToolCallResult {
|
||||||
|
callId: string;
|
||||||
|
functionName: string;
|
||||||
|
result: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAiChatOptions {
|
||||||
|
systemPrompt?: string;
|
||||||
|
userPrompt?: string;
|
||||||
|
context?: IContextChatMessage[];
|
||||||
|
tools?: AiTool[];
|
||||||
|
maxToolIterations?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAiChatResponse {
|
export interface IAiChatResponse {
|
||||||
response: string;
|
response: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
@ -84,6 +92,7 @@ export interface IAiChatResponse {
|
|||||||
done: boolean;
|
done: boolean;
|
||||||
doneReason?: string;
|
doneReason?: string;
|
||||||
toolCalls?: IToolCall[];
|
toolCalls?: IToolCall[];
|
||||||
|
toolCallResults?: IToolCallResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAiStreamChunk {
|
export interface IAiStreamChunk {
|
||||||
@ -161,4 +170,49 @@ export abstract class AiApi {
|
|||||||
options: IAiChatOptions,
|
options: IAiChatOptions,
|
||||||
streamCallback?: IAiResponseStreamFn,
|
streamCallback?: IAiResponseStreamFn,
|
||||||
): Promise<IAiChatResponse>;
|
): Promise<IAiChatResponse>;
|
||||||
|
|
||||||
|
protected async executeToolCalls(
|
||||||
|
toolCalls: IToolCall[],
|
||||||
|
tools: AiTool[],
|
||||||
|
): Promise<IToolCallResult[]> {
|
||||||
|
const results: IToolCallResult[] = [];
|
||||||
|
|
||||||
|
for (const toolCall of toolCalls) {
|
||||||
|
const tool = tools.find((t) => t.name === toolCall.function.name);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
this.log.warn(`tool not found: ${toolCall.function.name}`);
|
||||||
|
results.push({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
functionName: toolCall.function.name,
|
||||||
|
result: "",
|
||||||
|
error: `Tool '${toolCall.function.name}' not found`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(toolCall.function.arguments);
|
||||||
|
const result = await tool.execute(args, this.log);
|
||||||
|
results.push({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
functionName: toolCall.function.name,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message;
|
||||||
|
this.log.error(`tool execution failed: ${toolCall.function.name}`, {
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
results.push({
|
||||||
|
callId: toolCall.callId,
|
||||||
|
functionName: toolCall.function.name,
|
||||||
|
result: "",
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,27 @@
|
|||||||
|
|
||||||
export interface IAiEnvironment {
|
export interface IAiEnvironment {
|
||||||
NODE_ENV: string;
|
NODE_ENV: string;
|
||||||
google: {
|
services?: {
|
||||||
cse: {
|
google?: {
|
||||||
apiKey: string | undefined;
|
cse?: {
|
||||||
engineId: string | undefined;
|
apiKey?: string;
|
||||||
|
engineId?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
github?: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
slack?: {
|
||||||
|
token?: string;
|
||||||
|
signingSecret?: string;
|
||||||
|
};
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyEnvironment(): IAiEnvironment {
|
||||||
|
return {
|
||||||
|
NODE_ENV: "development",
|
||||||
|
services: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,8 @@ export {
|
|||||||
type IAiModelProbeResult,
|
type IAiModelProbeResult,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
|
||||||
|
export * from "./tools/search/google.ts";
|
||||||
|
|
||||||
export { OllamaAiApi } from "./ollama.js";
|
export { OllamaAiApi } from "./ollama.js";
|
||||||
export { OpenAiApi } from "./openai.js";
|
export { OpenAiApi } from "./openai.js";
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
AiApi,
|
AiApi,
|
||||||
IAiChatOptions,
|
IAiChatOptions,
|
||||||
IAiChatResponse,
|
IAiChatResponse,
|
||||||
|
IToolCallResult,
|
||||||
IAiGenerateOptions,
|
IAiGenerateOptions,
|
||||||
IAiGenerateResponse,
|
IAiGenerateResponse,
|
||||||
IAiLogger,
|
IAiLogger,
|
||||||
@ -20,6 +21,7 @@ import {
|
|||||||
IAiResponseStreamFn,
|
IAiResponseStreamFn,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { IAiEnvironment } from "./config/env.ts";
|
import { IAiEnvironment } from "./config/env.ts";
|
||||||
|
import type { Message as OllamaMessage } from "ollama";
|
||||||
|
|
||||||
export class OllamaAiApi extends AiApi {
|
export class OllamaAiApi extends AiApi {
|
||||||
protected client: Ollama;
|
protected client: Ollama;
|
||||||
@ -195,33 +197,118 @@ export class OllamaAiApi extends AiApi {
|
|||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await this.client.chat({
|
const maxIterations = options.maxToolIterations ?? 5;
|
||||||
model: model.modelId,
|
let iteration = 0;
|
||||||
messages: options.context,
|
|
||||||
stream: true,
|
|
||||||
think: model.params.reasoning,
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastChunk;
|
const messages: OllamaMessage[] = options.context
|
||||||
for await (const chunk of response) {
|
? options.context.map((msg) => ({
|
||||||
await this.log.debug("stream chunk received", { chunk });
|
role: msg.role,
|
||||||
lastChunk = chunk;
|
content: msg.content,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
const allToolCallResults: IToolCallResult[] = [];
|
||||||
|
|
||||||
|
while (iteration < maxIterations) {
|
||||||
|
iteration++;
|
||||||
|
|
||||||
|
const ollamaTools = options.tools
|
||||||
|
? options.tools.map((tool) => ({
|
||||||
|
type: tool.definition.type,
|
||||||
|
function: {
|
||||||
|
name: tool.definition.function.name,
|
||||||
|
description: tool.definition.function.description,
|
||||||
|
parameters: tool.definition.function.parameters,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const response = await this.client.chat({
|
||||||
|
model: model.modelId,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
think: model.params.reasoning,
|
||||||
|
tools: ollamaTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastChunk;
|
||||||
|
for await (const chunk of response) {
|
||||||
|
await this.log.debug("stream chunk received", { chunk });
|
||||||
|
lastChunk = chunk;
|
||||||
|
}
|
||||||
|
assert(lastChunk, "no response chunks received");
|
||||||
|
|
||||||
|
const toolCalls = lastChunk.message.tool_calls?.map((tc) => ({
|
||||||
|
callId: `tool_${tc.function.name}_${Date.now()}`,
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: JSON.stringify(tc.function.arguments),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!toolCalls || toolCalls.length === 0) {
|
||||||
|
return {
|
||||||
|
done: lastChunk.done,
|
||||||
|
doneReason: lastChunk.done_reason,
|
||||||
|
response: lastChunk.message.content,
|
||||||
|
thinking: lastChunk.message.thinking,
|
||||||
|
toolCalls: undefined,
|
||||||
|
toolCallResults: allToolCallResults.length > 0 ? allToolCallResults : undefined,
|
||||||
|
stats: {
|
||||||
|
duration: {
|
||||||
|
seconds: lastChunk.total_duration,
|
||||||
|
text: numeral(lastChunk.total_duration).format("hh:mm:ss"),
|
||||||
|
},
|
||||||
|
tokenCounts: {
|
||||||
|
input: lastChunk.prompt_eval_count,
|
||||||
|
response: lastChunk.eval_count,
|
||||||
|
thinking: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCallResults = await this.executeToolCalls(
|
||||||
|
toolCalls,
|
||||||
|
options.tools || [],
|
||||||
|
);
|
||||||
|
allToolCallResults.push(...toolCallResults);
|
||||||
|
|
||||||
|
const assistantMsg: OllamaMessage = {
|
||||||
|
role: "assistant",
|
||||||
|
content: lastChunk.message.content,
|
||||||
|
};
|
||||||
|
if (lastChunk.message.thinking) {
|
||||||
|
assistantMsg.thinking = lastChunk.message.thinking;
|
||||||
|
}
|
||||||
|
if (lastChunk.message.tool_calls) {
|
||||||
|
assistantMsg.tool_calls = lastChunk.message.tool_calls;
|
||||||
|
}
|
||||||
|
messages.push(assistantMsg);
|
||||||
|
|
||||||
|
for (const result of toolCallResults) {
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
content: result.error || result.result,
|
||||||
|
tool_name: result.functionName,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
assert(lastChunk, "no response chunks received");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
done: lastChunk.done,
|
done: false,
|
||||||
doneReason: lastChunk.done_reason,
|
doneReason: "max_tool_iterations_reached",
|
||||||
response: lastChunk.message.content,
|
response: "",
|
||||||
thinking: lastChunk.message.thinking,
|
thinking: undefined,
|
||||||
|
toolCalls: undefined,
|
||||||
|
toolCallResults: allToolCallResults,
|
||||||
stats: {
|
stats: {
|
||||||
duration: {
|
duration: {
|
||||||
seconds: lastChunk.total_duration,
|
seconds: 0,
|
||||||
text: numeral(lastChunk.total_duration).format("hh:mm:ss"),
|
text: "00:00:00",
|
||||||
},
|
},
|
||||||
tokenCounts: {
|
tokenCounts: {
|
||||||
input: lastChunk.prompt_eval_count,
|
input: 0,
|
||||||
response: lastChunk.eval_count,
|
response: 0,
|
||||||
thinking: 0,
|
thinking: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
AiApi,
|
AiApi,
|
||||||
IAiChatOptions,
|
IAiChatOptions,
|
||||||
IAiChatResponse,
|
IAiChatResponse,
|
||||||
|
IToolCallResult,
|
||||||
IAiGenerateOptions,
|
IAiGenerateOptions,
|
||||||
IAiGenerateResponse,
|
IAiGenerateResponse,
|
||||||
IAiLogger,
|
IAiLogger,
|
||||||
@ -18,8 +19,11 @@ import {
|
|||||||
IAiResponseStreamFn,
|
IAiResponseStreamFn,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
|
ChatCompletionAssistantMessageParam,
|
||||||
ChatCompletionFunctionTool,
|
ChatCompletionFunctionTool,
|
||||||
|
ChatCompletionMessageParam,
|
||||||
ChatCompletionTool,
|
ChatCompletionTool,
|
||||||
|
ChatCompletionToolMessageParam,
|
||||||
} from "openai/resources";
|
} from "openai/resources";
|
||||||
import { IAiEnvironment } from "./config/env.ts";
|
import { IAiEnvironment } from "./config/env.ts";
|
||||||
|
|
||||||
@ -233,10 +237,12 @@ export class OpenAiApi extends AiApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
const maxIterations = options.maxToolIterations ?? 5;
|
||||||
|
let iteration = 0;
|
||||||
|
|
||||||
const messages = [];
|
const messages: ChatCompletionMessageParam[] = [];
|
||||||
if (options.systemPrompt) {
|
if (options.systemPrompt) {
|
||||||
messages.push({ role: "system" as const, content: options.systemPrompt });
|
messages.push({ role: "system", content: options.systemPrompt });
|
||||||
}
|
}
|
||||||
if (options.context) {
|
if (options.context) {
|
||||||
for (const msg of options.context) {
|
for (const msg of options.context) {
|
||||||
@ -247,57 +253,113 @@ export class OpenAiApi extends AiApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (options.userPrompt) {
|
if (options.userPrompt) {
|
||||||
messages.push({ role: "user" as const, content: options.userPrompt });
|
messages.push({ role: "user", content: options.userPrompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools: ChatCompletionTool[] = options.tools
|
const allToolCallResults: IToolCallResult[] = [];
|
||||||
? options.tools.map((tool) => {
|
|
||||||
const openaiTool: ChatCompletionFunctionTool = {
|
while (iteration < maxIterations) {
|
||||||
type: tool.definition.type,
|
iteration++;
|
||||||
function: {
|
|
||||||
name: tool.definition.function.name,
|
const tools: ChatCompletionTool[] = options.tools
|
||||||
description: tool.definition.function.description,
|
? options.tools.map((tool) => {
|
||||||
parameters: tool.definition.function.parameters,
|
const openaiTool: ChatCompletionFunctionTool = {
|
||||||
|
type: tool.definition.type,
|
||||||
|
function: {
|
||||||
|
name: tool.definition.function.name,
|
||||||
|
description: tool.definition.function.description,
|
||||||
|
parameters: tool.definition.function.parameters,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return openaiTool;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: model.modelId,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const choice = response.choices[0];
|
||||||
|
const endTime = Date.now();
|
||||||
|
const durationMs = endTime - startTime;
|
||||||
|
|
||||||
|
const toolCalls = choice.message.tool_calls
|
||||||
|
?.filter((tc) => tc.type === "function")
|
||||||
|
.map((tc) => ({
|
||||||
|
callId: tc.id,
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: tc.function.arguments,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!toolCalls || toolCalls.length === 0) {
|
||||||
|
return {
|
||||||
|
done: true,
|
||||||
|
response: choice.message.content || "",
|
||||||
|
thinking: undefined,
|
||||||
|
toolCalls: undefined,
|
||||||
|
toolCallResults: allToolCallResults.length > 0 ? allToolCallResults : undefined,
|
||||||
|
stats: {
|
||||||
|
duration: {
|
||||||
|
seconds: durationMs / 1000,
|
||||||
|
text: numeral(durationMs / 1000).format("hh:mm:ss"),
|
||||||
},
|
},
|
||||||
};
|
tokenCounts: {
|
||||||
return openaiTool;
|
input: response.usage?.prompt_tokens || 0,
|
||||||
})
|
response: response.usage?.completion_tokens || 0,
|
||||||
: [];
|
thinking: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.chat.completions.create({
|
const toolCallResults = await this.executeToolCalls(
|
||||||
model: model.modelId,
|
toolCalls,
|
||||||
messages,
|
options.tools || [],
|
||||||
tools,
|
);
|
||||||
stream: false,
|
allToolCallResults.push(...toolCallResults);
|
||||||
});
|
|
||||||
|
const assistantMsg: ChatCompletionAssistantMessageParam = {
|
||||||
|
role: "assistant",
|
||||||
|
content: choice.message.content,
|
||||||
|
};
|
||||||
|
if (choice.message.tool_calls) {
|
||||||
|
assistantMsg.tool_calls = choice.message.tool_calls;
|
||||||
|
}
|
||||||
|
messages.push(assistantMsg);
|
||||||
|
|
||||||
|
for (const result of toolCallResults) {
|
||||||
|
const toolMsg: ChatCompletionToolMessageParam = {
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: result.callId,
|
||||||
|
content: result.error || result.result,
|
||||||
|
};
|
||||||
|
messages.push(toolMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const choice = response.choices[0];
|
|
||||||
const endTime = Date.now();
|
const endTime = Date.now();
|
||||||
const durationMs = endTime - startTime;
|
const durationMs = endTime - startTime;
|
||||||
|
|
||||||
const toolCalls = choice.message.tool_calls
|
|
||||||
?.filter((tc) => tc.type === "function")
|
|
||||||
.map((tc) => ({
|
|
||||||
callId: tc.id,
|
|
||||||
function: {
|
|
||||||
name: tc.function.name,
|
|
||||||
arguments: tc.function.arguments,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
done: true,
|
done: false,
|
||||||
response: choice.message.content || "",
|
doneReason: "max_tool_iterations_reached",
|
||||||
|
response: "",
|
||||||
thinking: undefined,
|
thinking: undefined,
|
||||||
toolCalls,
|
toolCalls: undefined,
|
||||||
|
toolCallResults: allToolCallResults,
|
||||||
stats: {
|
stats: {
|
||||||
duration: {
|
duration: {
|
||||||
seconds: durationMs / 1000,
|
seconds: durationMs / 1000,
|
||||||
text: numeral(durationMs / 1000).format("hh:mm:ss"),
|
text: numeral(durationMs / 1000).format("hh:mm:ss"),
|
||||||
},
|
},
|
||||||
tokenCounts: {
|
tokenCounts: {
|
||||||
input: response.usage?.prompt_tokens || 0,
|
input: 0,
|
||||||
response: response.usage?.completion_tokens || 0,
|
response: 0,
|
||||||
thinking: 0,
|
thinking: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -165,14 +165,23 @@ export class GoogleSearchTool extends AiTool {
|
|||||||
query: string,
|
query: string,
|
||||||
options: ISearchOptions,
|
options: ISearchOptions,
|
||||||
): Promise<ISearchResult[]> {
|
): Promise<ISearchResult[]> {
|
||||||
|
const apiKey = this.toolbox.env.services?.google?.cse?.apiKey;
|
||||||
|
const engineId = this.toolbox.env.services?.google?.cse?.engineId;
|
||||||
|
|
||||||
|
if (!apiKey || !engineId) {
|
||||||
|
throw new Error(
|
||||||
|
"Google CSE credentials not configured in environment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const customSearch = google.customsearch({
|
const customSearch = google.customsearch({
|
||||||
version: "v1",
|
version: "v1",
|
||||||
auth: this.toolbox.env.google.cse.apiKey,
|
auth: apiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: any = {
|
const params: any = {
|
||||||
q: query,
|
q: query,
|
||||||
cx: this.toolbox.env.google.cse.engineId,
|
cx: engineId,
|
||||||
num: options.num || 10,
|
num: options.num || 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,9 +238,9 @@ export class GoogleSearchTool extends AiTool {
|
|||||||
const apiError = error.response.data.error;
|
const apiError = error.response.data.error;
|
||||||
const statusCode = error.response.status;
|
const statusCode = error.response.status;
|
||||||
|
|
||||||
switch (statusCode) {
|
switch (statusCode) {
|
||||||
case 401:
|
case 401:
|
||||||
formatError({
|
return formatError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`,
|
message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user