ローカルの Ollama につないだエージェントが、パスの位置に数値を受け取ったまま平然と先へ進んでいたことに気づいたのは、うまくいかなかった実行のログを遡っていた夜でした。ツール定義には型を書いてあります。それなのに、どこでも弾かれた形跡がありませんでした。
原因はパラメータ定義の中の一語でした。私は any_of と書いていたのです。正しくは anyOf でした。
個人開発でツールを少しずつ足していくと、一度書いたパラメータ定義を読み返す機会はほとんどありません。私の場合も、動いているという事実だけを根拠に、そのまま何か月も置いていました。
厄介なのは、この綴り違いがどこからも警告として返ってこないことです。型を String と大文字で書いたときは即座に落ちるのに、結合子の綴りだけは何事もなかったように通ります。——落ちてくれるほうが、はるかに親切だったのだと気づいたのは、原因にたどり着いたあとでした。
Antigravity の changelog を見ると、2.12.2 でカスタム Python ツールのパラメータ定義が OpenAPI / JSON Schema 準拠の形(型は小文字、結合子はキャメルケース)で生成されるようになり、Ollama・LM Studio・vLLM のような OpenAI 互換のローカルエンジンに接続したときのスキーマエラーが解消される、と書かれています。これは修正としてありがたい変更です。ただ、修正が入るということは、それまで効いていなかった制約が効き始める、という意味でもあります。
以下の数値は、JSON Schema Draft 2020-12 を判定器(Python の jsonschema 4.26.0)として手元で実行した結果です。ローカルエンジン側の実装ではなく、changelog が準拠先として挙げている規格そのもので測っています。
大文字の型は落ちます。スネークケースの結合子は落ちません
同じ「意図」を持つ書き方を 10 種類つくり、それぞれについて 2 つのことを見ました。スキーマ自体がメタスキーマ検証を通るか、そして明らかに不正な引数を渡したときに違反として検出されるか、です。
| 書き方 | メタスキーマ検証 | 不正な引数を渡したとき | 判定 |
|---|---|---|---|
type: "String"(大文字) | エラー 1 件 | 実行時に UnknownType 例外 | 気づけます |
type: "Integer"(大文字) | エラー 1 件 | 実行時に UnknownType 例外 | 気づけます |
required に文字列を渡す | エラー 1 件 | 違反 1 件 | 気づけます |
any_of | エラー 0 件 | 違反 0 件 | 黙って通ります |
one_of | エラー 0 件 | 違反 0 件 | 黙って通ります |
all_of | エラー 0 件 | 違反 0 件 | 黙って通ります |
enum_values | エラー 0 件 | 違反 0 件 | 黙って通ります |
min_items | エラー 0 件 | 違反 0 件 | 黙って通ります |
exclusive_minimum | エラー 0 件 | 違反 0 件 | 黙って通ります |
additional_properties | エラー 0 件 | 違反 0 件 | 黙って通ります |
10 種類のうち 7 種類が、スキーマ検証も実行時チェックも素通りしました。理由は仕様どおりで、JSON Schema は知らないキーワードを「無視する」と決めているからです。min_items は誤りではなく、単に語彙の外にある注釈として扱われます。制約として登録されず、当然ながら違反も出ません。
型の綴りだけが落ちるのは、type が値の集合を厳密に決めているからです。ここだけは仕様が閉じているので、String は存在しない型として拒否されます。
気づける壊れ方と、気づけない壊れ方を、同じ「バグ」として数えないようにしています。 前者は放っておいても表に出てきます。後者は、こちらから探しに行かないかぎり出てきません。
正規化が入ると、これまで通っていた引数が拒否されます
次に確かめたのは、綴りが直ったときに何が変わるかです。型はすべて小文字にしたまま、結合子と制約キーワードだけをスネークケースで書いたパラメータ定義を用意し、正規化の前後で同じ引数を通しました。
| 渡した引数 | 正規化前 | 正規化後 |
|---|---|---|
| 想定どおりの引数 | 通過 | 通過 |
mode に数値を入れる | 通過 | 拒否(違反 1 件) |
tags を空配列にする | 通過 | 拒否(違反 1 件) |
定義していない depth を足す | 通過 | 拒否(違反 1 件) |
メタスキーマ検証は正規化の前後どちらも 0 件でした。つまり、どちらのスキーマも JSON Schema としては「正しい」のです。それでいて、壊れた引数 3 種類は前者では全部通り、後者では全部止まりました。
ここが実務でいちばん効いてくるところだと感じています。正規化はエラーを消す変更として案内されますが、自分のツールの側から見ると、これまで受け入れていた引数が突然拒否されるようになる変更でもあります。エージェントが投げてくる引数は毎回同じではありませんから、通っていたものが止まるのは、たいてい忙しい日の途中です。
正規化そのものは正しい方向の修正です。問題は、修正のあとで初めて自分の定義の甘さが表に出る、という順番のほうにあります。
アップデートの前に、自分のツール定義を数えます
必要なのは、綴りが正しいかどうかではなく、制約として評価されていないキーワードが何個あるかを数えることです。60 行ほどのスクリプトで足ります。
# schema_lint.py — ツールのパラメータ定義から、評価されないキーワードを数える
import json, sys
from jsonschema import Draft202012Validator as V
VALID_TYPES = {"string", "number", "integer", "boolean", "object", "array", "null"}
def collect_keywords():
"""メタスキーマから Draft 2020-12 の語彙を機械的に集めます"""
ks = set()
def walk(node):
if isinstance(node, dict):
for k, v in node.items():
if k == "properties" and isinstance(v, dict):
ks.update(v.keys())
walk(v)
elif isinstance(node, list):
for x in node:
walk(x)
walk(V.META_SCHEMA)
return ks
KEYWORDS = collect_keywords() | {"nullable", "examples", "title", "description", "default"}
def lint(schema, path="$"):
out = []
if not isinstance(schema, dict):
return out
parent = path.split(".")[-1]
for k, v in schema.items():
here = f"{path}.{k}"
if k not in KEYWORDS and parent not in ("properties", "$defs", "patternProperties"):
out.append((here, f"未知のキーワード '{k}' — 制約として評価されません"))
if k == "type":
for t in (v if isinstance(v, list) else [v]):
if isinstance(t, str) and t not in VALID_TYPES:
out.append((here, f"型 '{t}' は不正です(小文字のみ)"))
if k == "properties" and isinstance(v, dict):
for pn, ps in v.items():
out += lint(ps, f"{path}.properties.{pn}")
elif isinstance(v, dict):
out += lint(v, here)
elif isinstance(v, list):
for i, x in enumerate(v):
out += lint(x, f"{here}[{i}]")
return out
if __name__ == "__main__":
findings = lint(json.load(open(sys.argv[1])))
for p, m in findings:
print(f" {p}: {m}")
print(f"検出 {len(findings)} 件")
sys.exit(1 if findings else 0)親が properties のときだけ判定を飛ばしているのは、そこに並ぶのは語彙ではなく利用者が決めたパラメータ名だからです。ここを飛ばし忘れると、path も limit も「未知のキーワード」として報告され、出力が読めなくなります。私自身、最初はこの guard を入れ忘れて 100 件近い偽陽性を出し、レポートを一度捨てました。
壊れた定義を通すと、こう出ます。
$ python3 schema_lint.py tool.json
$.properties.path.type: 型 'String' は不正です(小文字のみ)
$.properties.limit.min_items: 未知のキーワード 'min_items' — 制約として評価されません
$.properties.mode.any_of: 未知のキーワード 'any_of' — 制約として評価されません
$.additional_properties: 未知のキーワード 'additional_properties' — 制約として評価されません
検出 4 件
終了コードを返すようにしてあるので、CI にそのまま置けます。設定キーが黙って無視されたまま CI を通り抜ける経路については、無視された設定キーが CI を通り抜ける経路と、その塞ぎ方に別途まとめてあります。考え方はほとんど同じで、「読まれていないものを、読まれていないと言ってくれる仕組みを自分で足す」という話です。
直したあとに、一度だけ確かめておくこと
綴りを直す作業自体は機械的です。順番だけ気をつけています。
- まず
schema_lint.pyを全ツール定義に走らせ、直す前の検出件数を記録します。これが後で効きます。 - 綴りを直します。
any_of→anyOf、min_items→minItems、additional_properties→additionalProperties。型は小文字へ。 - 直したスキーマに、これまで実際に流れていた引数を通します。ログから 20〜30 件ほど拾えば十分です。ここで拒否されるものが出たら、それは今まで黙って通っていた不正な引数です。
- 拒否されたものを見て、スキーマ側を緩めるのか、呼び出し側を直すのかを決めます。
3 番を飛ばさないでいただきたいのです。制約が効き始めるということは、エージェントの側が今まで送っていた引数のうち、いくつかが止まるということです。それを本番で知るのと、手元のログで先に知るのとでは、翌朝の気分がまったく違います。
ローカルエンジン側の接続そのものでつまずいている場合は、スキーマより先にそちらを片づけたほうが早いです。接続の組み方はAntigravity からローカル LLM を呼ぶ実用ワークフローにまとめました。また、ツールが読めるはずのファイルを読めていないときは、スキーマではなく除外ルールが原因のことがあり、その切り分けはGit が追跡しているのにエージェントが読めないファイルは、どこで外れているかで扱っています。
手を動かすなら、今日は 1 本だけ
いちばん引数の種類が多いカスタムツールを 1 つ選んで、schema_lint.py を走らせてみてください。検出 0 件なら、そこは心配しなくてよい場所だと分かります。1 件でも出たら、その制約は今まで一度も働いていなかったということです。
私は「エラーが出ていない」を「正しく動いている」の証拠として数えるのをやめました。黙っている仕組みには、黙っていることを報告させる係を別に立てる——それだけのことなのですが、この一手間を惜しんだ夜の遠回りは、しばらく忘れられそうにありません。
最後までお読みいただき、ありがとうございました。同じところで手が止まっている方の、少しでも近道になれば幸いです。