エージェントが夜のうちに走って、朝に見ると失敗していました。ログには error の一行があるだけで、どのサイクルの、どのツール呼び出しで、なぜ落ちたのかが読み取れません。console.log を散らして上から目で追ううちに、実行回数が増えた途端、その方法は通用しなくなりました。
この記事は、そこから引いた線をまとめたものです。計測した数値を並べられる記事ではありません——「サイクルの境界を記録しておけば、失敗の読み方が変わる」と実感した、その設計だけを書き残します。
前提を一つだけ置きます。Antigravity の内部には手を入れられないので、ここでの計測は自分で書いたエージェント実行ループ(スクリプトやワーカー、エージェントが呼ぶツール側)に入れるものです。Antigravity 本体が標準でこのスパン構造を出すわけではありません。
なぜエージェントは「失敗の理由が分からない」状態に陥るのか
原因は、おおよそ次の 3 つに集約されると考えています。
ひとつ目は、ステップ境界が曖昧なケースです。エージェントは「ツール呼び出し → LLM 推論 → 状態更新」のサイクルを回しますが、この境界を記録していないと、エラーが「どのサイクルの、どのステップで」起きたのかが追えません。タイムスタンプ付きの平文ログを眺めても、サイクル間の構造は見えてきません。
ふたつ目は、入出力が黒箱になっているケースです。LLM 呼び出しの中で何が起きたかは記録するのに、渡したコンテキストと受け取った生レスポンスが残っていない状態です。エラーは「LLM が変な返事をした」で終わり、再現できません。
みっつ目は、失敗の分類が無いケースです。エラーは出ているのに、リトライすべきものか、ユーザーに伝えるべきものか、コードのバグかを後から判断できません。アラート疲れとリトライ方針の破綻に、そのままつながります。
可観測性の設計とは、この 3 つを構造化して残す仕組みを、先に作っておくことです。
トレースの基本単位 — スパン構造を決める
OpenTelemetry の概念に揃えて、エージェント実行を次の階層でスパン化します。Antigravity 固有の構造ではなく汎用的なものですが、エージェント特有の「サイクル」を 1 階層挟むのが要です。
- agent.run (ルートスパン: 1 回のエージェント実行全体)
- agent.cycle (推論・実行サイクル 1 回ぶん)
- agent.plan (LLM による次アクション決定)
- agent.tool_call (ツール実行 — 個別ツールごとに別スパン)
- agent.observe (実行結果を観測してコンテキストに反映)
- agent.cycle
- ...
agent.cycle を独立したスパンにする理由は単純です。8 サイクル回って 5 サイクル目で落ちたとき、サイクル単位のスパンが無いと、「5 サイクル目でツールが落ちた」という事実を取り出すために、人間がログを目で追うことになります。
実装は、サイクルの開始点で start_as_current_span("agent.cycle") を呼ぶだけです。Python では次の形になります。上限サイクルに達して終わった場合も、失敗として分類して残す点に注目してください。
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer("antigravity.agent")
class AgentError(Exception):
def __init__(self, message, failure_class):
super().__init__(message)
self.failure_class = failure_class
def run_agent(task):
with tracer.start_as_current_span("agent.run") as run_span:
run_span.set_attribute("task.id", task.id)
run_span.set_attribute("task.kind", task.kind)
run_span.set_attribute("agent.policy", task.policy_name)
run_span.set_attribute("agent.tools_available", list(task.tools))
try:
for cycle_idx in range(MAX_CYCLES):
with tracer.start_as_current_span("agent.cycle") as cycle_span:
cycle_span.set_attribute("cycle.index", cycle_idx)
action = plan_next_action(task)
if action is None:
cycle_span.set_attribute("cycle.terminated", True)
run_span.set_attribute("run.cycle_count", cycle_idx + 1)
run_span.set_attribute("result.status", "success")
return success(task)
cycle_span.set_attribute("cycle.action_type", action.kind)
result = execute_tool(action)
observe(task, result)
# ループを使い切った場合も、失敗として分類して残す
raise AgentError("max cycles exceeded", "cycle_budget_exceeded")
except AgentError as e:
run_span.set_status(Status(StatusCode.ERROR, str(e)))
run_span.set_attribute("run.cycle_count", MAX_CYCLES)
run_span.set_attribute("result.status", "failure")
run_span.set_attribute("result.failure_class", e.failure_class)
raisewith 文の中で例外が出ると、OpenTelemetry の Python SDK は既定でその例外をスパンに記録し、ステータスも ERROR にします。それでも failure_class だけは自分で付ける必要があります。SDK には、失敗の「意味」までは分からないためです。
TypeScript(Node.js)では、@opentelemetry/api で同じ構造を書きます。startActiveSpan に渡したスパンは自動では閉じられないので、finally で必ず end() を呼びます。
import { trace, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("antigravity.agent");
export async function runAgent(task: Task): Promise<Result> {
return await tracer.startActiveSpan("agent.run", async (runSpan) => {
runSpan.setAttribute("task.id", task.id);
runSpan.setAttribute("task.kind", task.kind);
runSpan.setAttribute("agent.policy", task.policyName);
try {
for (let cycleIdx = 0; cycleIdx < MAX_CYCLES; cycleIdx++) {
const done = await tracer.startActiveSpan(
"agent.cycle",
async (cycleSpan) => {
try {
cycleSpan.setAttribute("cycle.index", cycleIdx);
const action = await planNextAction(task);
if (action === null) {
cycleSpan.setAttribute("cycle.terminated", true);
return true;
}
cycleSpan.setAttribute("cycle.action_type", action.kind);
const result = await executeTool(action);
observe(task, result);
return false;
} finally {
cycleSpan.end();
}
}
);
if (done) {
runSpan.setAttribute("run.cycle_count", cycleIdx + 1);
runSpan.setAttribute("result.status", "success");
return success(task);
}
}
throw new AgentError("max cycles exceeded", "cycle_budget_exceeded");
} catch (e) {
runSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
runSpan.setAttribute("result.status", "failure");
runSpan.setAttribute("result.failure_class", classify(e));
throw e;
} finally {
runSpan.end();
}
});
}agent.cycle には、必ず cycle.index を属性として乗せておきます。後から「3 サイクル目の挙動だけ見たい」というクエリが、ほぼ確実に必要になるためです。