Skip to content

Commit 6f5675d

Browse files
committed
feat: Add Tool and update specs
1 parent c345881 commit 6f5675d

File tree

10 files changed

+1065
-0
lines changed

10 files changed

+1065
-0
lines changed

.github/workflows/ci.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Run tests
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
workflow_dispatch:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: pnpm/action-setup@v4
14+
with:
15+
version: 9
16+
ref: ${{ github.event.pull_request.head.ref }}
17+
repository: ${{ github.event.pull_request.head.repo.full_name }}
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version: 20
21+
cache: 'pnpm'
22+
- name: Install pnpm dependencies
23+
run: pnpm i
24+
25+
- name: Run tests
26+
run: pnpm test
27+
28+
- name: Upload coverage reports
29+
uses: codecov/codecov-action@v4
30+
with:
31+
token: ${{ secrets.CODECOV_TOKEN }}
32+
file: ./coverage/coverage-final.json
33+
fail_ci_if_error: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,5 @@ dist
128128
.yarn/build-state.yml
129129
.yarn/install-state.gz
130130
.pnp.*
131+
132+
.turbo

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"build": "turbo build",
66
"dev": "turbo dev",
77
"lint": "turbo lint",
8+
"test": "turbo test",
89
"format": "prettier --write \"**/*.{ts,tsx,md}\""
910
},
1011
"devDependencies": {

packages/core/errors/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export class AgentError extends Error {
2+
constructor(message: string, public readonly code: string) {
3+
super(message);
4+
this.name = this.constructor.name;
5+
}
6+
}
7+
8+
export class InvalidImplementationError extends AgentError {
9+
constructor(message: string) {
10+
super(message, 'INVALID_IMPLEMENTATION');
11+
}
12+
}
13+
14+
export class InvalidSecretsError extends AgentError {
15+
constructor(message: string) {
16+
super(message, 'INVALID_SECRETS');
17+
}
18+
}
19+
20+
export class ExecutionError extends AgentError {
21+
constructor(message: string) {
22+
super(message, 'EXECUTION_ERROR');
23+
}
24+
}

packages/core/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@neuron.js/core",
3+
"version": "0.0.0",
4+
"main": "index.js",
5+
"scripts": {
6+
"test": "vitest --no-watch --no-cache"
7+
},
8+
"devDependencies": {
9+
"vitest": "^2.1.8"
10+
}
11+
}

packages/core/tool/index.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, test, expect, vi, beforeEach } from 'vitest';
2+
import { Tool } from './index';
3+
import { ExecutionError, InvalidImplementationError, InvalidSecretsError } from '../errors';
4+
import { FunctionInput } from '../types';
5+
6+
describe('Tool', () => {
7+
let validConfig: {
8+
properties?: Record<string, FunctionInput>;
9+
secrets?: string[];
10+
};
11+
12+
beforeEach(() => {
13+
validConfig = {
14+
properties: {
15+
requiredProp: { type: 'string', description: 'This is a required prop', required: true },
16+
optionalProp: { type: 'string', description: 'This is an optional prop', required: false }
17+
},
18+
secrets: ['apiKey']
19+
};
20+
});
21+
22+
describe('constructor', () => {
23+
test('creates a valid tool with all required properties', () => {
24+
const tool = new Tool('testTool', 'Test description', validConfig);
25+
expect(tool.name).toBe('testTool');
26+
expect(tool.description).toBe('Test description');
27+
expect(tool.config).toEqual(validConfig);
28+
});
29+
30+
test('throws error when missing required properties', () => {
31+
expect(() => {
32+
new Tool('', 'Test description', validConfig);
33+
}).toThrow(InvalidImplementationError);
34+
35+
expect(() => {
36+
// @ts-expect-error Testing invalid constructor params
37+
new Tool('Test Tool', 'Test description', null);
38+
}).toThrow(InvalidImplementationError);
39+
});
40+
});
41+
42+
43+
describe('execute', () => {
44+
test('successfully executes a registered function with valid input and secrets', async () => {
45+
const implementation = vi.fn().mockReturnValue('success');
46+
const tool = new Tool('testTool', 'Test description', validConfig);
47+
tool.registerFunction(implementation);
48+
49+
const result = await tool.execute(
50+
{ requiredProp: 'value' },
51+
{ apiKey: 'test-key' }
52+
);
53+
54+
expect(result).toBe('success');
55+
expect(implementation).toHaveBeenCalledWith(
56+
{ requiredProp: 'value' },
57+
{ apiKey: 'test-key' }
58+
);
59+
});
60+
61+
test('throws ExecutionError when no implementation is registered', async () => {
62+
const tool = new Tool('testTool', 'Test description', validConfig);
63+
64+
await expect(tool.execute(
65+
{ requiredProp: 'value' },
66+
{ apiKey: 'test-key' }
67+
)).rejects.toThrow(ExecutionError);
68+
});
69+
70+
test('throws InvalidSecretsError when required secrets are missing', async () => {
71+
const implementation = vi.fn();
72+
const tool = new Tool('testTool', 'Test description', validConfig, implementation);
73+
74+
await expect(tool.execute(
75+
{ requiredProp: 'value' },
76+
{}
77+
)).rejects.toThrow(InvalidSecretsError);
78+
});
79+
80+
test('throws InvalidImplementationError when required properties are missing', async () => {
81+
const implementation = vi.fn();
82+
const tool = new Tool('testTool', 'Test description', validConfig, implementation);
83+
84+
await expect(tool.execute(
85+
{ optionalProp: 'value' },
86+
{ apiKey: 'test-key' }
87+
)).rejects.toThrow(InvalidImplementationError);
88+
});
89+
90+
test('handles implementation throwing an error', async () => {
91+
const implementation = vi.fn().mockImplementation(() => {
92+
throw new Error('Implementation error');
93+
});
94+
const tool = new Tool('testTool', 'Test description', validConfig, implementation);
95+
96+
await expect(tool.execute(
97+
{ requiredProp: 'value' },
98+
{ apiKey: 'test-key' }
99+
)).rejects.toThrow(ExecutionError);
100+
});
101+
102+
test('works with no properties configured', async () => {
103+
const implementation = vi.fn().mockReturnValue('success');
104+
const tool = new Tool('testTool', 'Test description', {
105+
secrets: ['apiKey']
106+
}, implementation);
107+
108+
const result = await tool.execute(
109+
{},
110+
{ apiKey: 'test-key' }
111+
);
112+
113+
expect(result).toBe('success');
114+
});
115+
116+
test('works with no secrets configured', async () => {
117+
const implementation = vi.fn().mockReturnValue('success');
118+
const tool = new Tool('testTool', 'Test description', {
119+
properties: {
120+
requiredProp: { type: 'string', description: 'This is a required prop', required: true }
121+
}
122+
}, implementation);
123+
124+
const result = await tool.execute(
125+
{ requiredProp: 'value' }
126+
);
127+
128+
expect(result).toBe('success');
129+
});
130+
});
131+
});

packages/core/tool/index.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ExecutionError, InvalidImplementationError, InvalidSecretsError } from '../errors'
2+
import { FunctionInput } from "../types";
3+
4+
export class Tool {
5+
private static readonly REQUIRED_PROPERTIES = ['name', 'description', 'config'] as const;
6+
7+
constructor(
8+
readonly name: string,
9+
readonly description: string,
10+
readonly config: {
11+
properties?: Record<string, FunctionInput>,
12+
secrets?: string[],
13+
},
14+
private implementation?: (input: Record<string, unknown>, secrets: Record<string, unknown>) => void
15+
) {
16+
this.validateConfig();
17+
}
18+
19+
registerFunction(implementation: typeof this.implementation): void {
20+
this.implementation = implementation;
21+
}
22+
23+
async execute(input: Record<string, unknown>, providedSecrets: Record<string, unknown> = {}): Promise<unknown> {
24+
this.validateSecrets(providedSecrets);
25+
this.validateInput(input);
26+
27+
if (!this.implementation) {
28+
throw new ExecutionError("No implementation registered");
29+
}
30+
31+
try {
32+
return this.implementation(input, providedSecrets);
33+
} catch (error) {
34+
throw new ExecutionError(`Execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
35+
}
36+
}
37+
38+
private validateConfig(): void {
39+
const configKeys = [this.name, this.description, this.config];
40+
const missingKeys = Tool.REQUIRED_PROPERTIES.filter((_, index) => !configKeys[index]);
41+
42+
if (missingKeys.length) {
43+
throw new InvalidImplementationError(`Missing required properties: ${missingKeys.join(', ')}`);
44+
}
45+
}
46+
47+
private validateSecrets(providedSecrets: Record<string, unknown>): void {
48+
if (!this.config.secrets) {
49+
return;
50+
}
51+
52+
const missingSecrets = this.config.secrets.filter(secret => !(secret in providedSecrets));
53+
if (missingSecrets.length) {
54+
throw new InvalidSecretsError(`Missing required secrets: ${missingSecrets.join(', ')}`);
55+
}
56+
}
57+
58+
private validateInput(input: Record<string, unknown>): void {
59+
if (!this.config.properties) {
60+
return;
61+
}
62+
63+
Object.entries(this.config.properties).forEach(([property, details]) => {
64+
if (details.required && !(property in input)) {
65+
throw new InvalidImplementationError(`Missing required property: ${property}`);
66+
}
67+
});
68+
}
69+
}

packages/core/types/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export type FunctionInput = {
2+
type: string;
3+
description: string;
4+
required?: boolean;
5+
};

0 commit comments

Comments
 (0)