node-hash-tool contains a reference Typescript Node.js
implementation of the Hash
Tool.
This guide walks through the structure and design of the Tool and outlines the packaging requirements for Otto8
To clone this repo and follow along, run the following command:
git clone [email protected]:otto8-ai/node-hash-tool
The directory tree below highlights the files required to implement Hash
in Typescript and package it for Otto8
.
node-hash-tool
├── package-lock.json
├── package.json
├── tsconfig.json
├── tool.gpt
└── src
├── hash.ts
└── tools.ts
Note: The
tsconfig.json
file is only required for tools written in Typescript. It is not necessary for tools written in JavaScript.
The tool.gpt
file contains GPTScript Tool Definitions which describe a set of Tools that can be used by Agents in Otto8
.
Every Tool repository must have a tool.gpt
file in its root directory.
The Tools defined in this file must have a descriptive Name
and Description
that will help Agents understand what the Tool does, what it returns (if anything), and all the Parameters
it takes.
Agents use these details to infer a Tool's usage.
We call the section of a Tool definition that contains this info a Preamble
.
We want the Hash
Tool to return the hash of some given data
. It would also be nice to support a few different algorithms for the Agent to choose from.
Let's take a look at the Preamble
for Hash
to see how that's achieved:
Name: Hash
Description: Generate a hash of data using the given algorithm and return the result as a hexadecimal string
Param: data: The data to hash
Param: algo: The algorithm to generate a hash with. Supports "sha256" and "md5". Default is "sha256"
Breaking this down a bit:
- The
Preamble
above declares a Tool namedHash
. - The
Param
fields enumerate the arguments that an Agent must provide when callingHash
,data
andalgo
. - In this case, the description of the
algo
parameter outlines the valid options (sha256
ormd5
) and defines a default value (sha256
) - The
Description
explains whatHash
returns with respect to the given arguments; the hash ofdata
using the algorithm selected withalgo
.
Immediately below the Preamble
is the Tool Body
, which tells Otto8
how to execute the Tool:
#!/usr/bin/env npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- hash
This is where the magic happens.
To oversimplify, when an Agent calls the Hash
Tool, Otto8
reads this line and then:
- Downloads the appropriate
Node.js
tool chain (node
andnpm
) - Sets up a working directory for the Tool
- Installs the dependencies from the Tool's
package.json
andpackage-lock.json
- Projects the call arguments onto environment variables (
DATA
andALGO
) - Runs
npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- hash
.
Putting it all together, here's the complete definition of the Hash
Tool.
Name: Hash
Description: Generate a hash of data using the given algorithm and return the result as a hexadecimal string
Param: data: The data to hash
Param: algo: The algorithm to generate a hash with. Default is "sha256". Supports "sha256" and "md5".
#!/usr/bin/env npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- hash
The tool.gpt
file also provides the following metadata for use in Otto8
:
!metadata:*:category
which tags Tools with theCrypto
category to promote organization and discovery!metadata:*:icon
which assignshttps://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/duotone/fingerprint-duotone.svg
as the Tool icon
Note:
*
is a wild card pattern that applies the metadata to all Tools in thetool.gpt
file.
---
!metadata:*:category
Crypto
---
!metadata:*:icon
https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/duotone/fingerprint-duotone.svg
Note: Metadata can be applied to a specific Tool by either specifying the exact name (e.g. !metadata:Hash:category
) or by adding the metadata directly to a Tool's Preamble
Name: Hash
Metadata: category: Crypto
Metadata: icon: https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/duotone/fingerprint-duotone.svg
Complete tool.gpt
---
Name: Hash
Description: Generate a hash of data using the given algorithm and return the result as a hexadecimal string
Param: data: The data to hash
Param: algo: The algorithm to generate a hash with. Supports "sha256" and "md5". Default is "sha256"
#!/usr/bin/env npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- hash
---
!metadata:*:category
Crypto
---
!metadata:*:icon
https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/duotone/fingerprint-duotone.svg
As we saw earlier, the npm
command invoked by the Tool Body
passes hash
as an argument to the tool
script.
npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- hash
To figure out what this resolves to, let's inspect the tool
script defined in package.json
:
"scripts": {
"tool": "node --no-warnings --loader ts-node/esm src/tools.ts"
},
This means that when the Tool Body
is executed, the effective command that runs is:
node --no-warnings --loader ts-node/esm src/tools.ts hash
Note: The
--loader ts-node/esm
option, in conjunction with the contents oftsconfig.json
, is the "special sauce" that lets us run Typescript code directly without transpiling it to JavaScript first.
To summarize, when the Hash
Tool is called by an Agent, src/tools.ts
gets run with hash
as an argument.
Let's walk through the src/tools.ts
to understand what happens at runtime:
// ...
const cmd = process.argv[2]
try {
switch (cmd) {
case 'hash':
console.log(hash(process.env.DATA, process.env.ALGO))
break
default:
console.log(`Unknown command: ${cmd}`)
process.exit(1)
}
} catch (error) {
// Print the error to stdout so that it can be captured by the GPTScript
console.log(`${error}`)
process.exit(1)
}
This code implements a simple CLI that wraps business logic in a try/catch block and forwards any exceptions to stdout. Writing errors to stdout instead of stderr is crucial because only stdout is returned to the Agent, while sdterr is discarded.
Note: The simple CLI pattern showcased above is also easily extensible; adding business logic for new tools becomes a matter of adding a new case to the switch
statement.
For example, if we wanted to add a new Tool to verify a given hash, we'd add a verify
case:
switch (cmd) {
case 'verify':
console.log(verify(process.env.HASH, process.env.DATA, process.env.ALGO))
break
case 'hash':
// ...
default:
// ...
}
And the Body of the Verify
Tool would pass verify
to the tool
script instead of hash
:
Name: Verify
# ...
#!/usr/bin/env npm --silent --prefix ${GPTSCRIPT_TOOL_DIR} run tool -- verify
When "hash"
is passed as an argument, the code extracts the data
and algo
Tool arguments from the respective environment variables, then passes them to the hash
function.
The hash
function is where the bulk of the business logic is implemented.
import { createHash } from 'node:hash';
const SUPPORTED_ALGORITHMS = ['sha256', 'md5'];
export function hash(data: string = '', algo = 'sha256'): string {
if (data === '') {
throw new Error("A non-empty data argument must be provided");
}
if (!SUPPORTED_ALGORITHMS.includes(algo)) {
throw new Error(`Unsupported hash algorithm ${algo} not in [${SUPPORTED_ALGORITHMS.join(', ')}]`);
}
return JSON.stringify({
algo,
hash: createHash(algo).update(data).digest('hex'),
});
}
It starts off by validating the data
and algo
arguments.
When an argument is invalid, the function throws an exception that describes the validation issue in detail.
The goal is to provide useful information that an Agent can use to construct valid arguments for future calls.
For example, when an invalid algo
argument is provided, the code returns an error that contains the complete list of valid algorithms.
Once it determines that all of the arguments are valid, it calculates the hash and writes a JSON object to stdout. This object contains the hash and the algorithm used to generate it.
// ...
return JSON.stringify({
algo,
hash: createHash(algo).update(data).digest('hex'),
});
Note: Producing structured data with extra contextual info (e.g. the algorithm) is considered good form. It's a pattern that improves the Agent's ability to correctly use the Tool's result over time.
Complete package.json
, src/tools.ts
, and src/hash.ts
{
"type": "module",
"scripts": {
"tool": "node --no-warnings --loader ts-node/esm src/tools.ts"
},
"dependencies": {
"@types/node": "^20.16.11",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"devDependencies": {}
}
// src/tools.ts
import { hash } from './hash.ts'
if (process.argv.length !== 3) {
console.error('Usage: node tool.ts <command>')
process.exit(1)
}
const cmd = process.argv[2]
try {
switch (cmd) {
case 'hash':
console.log(hash(process.env.DATA, process.env.ALGO))
break
default:
console.log(`Unknown command: ${cmd}`)
process.exit(1)
}
} catch (error) {
// Print the error to stdout so that it can be captured by the GPTScript
console.log(`${error}`)
process.exit(1)
}
// src/hash.ts
import { createHash } from 'node:hash';
const SUPPORTED_ALGORITHMS = ['sha256', 'md5'];
export function hash(data: string = '', algo = 'sha256'): string {
if (data === '') {
throw new Error("A non-empty data argument must be provided");
}
if (!SUPPORTED_ALGORITHMS.includes(algo)) {
throw new Error(`Unsupported hash algorithm ${algo} not in [${SUPPORTED_ALGORITHMS.join(', ')}]`);
}
return JSON.stringify({
algo,
hash: createHash(algo).update(data).digest('hex'),
});
}
Before adding a Tool to Otto8
, verify that the Typescript business logic works on your machine.
To do this, run through the following steps in the root of your local fork:
-
Install dependencies
npm install
-
Run the Tool with some test arguments:
Command Output DATA='foo' npm run tool hash
{ "algo": "sha256", "hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae" }
npm run tool hash
Error: A data argument must be provided
DATA='foo' ALGO='md5' npm run tool hash
{ "algo": "md5", "hash": "acbd18db4cc2f85cedef654fccc4a4d8" }
DATA='foo' ALGO='whirlpool' npm run tool hash
Error: Unsupported hash algorithm: whirlpool not in ['sha256', 'md5']
Before a Tool can be used by an Agent, an admin must first add the Tool to Otto8
by performing the steps below:
To use the Hash
Tool in an Agent, open the Agent's Edit page, then: