💸

ai16zのCrypto AI Agent開発フレームワーク「Eliza」でETHを送金してみる

2024/12/26に公開

こんにちは!Web3特化の開発会社 Komlock lab CTOの山口なつきです。
前回の記事では、Crypto AIエージェントの開発フレームワークであるElizaのアーキテクチャやコアコンセプトの解説を行いました。
https://zenn.dev/komlock_lab/articles/345ec2597b3484

今回は、実際にAIエージェント通じてブロックチェーン上のトークンを送信してみたいと思います。
最終的な目標は、pluginを利用してElizaの機能を拡張することです。
これを達成すると、開発したエージェントがミームコインのプラットフォームであるpump.funでミームコインをミントしたり、購入したりできるようになります。

はじめに(定期)

最近「Crypto x AI Agent」が話題ですが、僕はブロックチェーン上での決済機能を持つAIエージェントに、DeFiに匹敵する可能性を感じています。今後はこの分野での情報発信を強化し、社内のメンバーとも共有していく予定です。興味のある方はぜひフォローしてください。

https://x.com/komlocklab

Crypto AI Agent 初めて聞いた!という方は、miinさんの記事を読むことをお勧めします。2024年12月時点のCrypto X AI Agentのトレンドやユースケースを理解することができます。
https://note.com/miin_nft/n/nf8cb760c3563

送金Actionの実装

前提

前回の記事でも触れましたが、Elizaはまだ発展途上のプロジェクトな為、plugin間のバージョン互換性問題があったりpluginによっては、設計の問題で拡張が難しかったりします。
その課題を今回は力技で解決している部分もあるので、参考程度に見て頂ければと思います。
もしより良いアプローチがあれば是非コメントお願いします。

plugin-goat

今回はウォレットの操作にplugin-goatというElizaのpluginを使います。plugin-evmやplugin-solanaなども選択肢としてありましたが、より汎用的で多機能なgoatを採用しました。
*plugin-solanaはBirdeyeというサービスのAPIを利用する必要があり、上手くいかなかった経緯もあります。

GOATについて

https://ohmygoat.dev/introduction

GOAT 🐐(Great Onchain Agent Toolkit)は、AIエージェントにウォレットの追加、トークンの保有・取引、ブロックチェーンのスマートコントラクトとのやり取りなどのブロックチェーンツールを組み込むためのオープンソースフレームワークです。MITライセンスのフリーソフトウェアであり、Crossmintがスポンサーとなっています。

今回の要件にぴったりなツールだと思いました。
参考:Crossmint公式
https://www.crossmint.com/

導入手順

eliza-starterをcloneする

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";

https://github.com/elizaOS/eliza/blob/main/agent/src/index.ts

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);
  }
}

ハマりポイント振り返り

  1. eliza-starterのバージョンが古い
    最新バージョンを利用しているフォークを使う。
  2. pluginのカスタマイズが不便
    別ディレクトリで管理。将来解決した際にnode_modulesから利用。
  3. pnpm buildでエラーが出る
    無視しても大丈夫。
  4. 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開発関連のイベントを定期開催しています!
是非チェックしてください。
https://connpass.com/user/Komlock_lab/open/

Discordでも有益な記事の共有や開発の相談など行っています。
どなたでもウェルカムです🔥
https://discord.gg/Ab5w53Xq8Z

Komlock lab エンジニア募集中

Web3の未来を共創していきたいメンバーを募集しています!!
気軽にDM等でお声がけください。

個人アカウント
https://x.com/0x_natto

Komlock labの企業アカウント
https://x.com/komlocklab

PR記事とCEOの創業ブログ
https://prtimes.jp/main/html/rd/p/000000332.000041264.html
https://note.com/komlock_lab/n/n2e9437a91023

Komlock lab

Discussion