0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NVIDIA NOOA 実機検証: sync def の ... は None を返す

0
Posted at

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 promptstype 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.8Requires-Python: >=3.12,<3.14 を宣言しているだけです。上限が <3.14 である点も見落としやすく、3.14 に上げた環境でも同じ落ち方をします。

uv venv --python 3.12 nooa-env で 3.12 系(手元は CPython 3.12.3)の仮想環境を切れば通ります。インストール後にパッケージを覗くと、サブモジュールには mcpstoragetracing が並んでいました。エージェント本体以外の周辺機能も同梱されています。

通常メソッドは本当に素の 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: intExample: 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 キーの用意が前提になるため、別の機会に回します。

関連記事

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?