🙌

Strands+Galileo(Agent Control)を試す

に公開

Galileo Agent Control について

https://galileo.ai/

Galileo が2026年3月に出した OSS のガードレール基盤。
"AI agent governance" と呼ばれる、新規市場の役者の一人。

この市場、まだ軸が定まっていない。
ガードレール(個別呼び出しを止める)、オーケストレーション(エージェント自体を管理する)、observability(事後に観測する) の3つが混ざっていて、
Galileo Agent Control に関していうと「ガードレール軸の中で OSS + フレームワーク非依存 + 中央集権ポリシー」という比較的明確なポジション。

「中央集権的なポリシープレーン」という思想自体が一つの選択であって、NeMo Guardrails のような分散ガード、対話フロー全体を記述する DSL アプローチとは設計思想が異なる。

特徴

  • (1) コードとポリシーの分離が徹底している — @control() デコレータと外部ポリシー定義の組み合わせで、エージェント側コードを変えずにruntime挙動を変えられる。これは他にも謳う製品はあるけど、Agent Control は「policies once, deploy anywhere」という portable policies を一次的な売りに置いている
  • (2) フレームワーク非依存 — Strands、CrewAI、LangChain、Google ADK のどれでも同じポリシーが効く。これは LangChain 系のように「自社フレームワークに統合された guardrail」とは思想が逆。
  • (3) Apache-2.0 オープンソース + vendor-neutral evaluator — regex、list、Luna-2 (Galileo自社モデル)、custom evaluator まで差し替え可能。lock-in を避ける設計。

1. このプロジェクト(Galileoためすプロジェクト)の位置づけ

これは Agent Control(Galileo OSS / Apache-2.0)の挙動を手で触って理解するための学習プロジェクト

つくるもの

Agent Control 入門サンドボックス(旅程プランナー)

なぜ旅程プランナーなのか

学習用の題材に必要だった条件:

  • Agent Control の3つの action(deny / warn / steer)が自然に全部出る
  • pre / post の非対称性が意味を持つ
  • multi-agent 的要素はあるが、題材複雑性に溺れない
  • 半日〜1日で動く
  • staged trust delegation(tool単位の段階的権限委譲)の実地写像になる

旅程プランナーは、tool 呼び出しごとに「予算ガード」「整合性ガード」「トーン調整」が自然に必要になるので、学習素材として密度が高い。

このプロジェクトで得たいもの

  1. @control() デコレータ、policy 記述、evaluator、decision(allow/warn/steer/deny)の実地感覚
  2. pre と post のどちらで介入するかという設計判断の肌感
  3. 中央集権的ポリシープレーンという思想に対する自分なりの評価軸
  4. dashboard に記録される事象の語彙から、Galileo の設計者が何を可観測にしたかを読む

得なくていいもの:

  • 旅程プランナーとしての完成度
  • Strands の高度な multi-agent 機能の習熟
  • 本番運用を想定した設計

2. 技術スタック

要素 選択 理由
エージェントフレームワーク Strands Agents (Python) 私が既に習熟しているため。あと今のところベンダーロックインがないため。
LLM OpenAI gpt-5.4-mini 低コスト・低レイテンシ、学習用途に適う
ガバナンス層 Agent Control (Docker Compose) このプロジェクトの主役
言語 Python 3.12+ Agent Control SDK の前提
パッケージ管理 uv 推奨、軽い
環境変数 .env + python-dotenv OPENAI_API_KEY を gitignore 配下で管理

依存パッケージ

strands-agents[openai]
agent-control-sdk
python-dotenv

3. エージェント構成

単一エージェント + 3 tool で構成する。multi-agent hand-off は使わない。

理由: @control() の介入点と観察対象の対応をシンプルに保つため。複雑性は tool の数で稼ぐ。

エージェント

  • planner_agent
    • system prompt: 旅程プランナーとして振る舞う。予算・日程・制約を守って旅程を組み立てる
    • 利用可能 tool: search_flights, search_hotels, assemble_itinerary

Tool 定義(すべてパラメトリックモック、外部API呼ばない)

tool 入力 出力
search_flights origin, destination, date, budget_max 固定ルールで3件のダミー結果を生成
search_hotels city, checkin, checkout, budget_per_night 固定ルールで3件のダミー結果を生成
assemble_itinerary flights, hotels, activities, user_constraints 組み立てた旅程を dict で返す

モックの実装方針:

  • 引数に応じて結果を動的生成(例: budget_max 以下の価格帯を返す)
  • ランダム要素は入れない(失敗の再現性を確保)
  • LLM が「矛盾した引数を渡す」「制約を忘れる」といった failure mode を起こせる余地を残す

4. Agent Control による介入設計

3つの control を異なる思想で配置する。これが本プロジェクトの中核実験

Control A: 予防型 (pre / deny)

  • 配置: search_flights の呼び出し前
  • トリガ: budget_max がユーザー指定の総予算の 80% を超えている
  • action: deny
  • 思想: 「信用しない」。委譲しない判断。強制的に止める
  • staged trust delegation での位置: 権限を与えない

Control B: ステアリング型 (pre / steer)

  • 配置: assemble_itinerary の呼び出し前
  • トリガ: ユーザー制約(例: 「飛行機なし指定」)と引数内容に矛盾がある
  • action: steer(引数を補正して続行させる)
  • 思想: 「部分的に信用」。LLM の見落としを構造的に補正
  • staged trust delegation での位置: 引数ガード付きで委譲

Control C: 事後検閲型 (post / deny)

  • 配置: assemble_itinerary の出力後
  • トリガ: 最終行程の合計金額がユーザー総予算を超えている
  • action: deny
  • 思想: 「自由にやらせて結果だけ見る」。局所判断は通すが全体整合だけ検閲
  • staged trust delegation での位置: 結果検閲付きで委譲

観察するメトリクス

  • 各 control の発火回数
  • pre で止まった場合と post で止まった場合の、LLM 再試行挙動の違い
  • steer で書き換えた後の LLM の振る舞い(素直に従うか、元の意図に戻ろうとするか)
  • Agent Control dashboard(localhost:8000)に記録される事象の語彙

5. 実装フェーズ(計画)

Phase 0: 環境構築

  • ディレクトリ作成、pyproject.toml.env.example.gitignore
  • Docker Compose で Agent Control server + UI + Postgres 起動
  • uv venv、依存インストール
  • localhost:8000/health で疎通確認

Phase 1: controlなしの最小構成

  • 3 tool のパラメトリックモック実装
  • planner_agent を Strands OpenAIModel(model_id="gpt-5.4-mini") で構築
  • 動作確認スクリプト: 「東京→札幌、予算5万、2泊3日」で旅程が組めるか
  • この時点では Agent Control は起動しているだけ、@control() は付けない

Phase 2: Control A(予防型 / deny)追加

  • search_flights@control() を付ける
  • Agent Control UI か setup スクリプトで control を登録
  • 「予算内だが flight budget だけ超過する」ケースで deny 発火を確認
  • dashboard で記録を確認

Phase 3: Control B(ステアリング型 / steer)追加

  • assemble_itinerary@control() を追加
  • 「飛行機なし」指定で国際線が混入するケースで steer 発火を確認
  • steer 後の LLM 挙動を観察

Phase 4: Control C(事後検閲型 / post / deny)追加

  • assemble_itinerary の post stage に control 追加
  • 合計金額超過ケースで deny 発火を確認
  • 3つの control の相互作用を観察

Phase 5: 振り返り

  • 発火パターンの記録
  • L3/L4 の観点で「この設計が何を可観測にしているか」をメモ
  • 中央集権プレーンという思想への評価メモ

基本コード

agent.py

from __future__ import annotations

import os

import agent_control
from dotenv import load_dotenv

from .tools import (
    assemble_itinerary,
    assemble_itinerary_plain,
    search_flights,
    search_flights_plain,
    search_hotels,
)

SYSTEM_PROMPT = """
You are a travel planner.
Respect user budget, dates, and constraints.
Use the available tools to search flights, search hotels, and assemble an itinerary.
If the user constraint conflicts with a proposed option, explain the conflict clearly.
""".strip()

AGENT_NAME = "agent-control-travel-sandbox"
AGENT_DESCRIPTION = "Travel planner sandbox for Agent Control learning"


def build_planner_agent(model_id: str = "gpt-5.4-mini", with_control: bool = True):
    """Build the Strands agent for the travel sandbox."""

    load_dotenv()
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        raise RuntimeError("OPENAI_API_KEY is required to build the planner agent.")

    try:
        from strands import Agent
        from strands.models.openai import OpenAIModel
    except ImportError as exc:  # pragma: no cover - import guard
        raise RuntimeError(
            "Install strands-agents[openai] before running the planner agent."
        ) from exc

    if with_control:
        server_url = (
            os.getenv("AGENT_CONTROL_SERVER_URL")
            or os.getenv("AGENT_CONTROL_URL")
            or "http://localhost:8000"
        )
        agent_control.init(
            agent_name=AGENT_NAME,
            agent_description=AGENT_DESCRIPTION,
            server_url=server_url,
            policy_refresh_interval_seconds=0,
        )

    model = OpenAIModel(
        client_args={"api_key": api_key},
        model_id=model_id,
        params={
            "max_completion_tokens": 1200,
            "temperature": 0.2,
        },
    )

    tools = (
        [search_flights, search_hotels, assemble_itinerary]
        if with_control
        else [search_flights_plain, search_hotels, assemble_itinerary_plain]
    )

    return Agent(model=model, tools=tools, system_prompt=SYSTEM_PROMPT)

tools.py

from __future__ import annotations

from dataclasses import asdict, dataclass
from datetime import date
from typing import Any, Iterable

from agent_control import control
from strands import tool


@dataclass(frozen=True)
class FlightOption:
    airline: str
    origin: str
    destination: str
    date: str
    departure: str
    arrival: str
    price: int
    baggage_included: bool


@dataclass(frozen=True)
class HotelOption:
    name: str
    city: str
    checkin: str
    checkout: str
    nightly_rate: int
    nights: int
    total_price: int
    breakfast_included: bool


def _clean_budget(value: Any) -> int:
    try:
        return max(0, int(value))
    except (TypeError, ValueError):
        return 0


def _count_nights(checkin: str, checkout: str) -> int:
    try:
        start = date.fromisoformat(checkin)
        end = date.fromisoformat(checkout)
    except ValueError:
        return 1

    nights = (end - start).days
    return nights if nights > 0 else 1


def _search_flights_impl(
    origin: str,
    destination: str,
    date: str,
    budget_max: int,
) -> list[dict[str, Any]]:
    """Return deterministic flight options that stay within the budget."""

    budget_max = _clean_budget(budget_max)
    if budget_max <= 0:
        return []

    base_price = max(6_000, budget_max // 3)
    price_steps = (
        base_price,
        min(budget_max, base_price + max(1_000, budget_max // 10)),
        min(budget_max, base_price + max(2_000, budget_max // 6)),
    )
    airlines = ("Aurora Air", "Northwind", "Skyline Express")
    departures = ("07:10", "11:20", "18:40")
    arrivals = ("09:05", "13:15", "20:35")

    results: list[dict[str, Any]] = []
    for index, price in enumerate(price_steps):
        results.append(
            asdict(
                FlightOption(
                    airline=airlines[index],
                    origin=origin,
                    destination=destination,
                    date=date,
                    departure=departures[index],
                    arrival=arrivals[index],
                    price=min(price, budget_max),
                    baggage_included=index != 2,
                )
            )
        )

    return results


@control(policy="flight-budget-guard", step_name="search_flights")
@tool
def _search_flights_guarded(
    origin: str,
    destination: str,
    date: str,
    budget_max: int,
) -> list[dict[str, Any]]:
    """Guarded flight search used to exercise Agent Control."""

    return _search_flights_impl(
        origin=origin,
        destination=destination,
        date=date,
        budget_max=budget_max,
    )


@tool
def search_flights(
    origin: str,
    destination: str,
    date: str,
    budget_max: int,
) -> list[dict[str, Any]]:
    """Return deterministic flight options that stay within the budget."""

    return _search_flights_guarded(
        origin=origin,
        destination=destination,
        date=date,
        budget_max=budget_max,
    )


@tool
def search_flights_plain(
    origin: str,
    destination: str,
    date: str,
    budget_max: int,
) -> list[dict[str, Any]]:
    """Return deterministic flight options without Agent Control."""

    return _search_flights_impl(
        origin=origin,
        destination=destination,
        date=date,
        budget_max=budget_max,
    )


@tool
def search_hotels(
    city: str,
    checkin: str,
    checkout: str,
    budget_per_night: int,
) -> list[dict[str, Any]]:
    """Return deterministic hotel options that stay within the nightly budget."""

    budget_per_night = _clean_budget(budget_per_night)
    if budget_per_night <= 0:
        return []

    nights = _count_nights(checkin, checkout)
    base_rate = max(5_000, budget_per_night // 2)
    rate_steps = (
        base_rate,
        min(budget_per_night, base_rate + max(750, budget_per_night // 8)),
        min(budget_per_night, base_rate + max(1_500, budget_per_night // 5)),
    )
    names = ("Harbor Stay", "City Nest", "Quiet Lodge")

    results: list[dict[str, Any]] = []
    for index, nightly_rate in enumerate(rate_steps):
        nightly_rate = min(nightly_rate, budget_per_night)
        results.append(
            asdict(
                HotelOption(
                    name=names[index],
                    city=city,
                    checkin=checkin,
                    checkout=checkout,
                    nightly_rate=nightly_rate,
                    nights=nights,
                    total_price=nightly_rate * nights,
                    breakfast_included=index != 0,
                )
            )
        )

    return results


def _activity_cost(activity: dict[str, Any]) -> int:
    raw_cost = activity.get("estimated_cost", activity.get("budget", 0))
    return _clean_budget(raw_cost)


def _assemble_itinerary_impl(
    flights: Iterable[dict[str, Any]],
    hotels: Iterable[dict[str, Any]],
    activities: Iterable[dict[str, Any]],
    user_constraints: dict[str, Any],
) -> dict[str, Any]:
    """Assemble a deterministic trip plan from already generated options."""

    flight_list = [dict(item) for item in flights]
    hotel_list = [dict(item) for item in hotels]
    activity_list = [dict(item) for item in activities]

    selected_flight = flight_list[0] if flight_list else None
    selected_hotel = hotel_list[0] if hotel_list else None
    total_budget = _clean_budget(user_constraints.get("total_budget", 0))

    activity_total = sum(_activity_cost(item) for item in activity_list)
    flight_total = _clean_budget(selected_flight["price"]) if selected_flight else 0
    hotel_total = _clean_budget(selected_hotel["total_price"]) if selected_hotel else 0
    total_cost = flight_total + hotel_total + activity_total

    warnings: list[str] = []
    if user_constraints.get("flight_allowed") is False and flight_list:
        warnings.append("Flight options are present even though flight travel was disallowed.")
    if total_budget and total_cost > total_budget:
        warnings.append(f"Total cost {total_cost} exceeds budget {total_budget}.")

    return {
        "selected_flight": selected_flight,
        "selected_hotel": selected_hotel,
        "activities": activity_list,
        "budget": {
            "total_budget": total_budget,
            "flight_total": flight_total,
            "hotel_total": hotel_total,
            "activity_total": activity_total,
            "total_cost": total_cost,
            "within_budget": not total_budget or total_cost <= total_budget,
        },
        "user_constraints": dict(user_constraints),
        "warnings": warnings,
    }


@tool
def assemble_itinerary(
    flights: Iterable[dict[str, Any]],
    hotels: Iterable[dict[str, Any]],
    activities: Iterable[dict[str, Any]],
    user_constraints: dict[str, Any],
) -> dict[str, Any]:
    """Assemble a deterministic trip plan from already generated options."""

    return _assemble_itinerary_guarded(
        flights=flights,
        hotels=hotels,
        activities=activities,
        user_constraints=user_constraints,
    )


@tool
def assemble_itinerary_plain(
    flights: Iterable[dict[str, Any]],
    hotels: Iterable[dict[str, Any]],
    activities: Iterable[dict[str, Any]],
    user_constraints: dict[str, Any],
) -> dict[str, Any]:
    """Assemble a deterministic trip plan without Agent Control."""

    return _assemble_itinerary_impl(
        flights=flights,
        hotels=hotels,
        activities=activities,
        user_constraints=user_constraints,
    )


@control(policy="itinerary-steer", step_name="assemble_itinerary")
@tool(name="assemble_itinerary_guarded")
def _assemble_itinerary_guarded(
    flights: Iterable[dict[str, Any]],
    hotels: Iterable[dict[str, Any]],
    activities: Iterable[dict[str, Any]],
    user_constraints: dict[str, Any],
) -> dict[str, Any]:
    """Guarded itinerary assembly used to exercise pre-steer controls."""

    return _assemble_itinerary_impl(
        flights=flights,
        hotels=hotels,
        activities=activities,
        user_constraints=user_constraints,
    )


@control(policy="itinerary-post-deny", step_name="assemble_itinerary")
@tool(name="assemble_itinerary_post_guarded")
def _assemble_itinerary_post_guarded(
    flights: Iterable[dict[str, Any]],
    hotels: Iterable[dict[str, Any]],
    activities: Iterable[dict[str, Any]],
    user_constraints: dict[str, Any],
) -> dict[str, Any]:
    """Guarded itinerary assembly used to exercise post-deny controls."""

    return _assemble_itinerary_impl(
        flights=flights,
        hotels=hotels,
        activities=activities,
        user_constraints=user_constraints,
    )

main.py

from __future__ import annotations

import argparse
import json
from typing import Any

from .agent import build_planner_agent
from .tools import assemble_itinerary, search_flights, search_hotels


def _demo_payload() -> dict[str, Any]:
    flights = search_flights("Tokyo", "Sapporo", "2026-05-01", 50_000)
    hotels = search_hotels("Sapporo", "2026-05-01", "2026-05-03", 15_000)
    activities = [
        {"name": "Otaru walk", "estimated_cost": 3_000},
        {"name": "Seafood market lunch", "estimated_cost": 2_500},
    ]
    constraints = {
        "total_budget": 50_000,
        "flight_allowed": True,
        "note": "Two nights in Hokkaido",
    }
    return assemble_itinerary(flights, hotels, activities, constraints)


def run_demo() -> None:
    print(json.dumps(_demo_payload(), ensure_ascii=False, indent=2))


def run_agent() -> None:
    agent = build_planner_agent()
    prompt = (
        "Plan a two-night trip from Tokyo to Sapporo with a total budget of 50000 "
        "JPY. Include one or two simple activities and keep the output concise."
    )
    response = agent(prompt)
    print(response)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Travel sandbox CLI")
    parser.add_argument(
        "--mode",
        choices=("demo", "agent"),
        default="demo",
        help="Run the deterministic demo or the Strands agent.",
    )
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.mode == "agent":
        run_agent()
        return

    run_demo()


if __name__ == "__main__":
    main()

Agent Control サーバーに control を登録するためのスクリプト

setup_controls.py

from __future__ import annotations

import asyncio
import os
import sys
from typing import Any
from pathlib import Path

import agent_control

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from src.agent import AGENT_DESCRIPTION, AGENT_NAME
import src.tools  # noqa: F401  # Registers @control-decorated steps.

CONTROL_NAME = "flight-budget-guard"
STEER_CONTROL_NAME = "itinerary-flight-conflict-steer"
POST_DENY_CONTROL_NAME = "itinerary-total-budget-deny"


def build_control_data() -> dict[str, Any]:
    return {
        "description": (
            "Block flight searches when the requested budget is at or above "
            "the 80% budget threshold used in the travel sandbox."
        ),
        "enabled": True,
        "execution": "server",
        "scope": {
            "step_types": ["tool"],
            "step_names": ["search_flights"],
            "stages": ["pre"],
        },
        "condition": {
            "selector": {"path": "input.budget_max"},
            "evaluator": {
                "name": "regex",
                "config": {
                    "pattern": r"^(?:[4-9][0-9]{4}|[1-9][0-9]{5,})$",
                },
            },
        },
        "action": {"decision": "deny"},
        "tags": ["phase-2", "flight", "budget", "deny"],
    }


def build_itinerary_control_data() -> dict[str, Any]:
    return {
        "description": (
            "Steer itinerary assembly when flights are present but the user "
            "explicitly disallows flying."
        ),
        "enabled": True,
        "execution": "server",
        "scope": {
            "step_types": ["tool"],
            "step_names": ["assemble_itinerary"],
            "stages": ["pre"],
        },
        "condition": {
            "and": [
                {
                    "selector": {"path": "input"},
                    "evaluator": {
                        "name": "regex",
                        "config": {
                            "pattern": r"flight_allowed': False",
                        },
                    },
                },
                {
                    "selector": {"path": "input"},
                    "evaluator": {
                        "name": "regex",
                        "config": {
                            "pattern": r"(Aurora Air|Northwind|Skyline Express)",
                        },
                    },
                },
            ]
        },
        "action": {
            "decision": "steer",
            "steering_context": {
                "message": (
                    "User explicitly disallowed flights, but flight options are "
                    "present. Remove flights from the itinerary and retry with "
                    "ground-only or no-flight alternatives."
                )
            },
        },
        "tags": ["phase-3", "itinerary", "flight", "steer"],
    }


def build_post_deny_control_data() -> dict[str, Any]:
    return {
        "description": "Deny itinerary assembly when the final trip exceeds the total budget.",
        "enabled": True,
        "execution": "server",
        "scope": {
            "step_types": ["tool"],
            "step_names": ["assemble_itinerary"],
            "stages": ["post"],
        },
        "condition": {
            "selector": {"path": "output"},
            "evaluator": {
                "name": "regex",
                "config": {
                    "pattern": r"'within_budget': False",
                },
            },
        },
        "action": {"decision": "deny"},
        "tags": ["phase-4", "itinerary", "budget", "deny"],
    }


def _control_id(record: dict[str, Any]) -> int | None:
    value = record.get("control_id", record.get("id"))
    return int(value) if value is not None else None


async def ensure_control(server_url: str, api_key: str | None) -> dict[str, Any]:
    existing = await agent_control.list_controls(
        server_url=server_url,
        api_key=api_key,
        name=CONTROL_NAME,
        limit=20,
    )
    for control in existing.get("controls", []):
        if control.get("name") == CONTROL_NAME:
            control_id = _control_id(control)
            if control_id is None:
                raise RuntimeError("Existing control missing control_id.")
            await agent_control.add_agent_control(
                agent_name=AGENT_NAME,
                control_id=control_id,
                server_url=server_url,
                api_key=api_key,
            )
            return {"control_id": control_id, "configured": True, "reused": True}

    created = await agent_control.create_control(
        name=CONTROL_NAME,
        data=build_control_data(),
        server_url=server_url,
        api_key=api_key,
    )
    control_id = _control_id(created)
    if control_id is None:
        raise RuntimeError("Created control missing control_id.")

    await agent_control.add_agent_control(
        agent_name=AGENT_NAME,
        control_id=control_id,
        server_url=server_url,
        api_key=api_key,
    )
    return {"control_id": control_id, "configured": True, "reused": False}


async def ensure_itinerary_control(server_url: str, api_key: str | None) -> dict[str, Any]:
    existing = await agent_control.list_controls(
        server_url=server_url,
        api_key=api_key,
        name=STEER_CONTROL_NAME,
        limit=20,
    )
    for control in existing.get("controls", []):
        if control.get("name") == STEER_CONTROL_NAME:
            control_id = _control_id(control)
            if control_id is None:
                raise RuntimeError("Existing control missing control_id.")
            await agent_control.delete_control(
                control_id=control_id,
                force=True,
                server_url=server_url,
                api_key=api_key,
            )
            break

    created = await agent_control.create_control(
        name=STEER_CONTROL_NAME,
        data=build_itinerary_control_data(),
        server_url=server_url,
        api_key=api_key,
    )
    control_id = _control_id(created)
    if control_id is None:
        raise RuntimeError("Created control missing control_id.")

    await agent_control.add_agent_control(
        agent_name=AGENT_NAME,
        control_id=control_id,
        server_url=server_url,
        api_key=api_key,
    )
    return {"control_id": control_id, "configured": True, "reused": False}


async def ensure_post_deny_control(server_url: str, api_key: str | None) -> dict[str, Any]:
    existing = await agent_control.list_controls(
        server_url=server_url,
        api_key=api_key,
        name=POST_DENY_CONTROL_NAME,
        limit=20,
    )
    for control in existing.get("controls", []):
        if control.get("name") == POST_DENY_CONTROL_NAME:
            control_id = _control_id(control)
            if control_id is None:
                raise RuntimeError("Existing control missing control_id.")
            await agent_control.delete_control(
                control_id=control_id,
                force=True,
                server_url=server_url,
                api_key=api_key,
            )
            break

    created = await agent_control.create_control(
        name=POST_DENY_CONTROL_NAME,
        data=build_post_deny_control_data(),
        server_url=server_url,
        api_key=api_key,
    )
    control_id = _control_id(created)
    if control_id is None:
        raise RuntimeError("Created control missing control_id.")

    await agent_control.add_agent_control(
        agent_name=AGENT_NAME,
        control_id=control_id,
        server_url=server_url,
        api_key=api_key,
    )
    return {"control_id": control_id, "configured": True, "reused": False}


def main() -> None:
    server_url = (
        os.getenv("AGENT_CONTROL_SERVER_URL")
        or os.getenv("AGENT_CONTROL_URL")
        or "http://localhost:8000"
    )
    api_key = os.getenv("AGENT_CONTROL_API_KEY")

    agent_control.init(
        agent_name=AGENT_NAME,
        agent_description=AGENT_DESCRIPTION,
        server_url=server_url,
        policy_refresh_interval_seconds=0,
    )

    result = asyncio.run(ensure_control(server_url=server_url, api_key=api_key))
    print(result)


if __name__ == "__main__":
    main()

実装:Phase 0

  • uv venv でセットアップを開始した。
  • uv pip install -e . による editable install を完了した。
  • docker-compose でGalileo Agent Control を起動
services:
  postgres:
    image: postgres:16-alpine
    container_name: agent_control_postgres
    ports:
      - "${AGENT_CONTROL_DB_HOST_PORT:-5432}:5432"
    environment:
      POSTGRES_DB: agent_control
      POSTGRES_USER: agent_control
      POSTGRES_PASSWORD: "${AGENT_CONTROL_POSTGRES_PASSWORD:-agent_control}"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U agent_control -d agent_control"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  server:
    platform: linux/amd64
    image: galileoai/agent-control-server:latest
    container_name: agent_control_server
    ports:
      - "${AGENT_CONTROL_SERVER_HOST_PORT:-8000}:8000"
    environment:
      AGENT_CONTROL_DB_URL: "postgresql+psycopg://agent_control:${AGENT_CONTROL_POSTGRES_PASSWORD:-agent_control}@postgres:5432/agent_control"
      AGENT_CONTROL_HOST: 0.0.0.0
      AGENT_CONTROL_PORT: 8000
      AGENT_CONTROL_API_KEY_ENABLED: ${AGENT_CONTROL_API_KEY_ENABLED:-false}
      AGENT_CONTROL_API_KEYS: ${AGENT_CONTROL_API_KEYS:-}
      AGENT_CONTROL_ADMIN_API_KEYS: ${AGENT_CONTROL_ADMIN_API_KEYS:-}
      AGENT_CONTROL_SESSION_SECRET: ${AGENT_CONTROL_SESSION_SECRET:-}
      AGENT_CONTROL_CORS_ORIGINS: ${AGENT_CONTROL_CORS_ORIGINS:-http://localhost:4000}
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

volumes:
  pgdata:
  • 再現性のある実験のために deterministic な mock tool を用意した。
    • 同じ入力を入れたら、毎回ほぼ同じ結果が返るようにした
    • 外部APIや乱数に頼らず、動きがぶれないようにした
    • あとで control の違いを比べやすくするための土台を作った

実装:Phase 1

☞「まずは制御を入れずに、旅程生成の基本動作を再現可能な形で確認する」

  • build_planner_agent(with_control=False) で control なし
  • search_flights_plain / assemble_itinerary_plain のような 素の deterministic mock tool を使う
  • その結果、同じ入力なら毎回だいたい同じ旅程が出るかを確認する

run_phase1.py

from __future__ import annotations

import json
import sys
from typing import Any
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from src.agent import build_planner_agent


def build_prompt() -> str:
    return (
        "Plan a two-night trip from Tokyo to Sapporo with a total budget of 50000 JPY. "
        "Include one or two simple activities and keep the output concise."
    )


def run() -> dict[str, Any]:
    agent = build_planner_agent(with_control=False)
    response = agent(build_prompt())
    print(response)
    return {"prompt": build_prompt(), "response": str(response)}


if __name__ == "__main__":
    result = run()
    print(json.dumps(result, ensure_ascii=False, indent=2))

実行結果

$ uv run python scripts/run_phase1.py

Tool #1: search_flights_plain

Tool #2: search_hotels

Tool #3: assemble_itinerary_plain
Here’s a concise 2-night Sapporo trip within your 50,000 JPY budget:

**Trip dates:** Apr 17–19, 2026  
**Flight:** Aurora Air Tokyo → Sapporo  
- 07:10–09:05  
- **16,666 JPY**  
- Baggage included

**Hotel:** Harbor Stay  
- 2 nights  
- **25,000 JPY total**  
- 12,500 JPY/night

**Simple activities:**
- Walk around **Odori Park**
- Visit **Sapporo Clock Tower**  
- Optional: quick stop at **Nijo Market**

**Estimated total:** **41,666 JPY**  
**Budget remaining:** **8,334 JPY**

If you want, I can also turn this into a day-by-day mini itinerary.Here’s a concise 2-night Sapporo trip within your 50,000 JPY budget:

**Trip dates:** Apr 17–19, 2026  
**Flight:** Aurora Air Tokyo → Sapporo  
- 07:10–09:05  
- **16,666 JPY**  
- Baggage included

**Hotel:** Harbor Stay  
- 2 nights  
- **25,000 JPY total**  
- 12,500 JPY/night

**Simple activities:**
- Walk around **Odori Park**
- Visit **Sapporo Clock Tower**  
- Optional: quick stop at **Nijo Market**

**Estimated total:** **41,666 JPY**  
**Budget remaining:** **8,334 JPY**

If you want, I can also turn this into a day-by-day mini itinerary.

{
  "prompt": "Plan a two-night trip from Tokyo to Sapporo with a total budget of 50000 JPY. Include one or two simple activities and keep the output concise.",
  "response": "Here’s a concise 2-night Sapporo trip within your 50,000 JPY budget:\n\n**Trip dates:** Apr 17–19, 2026  \n**Flight:** Aurora Air Tokyo → Sapporo  \n- 07:10–09:05  \n- **16,666 JPY**  \n- Baggage included\n\n**Hotel:** Harbor Stay  \n- 2 nights  \n- **25,000 JPY total**  \n- 12,500 JPY/night\n\n**Simple activities:**\n- Walk around **Odori Park**\n- Visit **Sapporo Clock Tower**  \n- Optional: quick stop at **Nijo Market**\n\n**Estimated total:** **41,666 JPY**  \n**Budget remaining:** **8,334 JPY**\n\nIf you want, I can also turn this into a day-by-day mini itinerary.\n"
}

phase1 の評価

  • Agent Control なしで planner を実行する。
  • Tokyo → Sapporo の最初の成功例を記録する。
  • 結果: deterministic な mock tool で、41,666 円の妥当な旅程が生成された

実装: phase2

「飛行機検索に入る前に、予算的に危ないなら止める」

  • search_flights を呼ぶ
  • その時点で budget_max=50,000 が flight-budget-guard の条件に引っかかる
  • なので flight 候補を返す前に deny する

run_phase2.py

from __future__ import annotations

import asyncio
import json
import os
import sys
from typing import Any
from pathlib import Path

import agent_control

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from policies.setup_controls import ensure_control
from src.agent import AGENT_DESCRIPTION, AGENT_NAME, build_planner_agent
from src.tools import search_flights


def build_prompt() -> str:
    return (
        "Plan a two-night trip from Tokyo to Sapporo with a total budget of 50000 JPY. "
        "Include one or two simple activities and keep the output concise."
    )


async def _prepare_controls() -> dict[str, Any]:
    server_url = (
        os.getenv("AGENT_CONTROL_SERVER_URL")
        or os.getenv("AGENT_CONTROL_URL")
        or "http://localhost:8000"
    )
    api_key = os.getenv("AGENT_CONTROL_API_KEY")

    agent_control.init(
        agent_name=AGENT_NAME,
        agent_description=AGENT_DESCRIPTION,
        server_url=server_url,
        policy_refresh_interval_seconds=0,
    )
    return await ensure_control(server_url=server_url, api_key=api_key)


def _verify_flight_guard() -> dict[str, Any]:
    try:
        search_flights(
            origin="Tokyo",
            destination="Sapporo",
            date="2026-05-01",
            budget_max=50_000,
        )
    except agent_control.ControlViolationError as exc:
        return {
            "blocked": True,
            "control_name": exc.control_name,
            "message": str(exc),
        }

    return {"blocked": False}


def run() -> dict[str, Any]:
    control_state = asyncio.run(_prepare_controls())
    guard_check = _verify_flight_guard()
    agent = build_planner_agent()
    prompt = build_prompt()
    try:
        response = agent(prompt)
        print(response)
        return {
            "prompt": prompt,
            "response": str(response),
            "control_state": control_state,
            "guard_check": guard_check,
            "expected": "deny",
        }
    except agent_control.ControlViolationError as exc:
        print(f"ControlViolationError: {exc}")
        return {
            "prompt": prompt,
            "error": str(exc),
            "control_name": exc.control_name,
            "control_state": control_state,
            "guard_check": guard_check,
            "expected": "deny",
        }


if __name__ == "__main__":
    result = run()
    print(json.dumps(result, ensure_ascii=False, indent=2))

実行結果

$ uv run python scripts/run_phase2.py

Tool #1: search_flights

Tool #2: search_hotels

Tool #3: search_flights
I can’t complete the flight search because the flight tool blocks budgets in the 40,000–99,999 JPY range, and your total trip budget is 50,000 JPY.

What I could confirm:
- Hotel options in Sapporo for 2 nights fit within budget:
  - Harbor Stay: 25,000 JPY total
  - City Nest: 31,250 JPY total
  - Quiet Lodge: 35,000 JPY total

That leaves too little for a Tokyo–Sapporo flight under your total budget, so the trip isn’t feasible as requested.

If you want, I can still help by:
- raising the total budget,
- switching to a cheaper destination,
- or planning a very low-cost Sapporo trip with a different transport assumption.I can’t complete the flight search because the flight tool blocks budgets in the 40,000–99,999 JPY range, and your total trip budget is 50,000 JPY.

What I could confirm:
- Hotel options in Sapporo for 2 nights fit within budget:
  - Harbor Stay: 25,000 JPY total
  - City Nest: 31,250 JPY total
  - Quiet Lodge: 35,000 JPY total

That leaves too little for a Tokyo–Sapporo flight under your total budget, so the trip isn’t feasible as requested.

If you want, I can still help by:
- raising the total budget,
- switching to a cheaper destination,
- or planning a very low-cost Sapporo trip with a different transport assumption.

{
  "prompt": "Plan a two-night trip from Tokyo to Sapporo with a total budget of 50000 JPY. Include one or two simple activities and keep the output concise.",
  "response": "I can’t complete the flight search because the flight tool blocks budgets in the 40,000–99,999 JPY range, and your total trip budget is 50,000 JPY.\n\nWhat I could confirm:\n- Hotel options in Sapporo for 2 nights fit within budget:\n  - Harbor Stay: 25,000 JPY total\n  - City Nest: 31,250 JPY total\n  - Quiet Lodge: 35,000 JPY total\n\nThat leaves too little for a Tokyo–Sapporo flight under your total budget, so the trip isn’t feasible as requested.\n\nIf you want, I can still help by:\n- raising the total budget,\n- switching to a cheaper destination,\n- or planning a very low-cost Sapporo trip with a different transport assumption.\n",
  "control_state": {
    "control_id": 1,
    "configured": true,
    "reused": true
  },
  "guard_check": {
    "blocked": true,
    "control_name": "flight-budget-guard",
    "message": "Control violation [flight-budget-guard]: Pattern '^(?:[4-9][0-9]{4}|[1-9][0-9]{5,})$' found"
  },
  "expected": "deny"
}
ri@rwin-gal:~/work/20260418/gallileo/agent-control-travel-sandbox$ 

phase2 の評価

  • flight の予算チェック用に pre-deny control を追加する。
  • 結果: flight-budget-guardbudget_max が 50,000 円のケースで search_flights を止めた。
  • その結果、agent は flight 選択を完了せず、予算に関する説明へフォールバックした。

実装: phase3

飛行機なしなのに flight が混ざった旅程を組もうとすると、assemble_itinerary を止める代わりに、flight を外してやり直すよう steer する

run_phase3.py

from __future__ import annotations

import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any

import agent_control

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from policies.setup_controls import ensure_itinerary_control
from src.agent import AGENT_DESCRIPTION, AGENT_NAME, build_planner_agent
from src.tools import assemble_itinerary, search_hotels


def build_prompt() -> str:
    return (
        "Plan a short trip from Tokyo to Sapporo on a 30000 JPY budget, but do "
        "not use flights. Keep the output concise and recommend ground-only "
        "alternatives if needed."
    )


async def _prepare_controls() -> dict[str, Any]:
    server_url = (
        os.getenv("AGENT_CONTROL_SERVER_URL")
        or os.getenv("AGENT_CONTROL_URL")
        or "http://localhost:8000"
    )
    api_key = os.getenv("AGENT_CONTROL_API_KEY")

    agent_control.init(
        agent_name=AGENT_NAME,
        agent_description=AGENT_DESCRIPTION,
        server_url=server_url,
        policy_refresh_interval_seconds=0,
    )
    return await ensure_itinerary_control(server_url=server_url, api_key=api_key)


def _verify_itinerary_steer() -> dict[str, Any]:
    flights = [
        {
            "airline": "Aurora Air",
            "origin": "Tokyo",
            "destination": "Sapporo",
            "date": "2026-05-01",
            "departure": "07:10",
            "arrival": "09:05",
            "price": 16_666,
            "baggage_included": True,
        }
    ]
    hotels = search_hotels("Sapporo", "2026-05-01", "2026-05-03", 10_000)
    activities = [
        {"name": "Odori Park stroll", "estimated_cost": 1_500},
        {"name": "Sapporo Clock Tower visit", "estimated_cost": 1_000},
    ]
    constraints = {
        "total_budget": 30_000,
        "flight_allowed": False,
        "note": "Ground-only trip requested",
    }

    try:
        assemble_itinerary(
            flights=flights,
            hotels=hotels,
            activities=activities,
            user_constraints=constraints,
        )
    except agent_control.ControlSteerError as exc:
        return {
            "steered": True,
            "control_name": exc.control_name,
            "message": str(exc),
        }

    return {"steered": False}


def run() -> dict[str, Any]:
    control_state = asyncio.run(_prepare_controls())
    steer_check = _verify_itinerary_steer()
    agent = build_planner_agent()
    prompt = build_prompt()
    try:
        response = agent(prompt)
        print(response)
        return {
            "prompt": prompt,
            "response": str(response),
            "control_state": control_state,
            "steer_check": steer_check,
            "expected": "steer",
        }
    except agent_control.ControlSteerError as exc:
        print(f"ControlSteerError: {exc}")
        return {
            "prompt": prompt,
            "error": str(exc),
            "control_name": exc.control_name,
            "control_state": control_state,
            "steer_check": steer_check,
            "expected": "steer",
        }


if __name__ == "__main__":
    result = run()
    print(json.dumps(result, ensure_ascii=False, indent=2))

実行結果

 $ uv run python scripts/run_phase3.py

Tool #1: search_hotels

Tool #2: assemble_itinerary
Short ground-only trip from Tokyo to Sapporo, within 30,000 JPY:

- Transport: No flights. Best ground-only option is the JR route via Shinkansen + limited express, but it will likely exceed your total budget by itself.
- Hotel options in Sapporo for 2 nights:
  - Harbor Stay 15,000 JPY total
  - City Nest 18,750 JPY total
  - Quiet Lodge 21,000 JPY total

Budget note:
- Since ground transport from Tokyo to Sapporo is typically far above 30,000 JPY round trip, a true Tokyo–Sapporo trip is not feasible on this budget without flights.
- If you want to stay within 30,000 JPY, consider a ground-only alternative closer to Tokyo, or increase the budget substantially.

Recommended alternative:
- Swap Sapporo for a nearby ground-access destination like Sendai, Nagano, or Niigata for a short 2-day trip.Short ground-only trip from Tokyo to Sapporo, within 30,000 JPY:

- Transport: No flights. Best ground-only option is the JR route via Shinkansen + limited express, but it will likely exceed your total budget by itself.
- Hotel options in Sapporo for 2 nights:
  - Harbor Stay 15,000 JPY total
  - City Nest 18,750 JPY total
  - Quiet Lodge 21,000 JPY total

Budget note:
- Since ground transport from Tokyo to Sapporo is typically far above 30,000 JPY round trip, a true Tokyo–Sapporo trip is not feasible on this budget without flights.
- If you want to stay within 30,000 JPY, consider a ground-only alternative closer to Tokyo, or increase the budget substantially.

Recommended alternative:
- Swap Sapporo for a nearby ground-access destination like Sendai, Nagano, or Niigata for a short 2-day trip.

{
  "prompt": "Plan a short trip from Tokyo to Sapporo on a 30000 JPY budget, but do not use flights. Keep the output concise and recommend ground-only alternatives if needed.",
  "response": "Short ground-only trip from Tokyo to Sapporo, within 30,000 JPY:\n\n- Transport: No flights. Best ground-only option is the JR route via Shinkansen + limited express, but it will likely exceed your total budget by itself.\n- Hotel options in Sapporo for 2 nights:\n  - Harbor Stay — 15,000 JPY total\n  - City Nest — 18,750 JPY total\n  - Quiet Lodge — 21,000 JPY total\n\nBudget note:\n- Since ground transport from Tokyo to Sapporo is typically far above 30,000 JPY round trip, a true Tokyo–Sapporo trip is not feasible on this budget without flights.\n- If you want to stay within 30,000 JPY, consider a ground-only alternative closer to Tokyo, or increase the budget substantially.\n\nRecommended alternative:\n- Swap Sapporo for a nearby ground-access destination like Sendai, Nagano, or Niigata for a short 2-day trip.\n",
  "control_state": {
    "control_id": 10,
    "configured": true,
    "reused": false
  },
  "steer_check": {
    "steered": true,
    "control_name": "itinerary-flight-conflict-steer",
    "message": "Control steering [itinerary-flight-conflict-steer]: Condition tree matched\nSteering context: User explicitly disallowed flights, but flight options are present. Remove flights from the itinerary and retry with ground-only or no-flight alternatives."
  },
  "expected": "steer"
}

Phase 3 の評価

  • 制約修正用に pre-steer control を追加する。
  • 結果: itinerary-flight-conflict-steerflight_allowed=False なのに flight があるときに assemble_itinerary を steer した。
  • 直接の guard check では、ground-only alternatives を促す ControlSteerError が返った。

実装: phase4

☞「最後に出来上がった旅程を見て、within_budget=False なら止める」

  • assemble_itinerary が旅程を作る
  • その結果が予算を超えていたら
  • post-execution で deny する

run_phase4.py

from __future__ import annotations

import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any

import agent_control

PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from policies.setup_controls import ensure_post_deny_control
from src.agent import AGENT_DESCRIPTION, AGENT_NAME, build_planner_agent
from src.tools import _assemble_itinerary_post_guarded, search_flights, search_hotels


def build_prompt() -> str:
    return (
        "Plan a trip from Tokyo to Sapporo with a total budget of 20000 JPY. "
        "Flights are allowed, but keep the final plan concise."
    )


async def _prepare_controls() -> dict[str, Any]:
    server_url = (
        os.getenv("AGENT_CONTROL_SERVER_URL")
        or os.getenv("AGENT_CONTROL_URL")
        or "http://localhost:8000"
    )
    api_key = os.getenv("AGENT_CONTROL_API_KEY")

    agent_control.init(
        agent_name=AGENT_NAME,
        agent_description=AGENT_DESCRIPTION,
        server_url=server_url,
        policy_refresh_interval_seconds=0,
    )
    control_state = await ensure_post_deny_control(server_url=server_url, api_key=api_key)
    agent_control.init(
        agent_name=AGENT_NAME,
        agent_description=AGENT_DESCRIPTION,
        server_url=server_url,
        policy_refresh_interval_seconds=0,
    )
    return control_state


def _verify_post_deny() -> dict[str, Any]:
    flights = search_flights("Tokyo", "Sapporo", "2026-05-01", 20_000)
    hotels = search_hotels("Sapporo", "2026-05-01", "2026-05-03", 10_000)
    activities = [
        {"name": "Odori Park stroll", "estimated_cost": 5_000},
        {"name": "Sapporo Clock Tower visit", "estimated_cost": 4_500},
    ]
    constraints = {
        "total_budget": 20_000,
        "flight_allowed": True,
        "note": "Flights allowed; test total budget enforcement",
    }

    try:
        _assemble_itinerary_post_guarded(
            flights=flights,
            hotels=hotels,
            activities=activities,
            user_constraints=constraints,
        )
    except agent_control.ControlViolationError as exc:
        return {
            "blocked": True,
            "control_name": exc.control_name,
            "message": str(exc),
        }

    return {"blocked": False}


def run() -> dict[str, Any]:
    control_state = asyncio.run(_prepare_controls())
    deny_check = _verify_post_deny()
    agent = build_planner_agent()
    prompt = build_prompt()
    try:
        response = agent(prompt)
        print(response)
        return {
            "prompt": prompt,
            "response": str(response),
            "control_state": control_state,
            "deny_check": deny_check,
            "expected": "deny",
        }
    except agent_control.ControlViolationError as exc:
        print(f"ControlViolationError: {exc}")
        return {
            "prompt": prompt,
            "error": str(exc),
            "control_name": exc.control_name,
            "control_state": control_state,
            "deny_check": deny_check,
            "expected": "deny",
        }


if __name__ == "__main__":
    result = run()
    print(json.dumps(result, ensure_ascii=False, indent=2))

実行結果

$ uv run python scripts/run_phase4.py

Tool #1: search_flights

Tool #2: search_hotels
Here’s a concise budget plan for Tokyo Sapporo on **2026-04-17**:

- **Flight:** Aurora Air **6,666 JPY**  
  - Depart **07:10** from Tokyo, arrive **09:05** in Sapporo  
  - Baggage included

- **Hotel:** Harbor Stay **6,667 JPY/night**  
  - **1 night**: 2026-04-17 to 2026-04-18

**Total:** **13,333 JPY**  
**Budget remaining:** **6,667 JPY**

If you want, I can also suggest a slightly more comfortable option while staying under budget.Here’s a concise budget plan for Tokyo Sapporo on **2026-04-17**:

- **Flight:** Aurora Air **6,666 JPY**  
  - Depart **07:10** from Tokyo, arrive **09:05** in Sapporo  
  - Baggage included

- **Hotel:** Harbor Stay **6,667 JPY/night**  
  - **1 night**: 2026-04-17 to 2026-04-18

**Total:** **13,333 JPY**  
**Budget remaining:** **6,667 JPY**

If you want, I can also suggest a slightly more comfortable option while staying under budget.

{
  "prompt": "Plan a trip from Tokyo to Sapporo with a total budget of 20000 JPY. Flights are allowed, but keep the final plan concise.",
  "response": "Here’s a concise budget plan for Tokyo → Sapporo on **2026-04-17**:\n\n- **Flight:** Aurora Air — **6,666 JPY**  \n  - Depart **07:10** from Tokyo, arrive **09:05** in Sapporo  \n  - Baggage included\n\n- **Hotel:** Harbor Stay — **6,667 JPY/night**  \n  - **1 night**: 2026-04-17 to 2026-04-18\n\n**Total:** **13,333 JPY**  \n**Budget remaining:** **6,667 JPY**\n\nIf you want, I can also suggest a slightly more comfortable option while staying under budget.\n",
  "control_state": {
    "control_id": 11,
    "configured": true,
    "reused": false
  },
  "deny_check": {
    "blocked": true,
    "control_name": "itinerary-total-budget-deny",
    "message": "Control violation [itinerary-total-budget-deny]: Pattern ''within_budget': False' found"
  },
  "expected": "deny"
}

Phase 4 の評価

  • 総予算チェック用に post-deny control を追加する。
  • 結果: itinerary-total-budget-deny は、組み立てた旅程が総予算を超えたときに post-execution で止めた。
  • 直接の guard check では、within_budget が false であることを示す ControlViolationError が返った。

Phase 5 の評価

  • control モデルについての振り返りを記録する。
  • pre-deny は、条件に合わない tool 呼び出しを 実行前に止める ための仕組みだった。
    そのため、処理が business logic に入る前に止まり、権限の境界をかなり強く切る使い方だと分かった。
  • pre-steer は、修正して続ければよいと伝えられるときに有効だった。
    今回は、flight を外す、あるいは別の条件に直すという、実行可能な指示として表現できたときにうまく機能した。
  • post-deny は、まず局所的な生成を許し、最後に全体として正しいかを確認して止めるやり方だった。
    そのため、個々の判断は通しつつ、最終結果だけで全体制約を守らせたい場合に向いていると感じた。
  • centralized policy plane は、どの control がいつ発火したかを一か所で見られるので観測しやすかった。 一方で、判断と正しさがその中心に集まるため、そこが信頼できることが前提になる構造でもあると分かった。
  • 今回は検証を優先して、control の条件判定を regex ベースの簡易実装に寄せた。構造化データに対する意味的な判定ではなく、入力や出力の文字列表現に依存しているため、本来のポリシープレーンの理想形とは距離がある。ただし、その分、発火条件を再現しやすく、deny / steer / post-deny の違いを観察するには十分だった。
  • また一方で、実行ログからは control の効き方にも差があることが見えた。
    • Phase 2 では、search_flights が deny されたあとに LLM が再試行しており、pre-deny は単に止めるだけでなく再試行を誘発することが分かった。
    • Phase 3 では、steering_context が実質的に LLM への修正文として働き、flight を外した ground-only の回答に切り替わった。
    • Phase 4 では、post-deny は assemble_itinerary を通る経路では有効だったが、LLM が tool を使わずに自力で答える経路は別に存在していた。つまり、post-deny は tool 経路の検閲としては強いが、LLM の直答経路までは捕まえない。

ダッシュボードUIもある

ダッシュボードからもコントロール作成できるみたい

可視化もある

感想

Agent Control は、エージェントのどこをそのまま任せず(@control が付いている関数=介入を明示)、いつ介入するかを deny(入力前に止める) / steer (途中で直させる)/ post-deny(出力後に検閲する) で具体的に扱える点がよかった。

あとコーディングもpythonのデコレータ(@control)とかでシンプルに表現できたり
しっかりみてないけど、ダッシュボードもあるから直感的につかえそうな気がしている。
ただしそのコンパクトさゆえに?状態管理ももたず、スコープ的には個別呼び出しの pre/post 評価のみなので守備範囲は狭い。。複雑でも致命的でもない何らかの処理に、手軽に最低限のガードレール設置は置いときたいってときに使えそうだが具体的なケースは?まだ思いついてない。

Splunk との統合の線もみえており、結果 policy に「どの team のエージェントか」「どの role が書いた policy か」「どの compliance framework に属するか」といった所属や権限ベース照合みたいなのまで統合された、Splunk エンタープライズガバナンス製品になる可能性もある。
現状、先行き不透明な製品との認識で、先述のように守備範囲も狭いため、今後ガバナンスはこれを使っていこう!とおもえるような段階ではないと思う。

ひとまず今回の検証を通して、制御点(control の発火点)と委譲境界の設計を、コードと実行結果の両方から確認できた。
そのことで、「その設計判断が実際にどう見えるか」を試せたことは体験価値だったと思う。

とりあえずはエージェントガードレールにライトに入門するための勉強用と割り切って Strands+ Galileo Agent Controlを試してみるのも良いのではないだろうか?

Discussion