ai16zのCrypto AI Agent開発フレームワーク「Eliza」でETHを送金してみる
こんにちは!Web3特化の開発会社 Komlock lab CTOの山口なつきです。
前回の記事では、Crypto AIエージェントの開発フレームワークであるElizaのアーキテクチャやコアコンセプトの解説を行いました。
今回は、実際にAIエージェント通じてブロックチェーン上のトークンを送信してみたいと思います。
最終的な目標は、pluginを利用してElizaの機能を拡張することです。
これを達成すると、開発したエージェントがミームコインのプラットフォームであるpump.funでミームコインをミントしたり、購入したりできるようになります。
はじめに(定期)
最近「Crypto x AI Agent」が話題ですが、僕はブロックチェーン上での決済機能を持つAIエージェントに、DeFiに匹敵する可能性を感じています。今後はこの分野での情報発信を強化し、社内のメンバーとも共有していく予定です。興味のある方はぜひフォローしてください。
Crypto AI Agent 初めて聞いた!という方は、miinさんの記事を読むことをお勧めします。2024年12月時点のCrypto X AI Agentのトレンドやユースケースを理解することができます。
送金Actionの実装
前提
前回の記事でも触れましたが、Elizaはまだ発展途上のプロジェクトな為、plugin間のバージョン互換性問題があったりpluginによっては、設計の問題で拡張が難しかったりします。
その課題を今回は力技で解決している部分もあるので、参考程度に見て頂ければと思います。
もしより良いアプローチがあれば是非コメントお願いします。
plugin-goat
今回はウォレットの操作にplugin-goatというElizaのpluginを使います。plugin-evmやplugin-solanaなども選択肢としてありましたが、より汎用的で多機能なgoatを採用しました。
*plugin-solanaはBirdeyeというサービスのAPIを利用する必要があり、上手くいかなかった経緯もあります。
GOATについて
GOAT 🐐(Great Onchain Agent Toolkit)は、AIエージェントにウォレットの追加、トークンの保有・取引、ブロックチェーンのスマートコントラクトとのやり取りなどのブロックチェーンツールを組み込むためのオープンソースフレームワークです。MITライセンスのフリーソフトウェアであり、Crossmintがスポンサーとなっています。
今回の要件にぴったりなツールだと思いました。
参考:Crossmint公式
導入手順
eliza-starterをcloneする
- 注意点:2024/12/26時点では、全体的にバージョンが古いのでissueを確認しながら、このフォークを利用すると良い。(ハマりポイント①)
- issue: https://github.com/elizaOS/eliza-starter/pull/20
- repository: https://github.com/noahgsolomon/eliza-starter/tree/upgrade/eliza-0.1.6-alpha.4
plugin-goatを導入する
- pnpm add @ai16z/plugin-goat しても良いですが、chainの情報などがベタ書きされていたりして不便(ハマりポイント②)なので、elizaのフルリポジトリからplugin-goatのsubmoduleをコピペしてくる方法を採用しました。
- 今回は、/packages/@ai16z/plugin-goat のパスに設置しています。
- そのままの設定だとpackage管理が本体とは別でplugin-goat以下でも独立して行われるので、あまり良い状態ではないですが将来解決される問題と思うので、そのまま進みます。。。
plugin-goatを調整する
wallet.tsに利用するチェーンが定義されています。
defaultだとbaseですが、sepoliaBaseに変更しました。
変更した際は、必ずpnpm buildして最新のコードが読み込まれるようにしましょう。
「Error: error occurred in dts build」のようなエラーが出る可能性ありますが、「ESM ⚡️ Build success」が表示されていれば無視して大丈夫です。(ハマりポイント③)
import { WalletClient } from "@goat-sdk/core";
import { viem } from "@goat-sdk/wallet-viem";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
// Add the chain you want to use, remember to update also
// the EVM_PROVIDER_URL to the correct one for the chain
export const chain = baseSepolia;
export function getWalletClient(
getSetting: (key: string) => string | undefined
) {
const privateKey = getSetting("EVM_PRIVATE_KEY");
if (!privateKey) return null;
console.log("privateKey:", privateKey);
const provider = getSetting("EVM_PROVIDER_URL");
if (!provider) throw new Error("EVM_PROVIDER_URL not configured");
console.log("provider:", provider);
const wallet = createWalletClient({
account: privateKeyToAccount(privateKey as `0x${string}`),
chain: chain,
transport: http(provider),
});
return viem(wallet);
}
export function getWalletProvider(walletClient: WalletClient) {
return {
async get(): Promise<string | null> {
try {
const address = walletClient.getAddress();
const balance = await walletClient.balanceOf(address);
return `EVM Wallet Address: ${address}\nBalance: ${balance} ETH`;
} catch (error) {
console.error("Error in EVM wallet provider:", error);
return null;
}
},
};
}
必要に応じてcharacter.tsを設定
今回は、tate.character.jsonを利用しています。
modelによっては、正常にpluginが機能しないことがあります。
openrouterだとエラーになるので、openaiを利用しました。(ハマりポイント④)
"modelProvider": "openai",
必要な環境変数の設定
EVM_PRIVATE_KEY=秘密鍵
EVM_PROVIDER_URL=https://sepolia.base.org
OPENAI_API_KEY=sk-xxxx
agent/index.tsでplugin-goatを有効化する
どう書けば良いのか迷った場合、フルリポジトリが参考になります。
先ほどコピペしたplugin-goatディレクトリを参照していることも重要です。
import createGoatPlugin from "../packages/@ai16z/plugin-goat/dist/index.js";
import { PostgresDatabaseAdapter } from "@ai16z/adapter-postgres";
import { SqliteDatabaseAdapter } from "@ai16z/adapter-sqlite";
import { DirectClientInterface } from "@ai16z/client-direct";
import { DiscordClientInterface } from "@ai16z/client-discord";
import { AutoClientInterface } from "@ai16z/client-auto";
import { TelegramClientInterface } from "@ai16z/client-telegram";
import { TwitterClientInterface } from "@ai16z/client-twitter";
import {
DbCacheAdapter,
defaultCharacter,
FsCacheAdapter,
ICacheManager,
IDatabaseCacheAdapter,
stringToUuid,
AgentRuntime,
CacheManager,
Character,
IAgentRuntime,
ModelProviderName,
elizaLogger,
settings,
IDatabaseAdapter,
validateCharacterConfig,
} from "@ai16z/eliza";
import { bootstrapPlugin } from "@ai16z/plugin-bootstrap";
import { solanaPlugin } from "@ai16z/plugin-solana";
import createGoatPlugin from "../packages/@ai16z/plugin-goat/dist/index.js";
import { nodePlugin } from "@ai16z/plugin-node";
import Database from "better-sqlite3";
import fs from "fs";
import readline from "readline";
import yargs from "yargs";
import path from "path";
import { fileURLToPath } from "url";
import { character } from "./character.ts";
import type { DirectClient } from "@ai16z/client-direct";
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory
export const wait = (minTime: number = 1000, maxTime: number = 3000) => {
const waitTime =
Math.floor(Math.random() * (maxTime - minTime + 1)) + minTime;
return new Promise((resolve) => setTimeout(resolve, waitTime));
};
export function parseArguments(): {
character?: string;
characters?: string;
} {
try {
return yargs(process.argv.slice(2))
.option("character", {
type: "string",
description: "Path to the character JSON file",
})
.option("characters", {
type: "string",
description: "Comma separated list of paths to character JSON files",
})
.parseSync();
} catch (error) {
console.error("Error parsing arguments:", error);
return {};
}
}
export async function loadCharacters(
charactersArg: string
): Promise<Character[]> {
let characterPaths = charactersArg?.split(",").map((filePath) => {
if (path.basename(filePath) === filePath) {
filePath = "../characters/" + filePath;
}
return path.resolve(process.cwd(), filePath.trim());
});
const loadedCharacters = [];
if (characterPaths?.length > 0) {
for (const path of characterPaths) {
try {
const character = JSON.parse(fs.readFileSync(path, "utf8"));
validateCharacterConfig(character);
loadedCharacters.push(character);
} catch (e) {
console.error(`Error loading character from ${path}: ${e}`);
// don't continue to load if a specified file is not found
process.exit(1);
}
}
}
if (loadedCharacters.length === 0) {
console.log("No characters found, using default character");
loadedCharacters.push(defaultCharacter);
}
return loadedCharacters;
}
export function getTokenForProvider(
provider: ModelProviderName,
character: Character
) {
switch (provider) {
case ModelProviderName.OPENAI:
return (
character.settings?.secrets?.OPENAI_API_KEY || settings.OPENAI_API_KEY
);
case ModelProviderName.LLAMACLOUD:
return (
character.settings?.secrets?.LLAMACLOUD_API_KEY ||
settings.LLAMACLOUD_API_KEY ||
character.settings?.secrets?.TOGETHER_API_KEY ||
settings.TOGETHER_API_KEY ||
character.settings?.secrets?.XAI_API_KEY ||
settings.XAI_API_KEY ||
character.settings?.secrets?.OPENAI_API_KEY ||
settings.OPENAI_API_KEY
);
case ModelProviderName.ANTHROPIC:
return (
character.settings?.secrets?.ANTHROPIC_API_KEY ||
character.settings?.secrets?.CLAUDE_API_KEY ||
settings.ANTHROPIC_API_KEY ||
settings.CLAUDE_API_KEY
);
case ModelProviderName.REDPILL:
return (
character.settings?.secrets?.REDPILL_API_KEY || settings.REDPILL_API_KEY
);
case ModelProviderName.OPENROUTER:
return (
character.settings?.secrets?.OPENROUTER || settings.OPENROUTER_API_KEY
);
case ModelProviderName.GROK:
return character.settings?.secrets?.GROK_API_KEY || settings.GROK_API_KEY;
case ModelProviderName.HEURIST:
return (
character.settings?.secrets?.HEURIST_API_KEY || settings.HEURIST_API_KEY
);
case ModelProviderName.GROQ:
return character.settings?.secrets?.GROQ_API_KEY || settings.GROQ_API_KEY;
}
}
function initializeDatabase(dataDir: string) {
if (process.env.POSTGRES_URL) {
const db = new PostgresDatabaseAdapter({
connectionString: process.env.POSTGRES_URL,
});
return db;
} else {
const filePath =
process.env.SQLITE_FILE ?? path.resolve(dataDir, "db.sqlite");
// ":memory:";
const db = new SqliteDatabaseAdapter(new Database(filePath));
return db;
}
}
export async function initializeClients(
character: Character,
runtime: IAgentRuntime
) {
const clients = [];
const clientTypes = character.clients?.map((str) => str.toLowerCase()) || [];
if (clientTypes.includes("auto")) {
const autoClient = await AutoClientInterface.start(runtime);
if (autoClient) clients.push(autoClient);
}
if (clientTypes.includes("discord")) {
clients.push(await DiscordClientInterface.start(runtime));
}
if (clientTypes.includes("telegram")) {
const telegramClient = await TelegramClientInterface.start(runtime);
if (telegramClient) clients.push(telegramClient);
}
if (clientTypes.includes("twitter")) {
const twitterClients = await TwitterClientInterface.start(runtime);
clients.push(twitterClients);
}
if (character.plugins?.length > 0) {
for (const plugin of character.plugins) {
if (plugin.clients) {
for (const client of plugin.clients) {
clients.push(await client.start(runtime));
}
}
}
}
return clients;
}
function getSecret(character: Character, secret: string) {
return character.settings.secrets?.[secret] || process.env[secret];
}
export async function createAgent(
character: Character,
db: IDatabaseAdapter,
cache: ICacheManager,
token: string
) {
elizaLogger.success(
elizaLogger.successesTitle,
"Creating runtime for character",
character.name
);
let goatPlugin: any | undefined;
if (getSecret(character, "EVM_PROVIDER_URL")) {
goatPlugin = await createGoatPlugin((secret) =>
getSecret(character, secret)
);
}
return new AgentRuntime({
databaseAdapter: db,
token,
modelProvider: character.modelProvider,
evaluators: [],
character,
plugins: [
bootstrapPlugin,
nodePlugin,
goatPlugin,
character.settings.secrets?.WALLET_PUBLIC_KEY ? solanaPlugin : null,
].filter(Boolean),
providers: [],
actions: [],
services: [],
managers: [],
cacheManager: cache,
});
}
function intializeFsCache(baseDir: string, character: Character) {
const cacheDir = path.resolve(baseDir, character.id, "cache");
const cache = new CacheManager(new FsCacheAdapter(cacheDir));
return cache;
}
function intializeDbCache(character: Character, db: IDatabaseCacheAdapter) {
const cache = new CacheManager(new DbCacheAdapter(db, character.id));
return cache;
}
async function startAgent(character: Character, directClient) {
let db: IDatabaseAdapter & IDatabaseCacheAdapter;
try {
character.id ??= stringToUuid(character.name);
character.username ??= character.name;
const token = getTokenForProvider(character.modelProvider, character);
const dataDir = path.join(__dirname, "../data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
db = initializeDatabase(dataDir) as IDatabaseAdapter &
IDatabaseCacheAdapter;
await db.init();
const cache = intializeDbCache(character, db);
const runtime = await createAgent(character, db, cache, token);
await runtime.initialize();
const clients = await initializeClients(character, runtime);
directClient.registerAgent(runtime);
return clients;
} catch (error) {
elizaLogger.error(
`Error starting agent for character ${character.name}:`,
error
);
console.error(error);
// if (db) {
// await db.close();
// }
throw error;
}
}
const startAgents = async () => {
const directClient = await DirectClientInterface.start();
const args = parseArguments();
let charactersArg = args.characters || args.character;
let characters = [character];
console.log("charactersArg", charactersArg);
if (charactersArg) {
characters = await loadCharacters(charactersArg);
}
console.log("characters", characters);
try {
for (const character of characters) {
await startAgent(character, directClient as DirectClient);
}
} catch (error) {
elizaLogger.error("Error starting agents:", error);
}
function chat() {
const agentId = characters[0].name ?? "Agent";
rl.question("You: ", async (input) => {
await handleUserInput(input, agentId);
if (input.toLowerCase() !== "exit") {
chat(); // Loop back to ask another question
}
});
}
elizaLogger.log("Chat started. Type 'exit' to quit.");
chat();
};
startAgents().catch((error) => {
elizaLogger.error("Unhandled error in startAgents:", error);
process.exit(1); // Exit the process after logging
});
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on("SIGINT", () => {
rl.close();
process.exit(0);
});
async function handleUserInput(input, agentId) {
if (input.toLowerCase() === "exit") {
rl.close();
process.exit(0);
return;
}
try {
const serverPort = parseInt(settings.SERVER_PORT || "3000");
const response = await fetch(
`http://localhost:${serverPort}/${agentId}/message`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: input,
userId: "user",
userName: "User",
}),
}
);
const data = await response.json();
data.forEach((message) => console.log(`${"Agent"}: ${message.text}`));
} catch (error) {
console.error("Error fetching response:", error);
}
}
ハマりポイント振り返り
- eliza-starterのバージョンが古い
最新バージョンを利用しているフォークを使う。 - pluginのカスタマイズが不便
別ディレクトリで管理。将来解決した際にnode_modulesから利用。 - pnpm buildでエラーが出る
無視しても大丈夫。 - modelによってpluginと互換性がない
openrouterだとエラーになるので、openaiを利用。
動作確認
pnpm start --characters="characters/tate.character.json"
ターミナル上からAIに話しかけてみます。
YOU:can you send 0.0001eth to 0xfE6966251577DBe811e3662208732F836Ab72BCA
実行結果!!ジガチイサイ
無事0.0001ethをbase sepoliaで送信することができました!
次回は、独自のpluginを開発したり、別のオンチェーン上のアクションを実装してみたいなと思っています。まだ手探りでの開発が続いているので、アドバイスある方いれば是非コメントお願いします。
Komlock lab もくもく会&LT会
web3開発関連のイベントを定期開催しています!
是非チェックしてください。
Discordでも有益な記事の共有や開発の相談など行っています。
どなたでもウェルカムです🔥
Komlock lab エンジニア募集中
Web3の未来を共創していきたいメンバーを募集しています!!
気軽にDM等でお声がけください。
個人アカウント
Komlock labの企業アカウント
PR記事とCEOの創業ブログ
Discussion