TL;DR
- NVIDIA が公開した NOOA(NVIDIA-labs OO Agents) は、Python のクラスをそのままエージェントにするフレームワークです。メソッド本体を
...にすると、その1メソッドだけが実行時に LLM へ渡されます。 -
pip install nooaは Python 3.12 系でしか通りません。PyPI の 0.0.8 はRequires-Python: >=3.12,<3.14で、3.11 環境では候補ゼロとして落ちます。 - 筆者が 0.0.8 で試したところ、
...メソッドを 同期のdefで書くと LLM を1回も呼ばずにNoneが返りました。例外も警告も出ません。async defに変えると同じコードが動きました。 - 戻り値の型注釈は本当に契約として機能します。
-> intのメソッドに文字列を返させると、3回リトライしたうえでGenerationErrorになりました。 -
nooa.print_prompt()を使うと、API キーなしで「クラスの docstring と型注釈がどうプロンプトに化けるか」を全文確認できます。
はじめに
対象読者は、Python でエージェントを書いていて LangGraph や CrewAI のようなグラフ・ロール定義の記述量に疲れている開発者です。
エージェントフレームワークを触るとき、最初に引っかかるのは「フレームワークの語彙を覚える」コストです。ノード、エッジ、ツールレジストリ、ステートスキーマ。やりたいことは「この関数を LLM に埋めてほしい」だけなのに、その手前に専用の型と作法が積み上がります。
NOOA はここを逆に振り切っています。README の表現を借りると、State lives on the object. Fields are typed.、docstrings are prompts、type annotations are contracts、そして A method with ... becomes an agentic loop; a real body stays deterministic Python. です。新しい語彙はほぼ増えません。
問題は、その「ほぼ増えない」がどこまで本当かです。Python の文法をそのまま使うということは、Python の文法上は正しいのに NOOA としては無効という状態が作れてしまうことでもあります。筆者はまさにそこで30分溶かしました。
NOOA の実行モデル
NOOA が1つのクラスをどう分解するかを整理すると、次の形になります。
... を持つメソッドだけが右のループに入り、それ以外は左の直線で終わります。この分岐がクラス定義の見た目からは判別しづらいところが、後述するハマりどころの根になっています。
環境構築でまず落ちる
最初の壁は Python のバージョンです。手元の 3.11 環境でそのまま入れると、次のように候補が見つからない形で落ちます。
$ python3 --version
Python 3.11.15
$ python3 -m pip install nooa
ERROR: Ignored the following versions that require a different python version: 0.0.7 Requires-Python <3.14,>=3.12; 0.0.8 Requires-Python <3.14,>=3.12
ERROR: Could not find a version that satisfies the requirement nooa (from versions: none)
ERROR: No matching distribution found for nooa
エラーメッセージが from versions: none なので、一見するとパッケージ名を間違えたように見えます。実際には PyPI 上の 0.0.8 が Requires-Python: >=3.12,<3.14 を宣言しているだけです。上限が <3.14 である点も見落としやすく、3.14 に上げた環境でも同じ落ち方をします。
uv venv --python 3.12 nooa-env で 3.12 系(手元は CPython 3.12.3)の仮想環境を切れば通ります。インストール後にパッケージを覗くと、サブモジュールには mcp や storage、tracing が並んでいました。エージェント本体以外の周辺機能も同梱されています。
通常メソッドは本当に素の Python か
最小のエージェントを書いて確かめます。ノートを溜める add_note() は普通の本体を持ち、summarize() は本体が ... です。
import nooa
from nooa.ellipsis_detection import has_ellipsis_body
from nooa.unifiedllm import FakeLLMClient
class ReleaseNoteAgent(nooa.Agent):
"""リリースノートを扱うエージェント。"""
def __init__(self, **kw):
super().__init__(**kw)
self.notes: list[str] = []
def add_note(self, text: str) -> int:
"""ノートを追加して件数を返す。"""
self.notes.append(text)
return len(self.notes)
async def summarize(self, max_words: int) -> str:
"""self.notes を max_words 語以内の日本語で要約する。"""
...
LLM には FakeLLMClient を渡しました。これは NOOA 本体に同梱されているテスト用クライアントで、ネットワークも API キーも要りません。実行すると、通常メソッドは素の Python として動き、LLM 呼び出し回数は 0 のままでした。
== 条件A: 通常メソッド(LLM 呼び出しなし) ==
add_note -> 1
add_note -> 2
notes -> ['v2.1.224: SendMessage 追加', 'v2.1.224: spawn 上限撤廃']
fake calls so far -> 0
== ellipsis 判定 ==
add_note: has_ellipsis_body=False
summarize: has_ellipsis_body=True
nooa.ellipsis_detection.has_ellipsis_body() が公開 API として使えるので、どのメソッドが LLM 側に回るかをテストコードから機械的に確認できます。エージェントの回帰テストで「意図せず ... のまま残ったメソッド」を検出する用途に使えます。
インスタンス化した時点で LLM が要る
ここで最初につまずきました。... メソッドを一切呼ばなくても、インスタンスを作る時点で LLM の解決が走ります。
ValueError: No LLM available for ReleaseNoteAgent. Resolution attempted:
1. Instance-level: Not provided
2. Class hierarchy: Not set (checked full MRO)
3. Runtime parent: No parent agent in context
Solutions:
- Pass llm=my_llm to __init__
- Set llm=my_llm in class definition
- Inherit from an Agent class with an LLM
- Instantiate within a parent agent's generated code
エラーメッセージ自体は親切で、解決策が4通り提示されます。ところが 0.0.8 では、2番目の Set llm=my_llm in class definition が筆者の手元では機能しませんでした。
class A(nooa.Agent):
"""クラス属性で llm を渡す。"""
llm = fake # → ValueError: No LLM available for A
class B(nooa.Agent):
"""注釈つきクラス属性。"""
llm: UnifiedLLM = fake # → ValueError: No LLM available for B
素のクラス属性でも型注釈つきでも同じ ValueError になります。一方、次の3通りはいずれも通りました。
ReleaseNoteAgent(llm=fake) # コンストラクタ引数
class D(nooa.Agent, llm=fake): ... # クラス定義のキーワード引数(README の作法)
class C(Base): ... # super().__init__(llm=fake) する基底クラスを継承
README のクラス定義例は class FeedbackAgent(Agent, llm=llm): というキーワード引数の形だけで、クラス本体への属性代入は文書化されていません。公式どおりに書けば踏まない一方、エラーメッセージの Set llm=my_llm in class definition を「クラス本体に書けばよい」と読むと外します。
面白いのは、llm をクラス属性として書いたクラスを print_prompt() に通すと、プロンプト内のクラス表現には llm: FakeLLMClient = FakeLLMClient() としてしっかり載ることです。属性としては認識されているのに、LLM 解決の経路には乗っていません。エラーメッセージが案内する解決策と実挙動が食い違うため、案内どおりに直して直らない、という時間の溶かし方をしました。回避策は単純で、コンストラクタか基底クラスに寄せれば済みます。
docstring と型注釈がプロンプトに化ける様子を見る
nooa.print_prompt() は、実際に LLM を呼ばずに送信予定のプロンプトを標準出力へ吐きます。API キーがない環境でも中身を確認できるので、フレームワークの理解にはこれが一番早い経路でした。
クラス docstring は <system_prompt expr="self._resolve_system_prompt()"> ブロックに包まれてそのまま system prompt になります。メソッド docstring はタスク指示になり、自己再帰への注意まで自動で添えられます。
=== TASK PROMPT [ReleaseNoteAgent.summarize] ===
## Task: summarize
self.notes を max_words 語以内の日本語で要約する。
You are executing `summarize` — code runs in the Execution Context above. Calling `self.summarize(...)` would recurse.
既定戦略の CodeActStrategy は、LLM に Jupyter 風のセッションを与えます。ツールは execute_python(code) と return_result(value) の2つだけで、プロンプトには eval / exec / globals / asyncio.run などの禁止リストも明記されています。エージェントに何を許すかがプロンプト側で宣言されているため、挙動を読むのにソースを追う必要がありませんでした。さらに prefill として pprint(max_words, ...) を含むセルが自動で仕込まれ、引数の型と中身を最初に観測させてから本題に入らせます。
sync def の ... は静かに None を返す
ここが本記事で一番共有したい所見です。summarize() を 同期の def で定義したまま呼ぶと、次の結果になりました。
B-2 tool_call(return_result): result=None type=NoneType llm_calls=0
B-3 plain text only: result=None type=NoneType llm_calls=0
戻り値は None、LLM 呼び出し回数は 0 です。例外も警告も出ません。has_ellipsis_body() は True を返し、print_prompt() はプロンプトを正しく組み立てるのに、実際に呼ぶと何も起きずに None が返ります。「LLM が空を返した」「API キーが無効だった」と誤読しやすい失敗の仕方です。
定義を async def に変えただけで、同じコードが動きました。
B-2 tool_call(return_result): result='SendMessage 追加と spawn 上限撤廃' type=str llm_calls=1
B-3 plain text only: result='SendMessage 追加と spawn 上限撤廃' type=str llm_calls=1
対照表にすると差は明確です。
| メソッド定義 | LLM 応答の形 | 戻り値 | LLM 呼び出し回数 |
|---|---|---|---|
def(同期) |
return_result ツールコール |
None |
0 |
def(同期) |
プレーンテキスト | None |
0 |
async def |
return_result ツールコール |
str(期待どおり) |
1 |
async def |
プレーンテキスト |
str(期待どおり) |
1 |
README のサンプルはいずれも async def なので、公式どおりに書いていれば踏みません。踏むのは、既存の同期クラスを NOOA に載せ替えるときです。Python の文法としては def でも ... でも通り、フレームワークも定義時には何も言いません。async を付け忘れた瞬間に、そのメソッドは「常に None を返す関数」に変わります。
回避策としては、has_ellipsis_body() が True のメソッドが inspect.iscoroutinefunction() でも True であることを、クラス定義後にアサートしておくのが確実でした。... を使うクラスが増えるほど効きます。
もう1点、プレーンテキストのみの応答でも str として通っている点は、戦略プロンプトの記述(plain-text responses do NOT end the session)と食い違って見えました。ここは FakeLLMClient 経由の観測なので、実クライアントで同じかどうかまでは確かめていません。
型注釈は本当に契約として効く
戻り値の型注釈を -> int に変え、LLM 側には文字列を返させました。結果は3回リトライしたうえでの GenerationError です。
ERROR GenerationError: return_result validation failed after 3 attempts.
Last error:
return_result(result=...) - 'result' has wrong type.
Expected: int
Example: return_result(result=42) llm_calls=3
エラー本文に Expected: int と Example: return_result(result=42) が入っており、これがそのままリトライ時のフィードバックとして LLM に返っています。型注釈を書き換えるだけで、検証・リトライ・修正指示の3点が同時に付いてくる形です。Pydantic モデルを別途定義してパーサを噛ませる手間がないぶん、素の Python らしさが保たれています。ツールコールの引数名を value にした場合も同じ経路で弾かれました(正しい引数名は result)。
使いどころの整理
30分ほど触った範囲での評価です。
向いていると感じたのは、既存の Python クラスに「1メソッドだけ LLM に任せたい」箇所がある場合です。クラスの構造を変えずに本体を ... にするだけで済み、周囲のメソッドは決定論的なまま残ります。テスト時は FakeLLMClient で丸ごと差し替えられるので、CI に載せる負担も小さく収まります。
慎重に判断したいのは、... メソッドが増えてくる場合です。どのメソッドが LLM 経路かはクラス定義の見た目からは分かりにくく、async の付け忘れが黙って None になる以上、規約かアサートで機械的に縛る前提が要ります。
なお、NVIDIA が公開している論文(arXiv:2607.20709)のアブストラクトには SWE-bench Verified・Terminal-Bench 2.0・ARC-AGI-3 といったベンチマーク名が挙がっていますが、改善率やトークン削減率の数値は書かれていません。性能面の判断材料としては、本文中の主張ではなく自分の課題での実測が要ります。
本記事で確認していないのは、実 LLM に接続したときの挙動全般(リトライの実効性、トークン消費、nooa-cli のトレース表示)と、nooa.mcp サブモジュール経由の MCP 連携です。いずれも API キーの用意が前提になるため、別の機会に回します。
関連記事
- agmsg入門 — MCPなしでClaude CodeとCodexを直接会話させる仕組み
- Claude Codeの10分の1のトークンで動くエージェント「Pi」を試した
- Coro Code 実践 — Rust製OSSコーディングエージェントをDeepSeekバックエンドで動かす