Skip to content

Commit

Permalink
feat: Add Tool and update specs
Browse files Browse the repository at this point in the history
  • Loading branch information
pranavrajs committed Dec 8, 2024
1 parent c345881 commit 6f5675d
Show file tree
Hide file tree
Showing 10 changed files with 1,065 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Run tests
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i

- name: Run tests
run: pnpm test

- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage/coverage-final.json
fail_ci_if_error: true
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,5 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

.turbo
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
Expand Down
24 changes: 24 additions & 0 deletions packages/core/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export class AgentError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
this.name = this.constructor.name;
}
}

export class InvalidImplementationError extends AgentError {
constructor(message: string) {
super(message, 'INVALID_IMPLEMENTATION');
}
}

export class InvalidSecretsError extends AgentError {
constructor(message: string) {
super(message, 'INVALID_SECRETS');
}
}

export class ExecutionError extends AgentError {
constructor(message: string) {
super(message, 'EXECUTION_ERROR');
}
}
11 changes: 11 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@neuron.js/core",
"version": "0.0.0",
"main": "index.js",
"scripts": {
"test": "vitest --no-watch --no-cache"
},
"devDependencies": {
"vitest": "^2.1.8"
}
}
131 changes: 131 additions & 0 deletions packages/core/tool/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { Tool } from './index';
import { ExecutionError, InvalidImplementationError, InvalidSecretsError } from '../errors';
import { FunctionInput } from '../types';

describe('Tool', () => {
let validConfig: {
properties?: Record<string, FunctionInput>;
secrets?: string[];
};

beforeEach(() => {
validConfig = {
properties: {
requiredProp: { type: 'string', description: 'This is a required prop', required: true },
optionalProp: { type: 'string', description: 'This is an optional prop', required: false }
},
secrets: ['apiKey']
};
});

describe('constructor', () => {
test('creates a valid tool with all required properties', () => {
const tool = new Tool('testTool', 'Test description', validConfig);
expect(tool.name).toBe('testTool');
expect(tool.description).toBe('Test description');
expect(tool.config).toEqual(validConfig);
});

test('throws error when missing required properties', () => {
expect(() => {
new Tool('', 'Test description', validConfig);
}).toThrow(InvalidImplementationError);

expect(() => {
// @ts-expect-error Testing invalid constructor params
new Tool('Test Tool', 'Test description', null);
}).toThrow(InvalidImplementationError);
});
});


describe('execute', () => {
test('successfully executes a registered function with valid input and secrets', async () => {
const implementation = vi.fn().mockReturnValue('success');
const tool = new Tool('testTool', 'Test description', validConfig);
tool.registerFunction(implementation);

const result = await tool.execute(
{ requiredProp: 'value' },
{ apiKey: 'test-key' }
);

expect(result).toBe('success');
expect(implementation).toHaveBeenCalledWith(
{ requiredProp: 'value' },
{ apiKey: 'test-key' }
);
});

test('throws ExecutionError when no implementation is registered', async () => {
const tool = new Tool('testTool', 'Test description', validConfig);

await expect(tool.execute(
{ requiredProp: 'value' },
{ apiKey: 'test-key' }
)).rejects.toThrow(ExecutionError);
});

test('throws InvalidSecretsError when required secrets are missing', async () => {
const implementation = vi.fn();
const tool = new Tool('testTool', 'Test description', validConfig, implementation);

await expect(tool.execute(
{ requiredProp: 'value' },
{}
)).rejects.toThrow(InvalidSecretsError);
});

test('throws InvalidImplementationError when required properties are missing', async () => {
const implementation = vi.fn();
const tool = new Tool('testTool', 'Test description', validConfig, implementation);

await expect(tool.execute(
{ optionalProp: 'value' },
{ apiKey: 'test-key' }
)).rejects.toThrow(InvalidImplementationError);
});

test('handles implementation throwing an error', async () => {
const implementation = vi.fn().mockImplementation(() => {
throw new Error('Implementation error');
});
const tool = new Tool('testTool', 'Test description', validConfig, implementation);

await expect(tool.execute(
{ requiredProp: 'value' },
{ apiKey: 'test-key' }
)).rejects.toThrow(ExecutionError);
});

test('works with no properties configured', async () => {
const implementation = vi.fn().mockReturnValue('success');
const tool = new Tool('testTool', 'Test description', {
secrets: ['apiKey']
}, implementation);

const result = await tool.execute(
{},
{ apiKey: 'test-key' }
);

expect(result).toBe('success');
});

test('works with no secrets configured', async () => {
const implementation = vi.fn().mockReturnValue('success');
const tool = new Tool('testTool', 'Test description', {
properties: {
requiredProp: { type: 'string', description: 'This is a required prop', required: true }
}
}, implementation);

const result = await tool.execute(
{ requiredProp: 'value' }
);

expect(result).toBe('success');
});
});
});
69 changes: 69 additions & 0 deletions packages/core/tool/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ExecutionError, InvalidImplementationError, InvalidSecretsError } from '../errors'
import { FunctionInput } from "../types";

export class Tool {
private static readonly REQUIRED_PROPERTIES = ['name', 'description', 'config'] as const;

constructor(
readonly name: string,
readonly description: string,
readonly config: {
properties?: Record<string, FunctionInput>,
secrets?: string[],
},
private implementation?: (input: Record<string, unknown>, secrets: Record<string, unknown>) => void
) {
this.validateConfig();
}

registerFunction(implementation: typeof this.implementation): void {
this.implementation = implementation;
}

async execute(input: Record<string, unknown>, providedSecrets: Record<string, unknown> = {}): Promise<unknown> {
this.validateSecrets(providedSecrets);
this.validateInput(input);

if (!this.implementation) {
throw new ExecutionError("No implementation registered");
}

try {
return this.implementation(input, providedSecrets);
} catch (error) {
throw new ExecutionError(`Execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}

private validateConfig(): void {
const configKeys = [this.name, this.description, this.config];
const missingKeys = Tool.REQUIRED_PROPERTIES.filter((_, index) => !configKeys[index]);

if (missingKeys.length) {
throw new InvalidImplementationError(`Missing required properties: ${missingKeys.join(', ')}`);
}
}

private validateSecrets(providedSecrets: Record<string, unknown>): void {
if (!this.config.secrets) {
return;
}

const missingSecrets = this.config.secrets.filter(secret => !(secret in providedSecrets));
if (missingSecrets.length) {
throw new InvalidSecretsError(`Missing required secrets: ${missingSecrets.join(', ')}`);
}
}

private validateInput(input: Record<string, unknown>): void {
if (!this.config.properties) {
return;
}

Object.entries(this.config.properties).forEach(([property, details]) => {
if (details.required && !(property in input)) {
throw new InvalidImplementationError(`Missing required property: ${property}`);
}
});
}
}
5 changes: 5 additions & 0 deletions packages/core/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type FunctionInput = {
type: string;
description: string;
required?: boolean;
};
Loading

0 comments on commit 6f5675d

Please sign in to comment.