Nimbus Framework

AIエージェント開発ガイド
2026


Cloudflare Workers向けフレームワークNimbusの実装を題材に、
型安全な設計からデプロイまでを解説する実践ガイド

Version 1.0 · 2026-06

ハンズオンチュートリアル →

AIエージェントとは何か

AIエージェントは、LLMの推論能力とツールの実行能力を組み合わせたシステムである。 従来のチャットボットがテキストの生成だけを行うのに対し、エージェントはAPIの呼び出し、データベースの操作、ファイルの読み書きを自律的に行う。

エージェントの動作はReActパターン(Reasoning and Acting)に基づく。 LLMがまず状況を推論し、次に取るべき行動を決定する。 行動の結果を観測し、その情報をもとに再び推論する。 このループをタスクが完了するまで繰り返す。

図1-1: エージェントの動作ループ(ReActパターン)
ユーザー入力
→
LLM推論
→
ツール呼び出し?
Yes
↓
ツール実行
↺
結果をLLMへ
No
↓
応答生成
↓
ユーザーへ

実際、2026年現在、ツール呼び出し(Function Calling)はLLMの標準機能として定着している。 OpenAI、Anthropic、Googleの主要プロバイダがJSON Schemaベースの統一的なインターフェースを提供しており、LLMに渡すツール定義の形式は事実上標準化されている。

外部ツールとLLMの接続を標準化するMCP(Model Context Protocol)も広く採用されている。 MCPはAnthropicが2024年に提唱したオープンプロトコルで、ツールサーバーとLLMクライアントの間の通信を統一する。 エージェント開発者は、MCPに準拠したツールサーバーを一度実装すれば、対応するあらゆるLLMクライアントから利用できる。

チャットボットAIエージェント
入出力テキストのみテキスト + ツール呼び出し
外部連携なしAPI、DB、ファイル操作
状態管理会話履歴のみ構造化された永続状態
判断受動的な応答ツール選択、実行順序の自律的決定
持続性セッション単位スケジュール実行、耐久タスク

Cloudflare Workers向けAIエージェントフレームワークNimbusの実装を題材に、以降の章ではエージェント開発の技術要素を解説する。 Nimbusの実際のソースコードを引用しながら、型安全な設計からデプロイまでの実践的なトピックを扱う。

本書は型安全な設計とアーキテクチャの考え方を解説する技術書であり、Nimbusを題材として用いる。各機能の現時点の実装状況(実装済み/一部実装/未実装)は、リポジトリのREADME「Implementation Status」が単一の真実(FEATURE_STATUSレジストリ)である。本書のコード例には設計段階のAPIも含まれる。

アーキテクチャの全体像

Nimbusのアーキテクチャは5つの層で構成されている。 各層は単一の責務を持ち、層の境界で型によるデータの検証を行う。

図2-1: Nimbusのレイヤー構造
チャネル層 Slack、Email、Webhook、HTTP
↕
エージェントランタイム 指示文、ツール、スキル、承認
↕
実行層 Fiber、スケジュール、タスクキュー
↕
サンドボックス層 Dynamic Workers → Browser → Container
↕
AI / ストレージ Workers AI、R2、KV、D1、Vectorize

具体的には、最上位の「チャネル層」は外部インターフェースからのメッセージを受信し、統一的なメッセージ形式に正規化する。 「エージェントランタイム」は指示文、ツール、スキルを束ね、LLMとの対話ループを管理する中核である。 「実行層」はFiber、スケジュール、タスクキューといった長時間処理を担う。 「サンドボックス層」はコード実行を3段階の隔離レベルで提供する。 「AI/ストレージ」は推論エンジンとデータ永続化を担当する。

実装状況:この層構成はNimbusの設計全体像を示す概念図である。エージェントランタイム・ツール・ワークフロー・Workers AI連携は動作するが、「チャネル層」の受信ディスパッチと「サンドボックス層」(コード実行)は設計段階で未実装である(詳細は第7章・第9章、およびREADMEの「Implementation Status」)。

このアーキテクチャを構成するNimbusの設計は、宣言的APIを基本とする。 エージェントの構成要素はすべてdefine*関数で定義する。

// agent/agent.ts
import { defineAgent } from "nimbus";

export default defineAgent({
  model: "workers-ai/@cf/meta/llama-4-scout-17b-16e-instruct",

  initialState: {
    reviewsCompleted: 0,
    issuesFound: 0,
    lastReviewedPR: null as string | null,
  },

  codeMode: {
    enabled: true,  // codeMode(サンドボックス)は設計段階・未実装(第9章)
    apis: {
      analyzeDiff: "(diff: string) => Promise<{ issues: Issue[]; score: number }>",
    },
  },

  maxSteps: 15,

  // ライフサイクルフック
  onSessionStart: async (ctx) => {
    ctx.log("Session started");
  },
  onTurn: async (ctx) => {
    ctx.log(`Turn started (session: ${ctx.sessionId})`);
  },
});

defineAgentはエージェントの基本設定を受け取り、Zodスキーマで検証したうえで不変オブジェクトを返す。 モデル指定は"プロバイダ名/モデル名"の文字列形式を採用しており、プロバイダ間の切り替えが容易である。 ライフサイクルフックはonSessionStart(セッション開始時)、onSessionEnd(セッション終了時)、onTurn(メッセージごと)の3種類を提供する。 AgentContextはstate、setState、sessionIdに加え、env(環境変数・バインディング)とlog(ロギング)を持つ。

もうひとつの設計原則はファイルシステム駆動の規約である。 agent/tools/search.tsに配置したファイルは自動的にsearchツールとして認識される。 中央集権的なレジストリは不要で、IDEのファイルツリーがそのままエージェントの構成図になる。

# Nimbusプロジェクトの標準ディレクトリ構造
agent/
  agent.ts              # エージェント定義
  instructions.md       # システムプロンプト
  tools/
    search.ts           # → "search" ツール
    get-weather.ts      # → "get-weather" ツール
  skills/
    admin-guide.md      # → "admin-guide" スキル
  channels/
    slack.ts            # → Slackチャネル
  schedules/
    daily-report.ts     # → 日次レポート
  subagents/
    security/agent.ts   # → "security" サブエージェント

型駆動設計

エージェントは複数の種類のIDを扱う。 セッションID、ツール呼び出しID、Fiber IDなどを型で区別しなければ、引数の取り違えによるバグが起きる。 Nimbusはブランド型(branded types)をZod v4で実装し、この種の誤りをコンパイル時に検出する。

import { z } from "zod/v4";

const SessionId = z.string().uuid().brand("SessionId");
type SessionId = z.infer<typeof SessionId>;

const ToolCallId = z.string().uuid().brand("ToolCallId");
type ToolCallId = z.infer<typeof ToolCallId>;

SessionId型の値はToolCallId型の引数に渡せない。 同じUUID文字列であっても型が異なるため、TypeScriptが代入を拒否する。 ランタイムのオーバーヘッドはゼロで、型チェックはコンパイル時にだけ作用する。

判別共用体による状態マシン

状態遷移の表現には判別共用体(discriminated unions)を使う。 各状態をkindフィールドで区別し、状態ごとに異なるフィールドを持たせる。

// 各状態が固有のフィールドを持つ
type ActiveSession = Readonly<{
  kind: "Active";
  id: SessionId;
  startedAt: number;
  messageCount: number;
}>;

type PausedSession = Readonly<{
  kind: "Paused";
  id: SessionId;
  pausedAt: number;
  reason: PauseReason;
  pendingApproval: ApprovalRequest;  // Pausedだけが持つ
}>;

// 判別共用体としてまとめる
type Session = ActiveSession | PausedSession
                | HibernatingSession | ExpiredSession;

この設計では、Paused状態のセッションだけがpendingApprovalフィールドを持つ。 Active状態で承認情報にアクセスしようとすると、コンパイルエラーになる。 状態の不整合を型システムで防いでいる。

Result型による関数型エラーハンドリング

エラーハンドリングにはneverthrowライブラリのResult型を採用する。 例外の代わりに値として成功と失敗を表現するため、エラーの流れが型で明示される。

type ToolExecutionError =
  | Readonly<{ kind: "ToolNotFound"; toolName: string }>
  | Readonly<{ kind: "ValidationFailed"; toolName: string;
      issues: ReadonlyArray<{ message: string; path: ReadonlyArray<string> }> }>
  | Readonly<{ kind: "ApprovalRequired"; toolCallId: ToolCallId }>
  | Readonly<{ kind: "ApprovalRejected"; toolCallId: ToolCallId }>
  | Readonly<{ kind: "ExecutionFailed"; toolCallId: ToolCallId }>
  | Readonly<{ kind: "Timeout"; toolCallId: ToolCallId; duration: number }>;

ToolExecutionErrorは6種類のエラーを「判別共用体」で定義している。 各エラーはkindフィールドで区別でき、switch文で網羅的に処理できる。 assertNever関数をdefault節に置けば、処理漏れをコンパイル時に検出できる。

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

ツールシステム

ツールはエージェントが外部世界に作用する手段であり、エージェント開発で最も多く実装するコンポーネントである。 defineTool関数でツールを定義する。

function defineTool<TInput, TOutput>(
  config: ToolConfig<TInput, TOutput>
): ToolDefinition<TInput, TOutput> {
  return Object.freeze({
    name: config.name ?? "",
    description: config.description,
    inputSchema: config.inputSchema,
    needsApproval: config.needsApproval ?? "never",
    clientSide: config.clientSide ?? false,
    execute: config.execute,
  });
}

ツール定義は3つの要素から構成される。 descriptionはLLMがツールの用途を判断するための説明文で、ツール選択の精度に直結する。 inputSchemaはZodスキーマで引数の型と制約を定義し、LLMへの仕様伝達と実行時のバリデーションの二重の役割を持つ。 executeは実際の処理を行う非同期関数である。

具体例として天気情報を取得するツールを見る。

import { defineTool } from "nimbus/tools";
import { z } from "zod/v4";

export default defineTool({
  name: "get-weather",
  description: "Get the current weather for a given city",

  inputSchema: z.object({
    city: z.string().describe("City name, e.g. 'Tokyo'"),
  }),

  async execute({ city }) {
    const conditions = ["sunny", "cloudy", "rainy"];
    const condition = conditions[city.length % conditions.length];
    const tempC = 15 + (city.length % 20);
    return { city, condition, temperature: { celsius: tempC } };
  },
});

nameはツールの識別子で、LLMがツールを呼び出す際に使用する。 省略した場合、ファイルシステム規約によりagent/tools/get-weather.tsに配置すればget-weatherが自動付与される。 .describe()でフィールドの説明を付与すると、LLMがどのような値を渡すべきか判断しやすくなる。

承認ワークフロー

承認ワークフロー(Human-in-the-Loop)は、ツールの実行前に人間の判断を挟む仕組みである。 needsApprovalオプションで制御する。

const approval = {
  always: (): ApprovalConfig => "always",
  never: (): ApprovalConfig => "never",
  once: (): ApprovalConfig => "once",
};

function requiresApproval(tool: ToolDefinition, args: unknown): boolean {
  const config = tool.needsApproval;
  if (config === true || config === "always") return true;
  if (config === false || config === "never") return false;
  if (config === "once") return true;
  if (typeof config === "function") return config(args);
  return false;
}

"always"はすべての呼び出しで承認を要求する。 "once"は初回のみ要求し、以降は自動承認する。 関数を渡せば、引数の内容に基づいた条件付き承認を実装できる。 たとえば、削除操作だけ承認を要求し、参照操作は自動承認するといった制御が可能になる。

ツール呼び出しの状態遷移

ツール呼び出しは7つの状態を遷移する。

図4-1: ToolCallの状態マシン
Pending
承認要求 ↓
AwaitingApproval
承認 ↓
Approved
却下 ↓
Rejected
直接実行 ↓
Executing
成功 ↓
Completed
失敗 ↓
Failed

この状態遷移はコンパニオンオブジェクトパターンで実装されている。 TypeScriptでは型と値が同じ名前を共有できるため、ToolCallを型としても値としても使える。

const ToolCall = {
  create: (toolCallId, toolName, args): PendingToolCall => ({
    kind: "Pending", toolCallId, toolName, args,
  }),

  requestApproval: (call: PendingToolCall): AwaitingApprovalToolCall => ({
    kind: "AwaitingApproval",
    toolCallId: call.toolCallId,
    toolName: call.toolName,
    args: call.args,
    requestedAt: Date.now(),
  }),

  approve: (call: AwaitingApprovalToolCall, approvedBy): ApprovedToolCall => ({
    kind: "Approved",
    // ... 元の呼び出しのフィールドを引き継ぐ
    approvedAt: Date.now(),
    approvedBy,
  }),
  // ... reject, startExecution, complete, fail
} as const;

遷移関数は入力の型を制約している。 approveはAwaitingApprovalToolCall型しか受け取らない。 Pending状態のツール呼び出しを直接承認しようとすると、コンパイルエラーになる。 不正な遷移を型システムが静的に排除する。

セッションと状態管理

セッションはエージェントとユーザーの対話単位であり、4つの状態を持つ状態マシンで管理する。

図5-1: Sessionの状態マシン
Active
承認待ち / 一時停止
↓
Paused
↑ 再開
非活性化
↓
Hibernating
↑ メッセージ受信
↓
TTL超過(不可逆)
Expired

Activeは対話中の状態で、メッセージ数をカウントする。 Pausedはツールの承認待ちやユーザーからの一時停止要求で遷移する状態で、一時停止の理由と承認情報を保持する。 Hibernatingは一定時間操作がない場合の省電力状態で、メッセージの到着で再び「Active」に戻る。 ExpiredはTTL超過による終了状態で、この遷移は不可逆である。

「コンパニオンオブジェクト」パターンは、型と同名のオブジェクトに状態遷移関数をまとめる設計である。

// 型として使う
const session: Session = Session.create(id);

// 値として使う(状態遷移)
const paused = Session.pause(session, reason, approval);
const resumed = Session.resume(paused);

// 型ガードとして使う
if (Session.isActive(session)) {
  // この分岐ではsessionはActiveSession型に絞られる
  console.log(session.messageCount);
}

遷移関数は新しいオブジェクトを返し、元のオブジェクトを変更しない。 この不変性により、状態の履歴を保持でき、デバッグ時に任意の時点の状態を再現できる。 Object.freezeで返却値を凍結するため、誤って直接変更しようとするとランタイムエラーになる。

設計上の判断:なぜクラスではなく「コンパニオンオブジェクト」を使うのか。 クラスはthisへの代入で状態を変更できてしまう。 純粋関数による状態遷移は、入力と出力だけで動作が決まるため、テストが容易で副作用がない。

耐久実行とワークフロー

外部サービスの応答待ち、バッチ処理、人間の判断待ちを含むタスクは、数分から数日かかることがある。 プロセスの再起動やデプロイを跨いでタスクの進行を維持する仕組みが耐久実行(Durable Execution)である。

この課題に対し、NimbusはFiberという抽象を提供する。 Fiberは名前付きのステップ列で構成され、各ステップの完了時にチェックポイントを記録する。 プロセスが再起動した場合、完了済みのステップはスキップされ、中断した地点から再開される。

const fiber = defineFiber({
  name: "data-import",
  async run(ctx, input) {
    // ステップ1: 外部データ取得
    const data = await ctx.step("fetch", async () => {
      return await fetchExternalData(input.url);
    });

    // ステップ2: 変換(リトライ付き)
    const cleaned = await ctx.step("transform",
      { retries: { limit: 3, delay: "5s", backoff: "exponential" } },
      async () => { return transform(data); }
    );

    // ステップ3: データベースへ格納
    await ctx.step("load", async () => {
      await insertIntoDatabase(cleaned);
    });
  },
});

リトライのbackoffには"linear"と"exponential"を指定できる。 "exponential"の場合、リトライ間隔は delay × 2^(attempt-1) で増加する。 上の例では初期遅延5秒の指数バックオフにより、5秒、10秒、20秒の間隔で再試行する。

図6-1: Fiberのライフサイクル
Running
sleep()
↓
Sleeping
↑ wake
waitForEvent()
↓
WaitingForEvent
↑ event received
↓
Completed
↓
Failed

sleepとsleepUntilはFiberの実行を一時停止する。 期間の指定は"5s"、"30m"、"24h"、"7d"といった人間が読みやすい形式を受け付ける。 これらの文字列をparseDuration関数がミリ秒に変換する。

function parseDuration(duration: string | number): number {
  if (typeof duration === "number") return duration;

  const match = duration.match(/^(\d+)(ms|s|m|h|d)$/);
  if (!match) throw new Error(`Invalid duration: "${duration}"`);

  const value = parseInt(match[1], 10);
  const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
  return value * multipliers[match[2]];
}

ワークフローとタスクキュー

ワークフローはFiberより軽量な実行単位で、ステップの連鎖を定義する。 タイムアウトとエラーハンドラを持ち、各ステップの結果を記録する。

タスクキューはバックグラウンド処理のキューイングを担う。 重複排除キー(deduplicationKey)により、同じタスクの多重投入を防止できる。 最大リトライ回数、リトライ間隔、タイムアウトをタスク定義ごとに設定できる。

実装状況:ワークフロー(defineWorkflow/executeWorkflow)は実装済み。Fiberは定義とwaitForEvent(deliverEventで解決・タイムアウトで失敗)まで動作するが、ステップの自動再開はエージェントランタイムからまだ自動駆動されない。タスクキューはメモリ上のenqueue/処理/キャンセル/重複排除まで動作し、delay・priorityは未実装(NotImplementedError)、リトライ間隔のバックオフとタイムアウトも未強制である(Durable Objectsによる永続化は今後対応)。最新状況はREADMEの「Implementation Status」を参照。

マルチチャネルとスキル

エージェントはSlack、メール、Webhookなど複数のインターフェースからメッセージを受け取る。 チャネルごとにメッセージの形式は異なるが、エージェントのロジックはチャネルに依存すべきではない。 チャネルアダプターパターンがこの分離を実現する。

function defineChannel(config: ChannelConfig): ChannelDefinition {
  return Object.freeze({
    name: config.name ?? "",
    description: config.description ?? "",
    handleIncoming: config.handleIncoming,
    handleOutgoing: config.handleOutgoing,
  });
}

handleIncomingは外部からの生データをNimbusMessageに変換する。 handleOutgoingはエージェントの応答を外部形式に戻す。 この二つの関数がチャネルの全責務である。

図7-1: チャネルアダプターパターン
Slack
Email
Webhook
HTTP
↓ handleIncoming
NimbusMessage(統一形式)
↓
エージェントランタイム
↓ handleOutgoing
Slack
Email
Webhook
HTTP

新しいチャネルの追加はアダプターの実装だけで完了する。 エージェント本体のコードを修正する必要はない。

スキルとプログレッシブ開示

スキルはLLMのコンテキストに注入する知識モジュールである。 マークダウン形式で記述し、YAMLフロントマターでメタデータを付与する。

function defineSkill(name: string, raw: string): SkillDefinition {
  const { frontmatter, content } = parseFrontmatter(raw);
  return Object.freeze({
    name: name.trim(),
    description: frontmatter.description,
    content,
    when: frontmatter.when,
    tokenEstimate: estimateTokens(content),  // ≈ 文字数 / 4
  });
}

「スキル」の読み込みにはプログレッシブ開示パターンを適用している。 エージェントの初期コンテキストにはスキルの名前と説明だけを含め、全文は含めない。 LLMが必要と判断したスキルだけをload_skillツールで読み込む。 100個の「スキル」を登録しても、初期コンテキストのトークン消費は説明文の分だけで済む。

when条件を使えば、状態に応じて「スキル」の可視性を制御できる。

function evaluateWhenCondition(
  condition: SkillWhenCondition | undefined,
  state: Record<string, unknown>,
): boolean {
  if (!condition) return true;
  return Object.entries(condition).every(([key, expected]) => {
    const keys = key.split(".");
    let current = state;
    for (const k of keys) {
      current = current?.[k];
    }
    return current === expected;
  });
}

ドット記法(user.role)でネストしたオブジェクトのプロパティを参照できる。 条件はAND結合で評価され、すべての条件が一致した場合にのみ「スキル」が可視になる。

実装状況:defineChannelと各アダプターのメッセージ変換(handleIncoming/handleOutgoing)は動作する。一方、受信時のセキュリティ検証は未配線で(SlackのHMAC・タイムスタンプ検証は行われず、Webhookのverify・HTTPのauthはNotImplementedError)、チャネル受信のランタイムディスパッチも未実装である。スキルは定義とwhenフロントマターのパースまで動作するが、ランタイムからの自動駆動とload_skillツール(組み込みツール)はまだ未実装である。最新状況はREADMEの「Implementation Status」を参照。

テストと評価

同じ入力に対して異なる応答を返すLLMの非決定性は、エージェントのテストに固有の課題をもたらす。 Nimbusはこの課題に3つのレベルで対処する。

レベル1: 純粋関数のユニットテスト

型定義、状態遷移、バリデーションはLLMとは独立にテストできる。 Nimbusのドメインロジックは純粋関数で構成されているため、入力に対する出力を確定的に検証できる。

test("Session.pause requires ActiveSession", () => {
  const session = Session.create(SessionId.create());
  const paused = Session.pause(session,
    { kind: "ApprovalRequired" },
    { toolCallId: "tc-1", toolName: "deploy" }
  );
  expect(paused.kind).toBe("Paused");
  expect(paused.reason.kind).toBe("ApprovalRequired");
});

レベル2: Fake AIによるモック

createFakeAIは決定論的な応答を返すモックを作成する。 あらかじめ定義した応答列を順番に返すため、テスト結果は再現可能になる。

function createFakeAI(config: FakeAIConfig) {
  let callIndex = 0;
  const calls = [];

  const generate = async (opts) => {
    calls.push({ messages: opts.messages, tools: opts.tools });
    if (callIndex < config.responses.length) {
      return config.responses[callIndex++];
    }
    return { text: config.defaultResponse ?? "..." };
  };

  return Object.freeze({
    generate,
    getCalls: () => [...calls],
    getCallCount: () => calls.length,
    reset: () => { callIndex = 0; calls.length = 0; },
  });
}

ツール呼び出しのシミュレーションにも対応している。 応答にtoolCallsを含めれば、LLMがツールを選択した状況を再現できる。

const fakeAI = createFakeAI({
  responses: [
    { text: "天気を調べます",
      toolCalls: [{ toolName: "get-weather", args: { city: "Tokyo" } }] },
    { text: "東京は晴れです" },
  ],
});

レベル3: Evalフレームワーク

defineEvalはエージェント全体の振る舞いを宣言的にテストする。

import { defineEval } from "nimbus-eval";

export default defineEval({
  description: "Agent recommends appropriate tools",
  async test(t) {
    await t.send("東京の天気を教えて");
    await t.completed();
    t.calledTool("get-weather");
    t.check(t.lastMessage, includes("東京"));
  },
});

tオブジェクトがメッセージ送信、完了待ち、ツール呼び出しの検証、応答内容のチェックを提供する。 includes、startsWith、matches、semanticMatchなどのマッチャーで応答内容を評価する。

LLM-as-Judge

LLM-as-Judgeは、LLMを使ってエージェントの応答品質を評価する手法である。 人間による評価をスケールさせる目的で使う。 NimbusのevalフレームワークはrubricScore(ルーブリック評価)、closedQA(正答判定)、factuality(事実性検証)の3種類のジャッジを提供する。

テストレベル対象決定論性速度
ユニットテスト型、状態遷移、バリデーション完全に決定的数ミリ秒
Fake AIモックツール選択、対話フロー決定的(応答を固定)数十ミリ秒
Eval + LLM-as-Judgeエージェント全体の振る舞い確率的(複数回実行で安定化)数秒

エッジデプロイメント

NimbusはCloudflare Workersを実行基盤とする。 Workersはリクエストごとに起動するサーバーレス関数で、世界300以上の拠点で動作する。 起動時間は数ミリ秒のため、コールドスタートの問題が起きにくい。 Node.jsランタイムではなくV8 Isolateで動作するため、メモリフットプリントが小さく、同一マシン上に多数のインスタンスを載せられる。

エージェントの状態はDurable Objectsに保存される。 Durable Objectsは単一のインスタンスが一貫した状態を保持する仕組みで、内蔵のSQLiteデータベースとWebSocket接続を備えている。 セッション状態、会話履歴、Fiberのチェックポイントはすべてここに格納される。

エージェントランタイムの構成

function createNimbusAgent(config) {
  const namedTools = resolveNamedTools(config.tools ?? []);
  const useDirectWorkersAI = isWorkersAIModel(config) && hasTools;

  return class extends AIChatAgent {
    initialState = config.definition.config.initialState;

    async onChatMessage(onFinish, options) {
      // Workers AI + ツール → env.AI.run() 直接呼び出し
      if (useDirectWorkersAI) {
        const messages = convertUIMessagesToWorkersAI(
          this.messages, config.instructions
        );
        const result = await runWithTools(
          this.env.AI, modelName,
          messages, workersAITools, toolMap,
          () => this._createToolContext(),
          config.definition.config.maxSteps,
        );
        return buildNonStreamingResponse(result);
      }

      // その他のプロバイダ → AI SDK経由
      const model = config.createModel(this.env);
      const result = streamText({
        model,
        system: config.instructions,
        messages: await convertToModelMessages(this.messages),
        tools: aiTools,
        maxSteps: config.definition.config.maxSteps,
        onFinish,
      });
      return result.toUIMessageStreamResponse();
    }
  };
}

createNimbusAgentはCloudflareのAIChatAgentを拡張し、Nimbusのツール定義と指示文を統合する。 エージェント定義のモデルがworkers-ai/プレフィックスを持ち、かつツールが存在する場合、env.AI.run()を直接呼び出すパスに自動切り替えする。 これによりworkers-ai-providerのストリーミングツール呼び出しの非互換を回避し、ツール引数が確実に渡される。 Workers AI以外のプロバイダ(OpenAI、Anthropic等)では従来通りVercel AI SDKのstreamTextを使用する。

Viteプラグインによるビルド自動化

function nimbusVitePlugin(options = {}) {
  const agentDir = options.agentDir ?? "agent";

  const watchPaths = Object.freeze([
    `${agentDir}/agent.ts`,
    `${agentDir}/instructions.md`,
    `${agentDir}/tools/**/*.ts`,
    `${agentDir}/skills/**/*.md`,
    `${agentDir}/channels/**/*.ts`,
    `${agentDir}/schedules/**/*.ts`,
    `${agentDir}/subagents/**/agent.ts`,
  ]);

  return Object.freeze({
    name: "nimbus",
    config: options,
    generateWrangler: (projectName) =>
      generateWranglerConfig(projectName, options),
    getWatchPaths: () => watchPaths,
  });
}

nimbusVitePluginはプロジェクト設定からWranglerの構成ファイルを自動生成する。 Durable Objects、R2、KV、D1、Vectorize、Workers AIのバインディングを宣言的に指定でき、手動でwrangler.jsoncを編集する必要がない。 getWatchPathsはHMR対象のファイルパターンを返し、ツールやスキルの変更をリアルタイムに反映する。

サンドボックス実行

エージェントが生成したコードを実行する場合、安全な隔離環境が必要になる。 Nimbusは3段階のサンドボックスを設計している。

実装状況:以下の3段階サンドボックス(Code Mode/Browser/Container)は設計段階で未実装であり、有効化された実行系を呼ぶとNotImplementedErrorを投げる(Dynamic Workers/Browser Rendering/Containersという重いインフラを要するため、需要に応じて実装予定)。本節はサンドボックスAPIの設計方針を示す。最新状況はREADMEの「Implementation Status」を参照。
レベル実行環境対応言語起動時間隔離度
L1: CodeModeDynamic WorkersJavaScript / TypeScript数ミリ秒V8 Isolate
L2: BrowserRunBrowser RenderingJavaScript + DOM数百ミリ秒ブラウザサンドボックス
L3: ContainerDocker ContainerPython, Go, Rust, Shell数秒コンテナ隔離

設計上は、実行するコードの要件に応じて適切なレベルが自動選択される。 単純なJavaScriptの計算はL1のDynamic Workersで数ミリ秒、Python処理やシェルスクリプトが必要な場合はL3のコンテナ、という使い分けを想定している。

エージェント設計の8原則

以下の設計原則はNimbusの実装に限らず、エージェント開発全般に適用できる。

1. 境界で解析し、内部では信頼する

外部からの入力はZodスキーマで検証する。 検証を通過したデータは型で保証されるため、境界(チャネルアダプター、ツール入力、設定ファイル)以降での再検証は不要になる。

2. 状態遷移を型で保護する

「判別共用体」と「コンパニオンオブジェクト」の組み合わせは、不正な状態遷移をコンパイル時に防ぐ。 Pending状態のツール呼び出しをapprove関数に渡すとコンパイルエラーになるという制約は、ランタイムの防御コードを不要にする。

3. 関数型エラーハンドリング

Result型は例外の暗黙的な伝播を排し、呼び出し側にエラー処理を型で強制する。 例外を使うと、どの関数がどのエラーを投げるかをコードから読み取れない。 Result型はエラーの種類を戻り値の型に含めるため、処理漏れが起きない。

4. 不変データで安全性を確保する

Object.freezeとReadonly<T>で定義オブジェクトの変更を防ぐ。 副作用のない遷移関数はテストが容易で、並行処理でも安全に動作する。

5. ファイルシステムが設計図になる

agent/tools/search.tsがsearchツールを定義するという規約は、設定ファイルの管理コストを排除する。 ディレクトリ構造を見るだけで、エージェントがどのツールを持ち、どのチャネルに接続し、どのスケジュールで動作するかがわかる。

6. プログレッシブ開示でコンテキストを節約する

LLMのコンテキストウィンドウは有限の資源である。 100個の「スキル」があっても、初期コンテキストには説明文だけを含め、全文は必要時にロードする。 エージェントが自律的に必要な知識を判断し、取得する設計にすることで、コンテキストの利用効率を最大化する。

7. ゼロコンフィグで始め、必要に応じて拡張する

デフォルト値で動作するシステムは導入障壁が低い。 Nimbusはモデル、最大ステップ数、コンパクション閾値にデフォルト値を持ち、設定なしで動作する。 要件が複雑化した段階で、設定を段階的に追加できる。

8. テストを3層で構成する

純粋関数のユニットテスト、Fake AIによるモック、Evalフレームワークの3層で、決定論性と実環境の忠実度のトレードオフを制御する。 決定論的なテストで基盤の正しさを保証し、確率的なテストでエージェント全体の振る舞いを評価する。

Function Calling、MCP、LLM-as-Judgeは2024年以降に標準化されたもので、エージェント開発の具体的な技術は変化が速い。 型安全な設計、宣言的API、テスト可能なアーキテクチャといった基盤の原則は変わらないが、その上に載る技術は今後も更新される。 具体例はフレームワークのバージョンアップに合わせて更新する。