Blog

ハーネス設計が新しいソフトウェアエンジニアリングになる

2026-07-24 18:30:00

ハーネス設計が新しいソフトウェアエンジニアリングになる

ハーネスエンジニアリングとは、1回のコンテキストウィンドウに収まらない長い仕事を、複数のセッションをまたいで最後まで完成させる「ハーネス(harness)」を設計することです。この記事では、ハーネス設計の変遷を解説した後、実践的なデモを説明します。対象はエージェントを設計・運用する人です。

ハーネスとは何か

ハーネス(scaffold とも呼ばれる)は、モデルをエージェントとして動かすための制御層です。モデル本体の外側で、入力を組み立て、ツール呼び出しを差配し、状態を管理し、ループを回し、結果を返す。具体的には、ファイルシステム・タスクボード・テスト・ブラウザ・シェル・git・ログ・サンドボックス・権限・チェックポイント・引き継ぎノートといった部品の集まりです(参考: 「The Evolution of Agents: From Context Engineering to Long-running Harnesses」)。

なぜ要るのか - セッションを分けると出る3つの症状

長い仕事は、必ず1つのコンテキストウィンドウを超えます。そのため、作業を複数のセッションに分けるしかありません。ところがセッションを分けた瞬間、単一のコンテキストでは出なかった3つの症状が現れます。

  • 健忘(amnesia)
    • 新しいセッションは、前のセッションが何をしたかを知らない。
  • コンテキスト腐敗(context rot)
    • コンテキストが長いほど要点を見失い、初期の情報がノイズに埋もれる。
  • コンテキスト不安(context anxiety)
    • 上限が近いと感じたモデルが焦って切り上げ、半完成を「完成」と言い張る。

ハーネスは、この3つを解決する「引き継ぎと境界の設計」です。

ハーネス設計の変遷

第1世代: initializer + coding agent

最初のハーネスとして、セッション間でコンテキストを引き継ぐため、Two-Agent Patternが実装されました(参考: 「Effective harnesses for long-running agents」)。

  • initializer(初期化: 最初に一度だけ走り、作業状況を迅速に把握できる仕組みを提供)
    1. プロンプトから検証項目一覧を作成する
    2. エージェントが行った処理のログを記録する環境を準備する
    3. 初期化スクリプトを作成する
  • coding agent(実装: セッションごとに検証項目を逐次的に進める)
    1. 進捗ログと検証項目から現状を把握する
    2. 最優先の1項目を選んで実装し、テストする
    3. 次セッションへ引き継ぐ(進捗ログを更新する)

各セッションが小さな問題を残すと、問題は複利的に増加します。そのため、ハーネスは「残された状態」も評価・記録することが重要です。

第2世代: planner + generator + evaluator

第1世代で解決できなかった問題は「自己評価」です。エージェントは平凡な出来栄えであっても、自分の作品を評価する際に肯定的な評価を下す傾向がありました。

そこで第2世代はGANからヒントを得て、生成エージェントと評価エージェントに分離し、役割を3つに分けます(参考: 「Harness design for long-running application development」)。

  • planner(計画)
    • 大まかなプロンプトから詳細な仕様・検証項目一覧・設計方針・実装計画へ具体化し、スコープ不足を解消する
  • generator(生成)
    • 計画に従い、スプリント単位で検証項目を実装する
  • evaluator(評価)
    • 結果を確認し、問題点を指摘し、合格か不合格かを判断する

各スプリントは開始前にgeneratorとevaluatorで「何をもって完成か(今回やること・受け入れ基準・対象外)」を協議し、事前に定義します(参考: 「Demystifying evals for AI agents」)。

このプロセスが曖昧なユーザーストーリーをテスト可能な契約へと具体化します。そして、スプリントが不合格となった際、evaluator-optimizerパターンで改善します。

まさに、人間チームにおける製品要件の明確化と品質保証テスト計画の策定→不具合修正のプロセスに似ています。

ハーネスとモデル: Coevolution

モデルの進化に合わせてハーネス設計は見直すべきです。なぜなら、ハーネスの各要素は、「今のモデルが単独ではできないこと」の仮定を暗に含んでいるためです。

モデルが context anxiety を持つからコンテキストのリセットを足す。計画が下手だから planner を足す。自己評価が甘いから evaluator を足す。

しかし、モデルが強くなると、その仮定が崩れます(参考: 「Scaling Managed Agents: Decoupling the brain from the hands」)。

モデル単独で確実にこなせるタスクが増えると、以前は有用だったステップが不要な遅延とコストに変わるのです。そのため、モデルが改善されたら最小構成から始め、evaluator で本当の失敗モードを見つけ、その失敗にだけ構造を足し、不要なハーネスは外す必要があります。

ハーネスに手順書を持たせる - Agent Skills

ハーネスは制御層ですが、そこに「この作業はこうやる」という手順書としてAgent Skillsを定義できます(参考: 「Equipping agents for the real world with Agent Skills」)。

スキルは1つのフォルダで、中身は SKILL.md(指示を書いたMarkdown+YAML frontmatter)と、必要に応じた外部ファイル(scripts/references/assets/ など)です。

例: スプリント計画を作るスキル

---
name: sprint-planner
description: Linearのスプリント計画を作る。「スプリントを立てて」「タスクを作って」と言われたら使う。
---
# 手順
1. Linear(MCP)から現状を取得
2. ベロシティと空き容量を分析
3. 優先度をつけてタスクを作成

スキルは3段階で読み込まれます。

  1. YAML frontmatter: 常にシステムプロンプトに載る。「いつ使うか」だけを数十トークンで示す。
  2. SKILL.md 本文: そのタスクに関係すると判断したときだけ読み込む。
  3. リンク先ファイル(references/ 等): さらに必要になったときだけ辿る。

本文は簡潔に(目安500行以下)、細部は references/ に逃がすのが定石です。

Agent SkillsはClaude だけでなくGitHub Copilot・Cursor・OpenAI Codex・Gemini CLI など多くのツールが採用しています。

公式スキルを使う - AnthropicとOpenAIの配布物

スキルは自作もできますが、両社ともすぐ使える公式スキルを公開レポジトリで配っています。しかも同じ agentskills.io のオープン標準に沿うので、片方で書いた SKILL.md がもう片方でも動きます(write once, use everywhere)。

anthropics/skills は用途の広いサンプル集で、大きく4系統に分かれます。

  • Document
    • ファイル作成機能(docx / pdf / pptx / xlsx)
  • Creative & Design:
    • アート・音楽・デザインなどの創作系サンプル。
  • Development & Technical
    • Webアプリのテスト、MCPサーバ生成などの技術系サンプル。
  • Enterprise & Communication
    • コミュニケーション・ブランディングなどの業務系サンプル。

スキルのほかに、標準仕様(spec)とテンプレートも同梱されています。入手経路は3つです。

  • Claude Code: プラグインマーケットから(document-skills / example-skills)
  • Claude.ai: 有料プランに同梱
  • Claude API: Skills API で既製を使うか、自作をアップロード

OpenAI(openai/skills)は Codex 向けのカタログで、3段階に分けて管理します。

  • .system : 最新 Codex に自動インストール
  • .curated : $skill-installer <名前> で入れる厳選版
  • .experimental : フォルダやURLを指定して入れる実験版

導入後は Codex を再起動して読み込み、ライセンスはスキルごとに LICENSE.txt で管理します。

Anthropic anthropics/skills

OpenAI openai/skills

位置づけ

幅広いサンプル+標準仕様の本体

Codex向けの公式カタログ

主な中身

Document / Creative / Dev / Enterprise

system / curated / experimental

配布・導入

Claude Code プラグイン / Claude.ai同梱 / Skills API

$skill-installer + .system自動導入

ライセンス

Documentはsource-available、他はApache 2.0中心

スキルごとに LICENSE.txt

OpenAIも同じ結論に - Codex流のハーネスエンジニアリング

ここまではAnthropic側のハーネス設計の変遷を追ってきましたが、同じ結論にOpenAIも到達しています。「ハーネスエンジニアリング」という言葉を前面に出したのは、むしろ OpenAI の Codex チームでした(参考: 「Harness engineering: leveraging Codex in an agent-first world」)。彼らは5か月間、人手で書いたコードを1行も入れず、Codex だけで約100万行・約1,500本のPRを積み上げて社内向け製品を出しました。

合言葉は「人が舵を取り、エージェントが実装する(Humans steer. Agents execute.)」。

エンジニアの仕事は、コードを書くことから、環境・ハーネス・フィードバックループを設計することへ移った、と明言しています。面白いのは、彼らがぶつかった問題と対処が、Anthropic 側とほぼ同じ形をしていることです。25時間ぶっ通しでCodexを走らせた別の実験でも、核は同じでした。仕様・計画・実装手順・状況を外部メモリ(prompt.mdplans.mdimplement.mddocumentation.md)に置き、各マイルストーンで検証させてドリフトを防ぎます(参考: 「Run long horizon tasks with Codex」)。

実検証: スキルを1つ作って、札幌市の機構図への質問に答えさせる

小さなスキルを1つ作って動かしてみます。

お題は「札幌市が公開する『機構図』PDF(局・部・課・区がびっしり並ぶ組織図)を読み、"税に関わる部は?""交通を扱う局は?"のような自然文の質問に答える」。この資料は124ページありますが、構造化はツール(custom tool)に任せ、解釈はエージェント(Claude)に任せます。スキル自身はLLMを内蔵しません。

まず SKILL.md は、そのツールをいつ呼び、結果をどう使うかだけを書きます。

---
name: sapporo-orgchart-qa
description: 札幌市の機構図について、局・部の一覧や所属を自然文で質問されたときに使う。税、交通、組織図、機構図、どの局・どの部かという質問が対象。
---

# 札幌市 機構図 Q&A

札幌市の機構図に関する質問には、必ず `get_sapporo_orgchart` custom tool を呼ぶ。

## 手順

1. `get_sapporo_orgchart` を入力なしで呼ぶ。
2. 返却されたJSONの `bureaus` と `departments` だけを根拠に答える。
3. 回答にはJSONの `year` を添える。
4. JSONにない内容は推測せず、「抽出データでは確認できない」と答える。
5. 電話番号・職員名は、明示的に聞かれない限り出力しない。

## JSONの形

```json
{
  "source": "令和8年度 札幌市機構図",
  "year": "令和8年度",
  "bureaus": ["財政局"],
  "departments": {
    "財政局": ["財政部", "税政部", "管財部"]
  }
}
```

取得元とパース条件は `references/` を参照する。

常時ロードされるのは YAML frontmatter の数十トークンだけ(「機構図の質問ならこれ」)。

本文の手順はタスクに関係するときだけ、細かなパース仕様(references/parsing-notes.md)は必要になったときだけ読み込みます。

# 機構図パース規則

## 目次から局一覧を取得する

局などの上位組織は本文の住所や説明にも現れるため、一覧と数え上げには目次を使う。

- 「名称 + ドットリーダー + ページ番号」の行から名称を抽出する。
- 名称が `局` で終わるものを候補にする。
- `委員会` を含むものと `事務局` で終わるものは除外する。

## 本文から局と部を対応付ける

本文を上から順に走査する。

1. 目次から得た局名と完全一致する単独行を見つけたら、現在の局を切り替える。
2. その後に現れる行の先頭トークンが `○○部長` なら、`長` を除いた `○○部` を現在の局へ紐づける。
3. `担当部長` と `副○○部長` は組織上の部ではないため除外する。
4. 同じ部名が複数回現れても重複させない。

## 注意

- PDFBoxはOCRではない。画像だけのPDFには別途OCRが必要。
- PDFの段組みや文字配置が年度版で変わると、読み順と改行が変化する可能性がある。
- 年度によって組織は変わるため、回答には必ず版を付ける。
- 電話番号・職員名は、明示的に聞かれない限り出力しない。

重い実処理は custom tool へ逃がすので、コンテキストを食いません。

取得元URLは、コードにもSKILL.mdにも書かず、カタログ(references/sources.json)に id で持たせます。

{
  "sources": [
    {
      "id": "orgchart-r8",
      "name": "令和8年度 札幌市機構図",
      "type": "orgchart",
      "year": "令和8年度",
      "url": "https://www.city.sapporo.jp/org/address/documents/r8kikouzu.pdf"
    }
  ]
}

資料が増えても、カタログに1行足すだけで、コードもSKILL.mdも触りません。

配布する: .skill にまとめる

作ったスキルは、スキルフォルダを zip にすれば配れます。

Cowork や Claude.ai は .skill(中身は zip)を取り込み、Claude Code なら zip せず .claude/skills/ にフォルダを置くだけです。

ファイル名は SKILL.md(大文字)、フォルダ名=スキル名に揃えます。

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * sapporo-orgchart-qa/ を配布用の .skill(ZIP)へまとめる。
 *
 * ZIP内にはトップレベルディレクトリを残す。
 *
 * sapporo-orgchart-qa.skill
 * └── sapporo-orgchart-qa/
 *     ├── SKILL.md
 *     └── references/
 *         ├── parsing-notes.md
 *         └── sources.json
 */
public final class PackageSkill {

  private static final String SKILL_DIR = "sapporo-orgchart-qa";
  private static final String OUTPUT = SKILL_DIR + ".skill";

  private PackageSkill() {}

  public static void main(String[] args) throws IOException {
    Path skillRoot = Path.of(SKILL_DIR);
    Path skillMd = skillRoot.resolve("SKILL.md");
    if (!Files.isDirectory(skillRoot)) {
      throw new IOException("スキルディレクトリが見つかりません: " + skillRoot);
    }
    if (!Files.isRegularFile(skillMd)) {
      throw new IOException("SKILL.md が見つかりません: " + skillMd);
    }

    Path output = Path.of(OUTPUT);
    Files.deleteIfExists(output);

    try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(output));
        Stream<Path> paths = Files.walk(skillRoot)) {

      paths
          .filter(Files::isRegularFile)
          .filter(PackageSkill::shouldInclude)
          .sorted(Comparator.naturalOrder())
          .forEach(path -> addEntry(zip, path));
    }

    System.out.println("created: " + output.toAbsolutePath());
  }

  private static boolean shouldInclude(Path path) {
    String filename = path.getFileName().toString();
    return !filename.equals(".DS_Store") && !filename.endsWith("~");
  }

  private static void addEntry(ZipOutputStream zip, Path file) {
    String entryName = file.toString().replace(File.separatorChar, '/');

    try {
      zip.putNextEntry(new ZipEntry(entryName));
      Files.copy(file, zip);
      zip.closeEntry();
    } catch (IOException exception) {
      throw new IllegalStateException("ZIPへの追加に失敗しました: " + file, exception);
    }
  }
}

本検証では、Managed Agents を利用するため、ループとサンドボックスはサーバ側で回り、custom tool の実行だけがクライアント側で実行します。

セットアップとして、スキル(SKILL.md の zip)を作成した後、Skills API に登録し、custom tool とともに Agent へ添付します。これでサーバ側のエージェントが SKILL.md(いつ・どうツールを使うか)を読む形になります。

実行時は Session を張って質問を送るだけです。エージェントが agent.custom_tool_use を出したら、こちらが抽出を実行し、user.custom_tool_result に JSON を載せて返す。回答はエージェントがその JSON から作ります。

import static com.google.common.base.Preconditions.checkArgument;

import com.google.common.base.CharMatcher;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.text.Normalizer;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;

/**
 * 札幌市機構図を取得・解析し、Managed Agentsのcustom toolへ返すJSONを生成する。
 *
 * <p>LLMは呼ばない。次の処理だけを決定的に行う。
 *
 * <ol>
 *   <li>sources.jsonをJacksonで読む
 *   <li>PDFをJava HttpClientで取得する
 *   <li>PDFBoxでテキストを抽出する
 *   <li>Guavaを使い、局一覧と局→部の対応を不変コレクションへまとめる
 *   <li>JacksonでJSONを生成する
 * </ol>
 */
public final class OrgchartQa {

  private static final String SOURCE_ID = "orgchart-r8";
  private static final Path SKILL_DIR = Path.of("sapporo-orgchart-qa");
  private static final Path CATALOG = SKILL_DIR.resolve("references/sources.json");
  private static final Path CACHE_DIR = Path.of("target/data");
  private static final ObjectMapper JSON = JsonMapper.builder().build();
  private static final HttpClient HTTP =
      HttpClient.newBuilder()
          .followRedirects(HttpClient.Redirect.NORMAL)
          .connectTimeout(Duration.ofSeconds(20))
          .build();

  private static final Splitter TOKENS =
      Splitter.on(CharMatcher.whitespace()).trimResults().omitEmptyStrings();

  /** 目次の「組織名 + ドットリーダー + 任意のページ番号」。 */
  private static final Pattern INDEX_ROW =
      Pattern.compile("^(.+?)\\s*[・・…\\.]{3,}\\s*(?:[0-90-9]+)?\\s*$");

  /** 行頭トークンが「○○部長」の場合に、○○部を取得する。 */
  private static final Pattern DEPARTMENT_HEAD = Pattern.compile("^(.+?部)長$");

  private OrgchartQa() {}

  /** ManagedAgentRunnerから呼ぶcustom toolの本体。 */
  public static String extractJson() throws IOException, InterruptedException {
    return JSON.writeValueAsString(extract());
  }

  /** custom tool失敗時も、文字列連結ではなくJacksonでJSONにする。 */
  public static String errorJson(String message) {
    Map<String, String> error =
        ImmutableMap.of(
            "error", "orgchart extraction failed", "message", Strings.nullToEmpty(message));
    return JSON.writeValueAsString(error);
  }

  /** 単体確認用。Managed Agentsを使わず抽出結果だけを整形表示する。 */
  public static void main(String[] args) throws Exception {
    System.out.println(JSON.writerWithDefaultPrettyPrinter().writeValueAsString(extract()));
  }

  private static OrgchartResult extract() throws IOException, InterruptedException {
    Source source = source(SOURCE_ID);
    String text = loadText(source);
    List<String> bureaus = bureaus(text);
    Map<String, List<String>> departments = departmentsByBureau(text, bureaus);

    return new OrgchartResult(source.name(), source.year(), bureaus, departments);
  }

  /** sources.jsonから資料IDに一致する定義を取得する。 */
  private static Source source(String sourceId) throws IOException {
    checkArgument(!Strings.isNullOrEmpty(sourceId), "sourceId is required");

    if (!Files.isRegularFile(CATALOG)) {
      throw new IOException("sources.jsonが見つかりません: " + CATALOG);
    }

    try (Reader reader = Files.newBufferedReader(CATALOG, StandardCharsets.UTF_8)) {
      Catalog catalog = JSON.readValue(reader, Catalog.class);
      if (catalog == null || catalog.sources() == null) {
        throw new IOException("sources.jsonの形式が不正です: sources がありません");
      }

      for (Source source : catalog.sources()) {
        if (source != null && sourceId.equals(source.id())) {
          validateSource(source);
          return source;
        }
      }
    }

    throw new IOException("sources.jsonに資料IDがありません: " + sourceId);
  }

  /** テキスト指定がなければ、PDF取得→PDFBox抽出を行う。 */
  private static String loadText(Source source) throws IOException, InterruptedException {
    checkArgument(source != null, "source is required");

    String textOverride = System.getenv("ORGCHART_TEXT_FILE");
    if (!Strings.isNullOrEmpty(textOverride)) {
      Path textFile = Path.of(textOverride);
      if (!Files.isRegularFile(textFile)) {
        throw new IOException("ORGCHART_TEXT_FILEが見つかりません: " + textFile);
      }
      return Files.readString(textFile, StandardCharsets.UTF_8);
    }

    Path pdf = downloadPdf(source);
    String text = extractPdfText(pdf);

    Files.createDirectories(CACHE_DIR);
    Files.writeString(CACHE_DIR.resolve(source.id() + ".txt"), text, StandardCharsets.UTF_8);

    return text;
  }

  /** PDFを一時ファイルへ取得し、PDFヘッダ確認後にキャッシュへ置く。 */
  private static Path downloadPdf(Source source) throws IOException, InterruptedException {
    checkArgument(source != null, "source is required");

    Files.createDirectories(CACHE_DIR);

    Path destination = CACHE_DIR.resolve(source.id() + ".pdf");
    boolean refresh = Boolean.parseBoolean(System.getenv().getOrDefault("REFRESH_SOURCE", "false"));

    if (!refresh && Files.isRegularFile(destination) && Files.size(destination) > 0) {
      return destination;
    }

    HttpRequest request =
        HttpRequest.newBuilder(URI.create(source.url()))
            .timeout(Duration.ofMinutes(2))
            .header("Accept", "application/pdf")
            .header("User-Agent", "fundoshi-harness-engineering-demo/1.0")
            .GET()
            .build();

    HttpResponse<byte[]> response = HTTP.send(request, HttpResponse.BodyHandlers.ofByteArray());

    if (response.statusCode() < 200 || response.statusCode() >= 300) {
      throw new IOException("PDF取得に失敗しました: HTTP " + response.statusCode() + " / " + source.url());
    }

    byte[] body = response.body();
    if (!looksLikePdf(body)) {
      String contentType = response.headers().firstValue("Content-Type").orElse("unknown");
      throw new IOException("取得内容がPDFではありません: Content-Type=" + contentType);
    }

    Path temporary = CACHE_DIR.resolve(source.id() + ".pdf.part");
    Files.write(temporary, body);
    try {
      Files.move(
          temporary,
          destination,
          StandardCopyOption.REPLACE_EXISTING,
          StandardCopyOption.ATOMIC_MOVE);
    } catch (AtomicMoveNotSupportedException exception) {
      Files.move(temporary, destination, StandardCopyOption.REPLACE_EXISTING);
    }

    return destination;
  }

  /** PDFBoxで文字レイヤーをプレーンテキストへ変換する。OCRは行わない。 */
  private static String extractPdfText(Path pdf) throws IOException {
    checkArgument(pdf != null, "pdf is required");

    if (!Files.isRegularFile(pdf)) {
      throw new IOException("PDFが見つかりません: " + pdf);
    }

    try (PDDocument document = Loader.loadPDF(new File(pdf.toString()))) {
      PDFTextStripper stripper = new PDFTextStripper();
      stripper.setSortByPosition(true);
      return stripper.getText(document);
    }
  }

  /** 目次形式の行から組織名を抜き出す。 */
  private static List<String> parseIndex(String text) {
    checkArgument(text != null, "text is required");

    ImmutableList.Builder<String> names = ImmutableList.builder();

    for (String raw : text.split("\\R")) {
      String line = normalize(raw);
      Matcher matcher = INDEX_ROW.matcher(line);

      if (matcher.matches()) {
        String name = matcher.group(1).strip();
        if (!name.isEmpty()) {
          names.add(name);
        }
      }
    }

    return names.build();
  }

  /** 委員会・事務局を除く執行機関としての「局」。 */
  private static List<String> bureaus(String text) {
    Set<String> unique = new LinkedHashSet<>();

    for (String name : parseIndex(text)) {
      if (name.endsWith("局") && !name.contains("委員会") && !name.endsWith("事務局")) {
        unique.add(name);
      }
    }

    return ImmutableList.copyOf(unique);
  }

  /** 本文を上から走査する。 単独行の局名でcurrentBureauを切り替え、行頭の「○○部長」を現在の局へ紐づける。 */
  private static Map<String, List<String>> departmentsByBureau(String text, List<String> bureaus) {

    checkArgument(text != null, "text is required");
    checkArgument(bureaus != null, "bureaus is required");

    Set<String> knownBureaus = ImmutableSet.copyOf(bureaus);
    Map<String, LinkedHashSet<String>> mutable = new LinkedHashMap<>();
    for (String bureau : bureaus) {
      mutable.put(bureau, new LinkedHashSet<>());
    }

    String currentBureau = null;

    for (String raw : text.split("\\R")) {
      String line = normalize(raw);

      if (knownBureaus.contains(line)) {
        currentBureau = line;
        continue;
      }

      if (currentBureau == null || line.isEmpty()) {
        continue;
      }

      List<String> tokens = TOKENS.splitToList(line);
      if (tokens.isEmpty()) {
        continue;
      }

      Matcher matcher = DEPARTMENT_HEAD.matcher(tokens.getFirst());
      if (!matcher.matches()) {
        continue;
      }

      String department = matcher.group(1);
      if (department.contains("担当") || department.contains("副")) {
        continue;
      }

      mutable.get(currentBureau).add(department);
    }

    ImmutableMap.Builder<String, List<String>> result = ImmutableMap.builder();
    mutable.forEach((bureau, departments) -> result.put(bureau, ImmutableList.copyOf(departments)));

    return result.buildOrThrow();
  }

  private static String normalize(String value) {
    String normalized = Normalizer.normalize(Strings.nullToEmpty(value), Normalizer.Form.NFKC);

    return normalized.replace('\u3000', ' ').strip();
  }

  private static boolean looksLikePdf(byte[] bytes) {
    return bytes != null
        && bytes.length >= 5
        && bytes[0] == '%'
        && bytes[1] == 'P'
        && bytes[2] == 'D'
        && bytes[3] == 'F'
        && bytes[4] == '-';
  }

  private static void validateSource(Source source) throws IOException {
    try {
      checkArgument(!Strings.isNullOrEmpty(source.id()), "id is required");
      checkArgument(!Strings.isNullOrEmpty(source.name()), "name is required");
      checkArgument(!Strings.isNullOrEmpty(source.year()), "year is required");
      checkArgument(!Strings.isNullOrEmpty(source.url()), "url is required");
    } catch (IllegalArgumentException exception) {
      throw new IOException("sources.jsonの資料定義に必須項目がありません: " + exception.getMessage(), exception);
    }
  }

  record Catalog(List<Source> sources) {}

  record Source(String id, String name, String type, String year, String url) {}

  record OrgchartResult(
      String source, String year, List<String> bureaus, Map<String, List<String>> departments) {}
}
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;

import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.core.MultipartField;
import com.anthropic.core.http.StreamResponse;
import com.anthropic.models.beta.agents.AgentCreateParams;
import com.anthropic.models.beta.agents.AgentUpdateParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsAgentToolset20260401Params;
import com.anthropic.models.beta.agents.BetaManagedAgentsCustomToolInputSchema;
import com.anthropic.models.beta.agents.BetaManagedAgentsCustomToolParams;
import com.anthropic.models.beta.agents.BetaManagedAgentsModel;
import com.anthropic.models.beta.agents.BetaManagedAgentsModelConfigParams;
import com.anthropic.models.beta.environments.EnvironmentCreateParams;
import com.anthropic.models.beta.sessions.SessionCreateParams;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsStreamSessionEvents;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsTextBlock;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsUserCustomToolResultEventParams;
import com.anthropic.models.beta.sessions.events.BetaManagedAgentsUserMessageEventParams;
import com.anthropic.models.beta.sessions.events.EventSendParams;
import com.anthropic.models.beta.skills.SkillCreateParams;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;

/**
 * Managed Agentsのセットアップと実行を行うクライアント。
 *
 * セットアップ:
 *   mvn -q compile exec:java -Dexec.args="setup"
 *
 * 質問:
 *   ANTHROPIC_ENVIRONMENT_ID と ANTHROPIC_AGENT_ID を設定してから
 *   mvn -q compile exec:java -Dexec.args="税に関わる部はどの局?"
 */
public final class ManagedAgentRunner {

  static final String TOOL_NAME = "get_sapporo_orgchart";
  static final Path SKILL_DIRECTORY = Path.of("sapporo-orgchart-qa");

  private final AnthropicClient client;

  ManagedAgentRunner(AnthropicClient client) {
    this.client = client;
  }

  public static void main(String[] args) throws Exception {
    ManagedAgentRunner runner = new ManagedAgentRunner(AnthropicOkHttpClient.fromEnv());

    if (args.length > 0 && "setup".equalsIgnoreCase(args[0])) {
      SetupResult setup = runner.setup();
      System.out.println("created skill      : " + setup.skillId());
      System.out.println("created environment: " + setup.environmentId());
      System.out.println("created agent      : " + setup.agentId());
      System.out.println();
      System.out.println("export ANTHROPIC_ENVIRONMENT_ID=\"" + setup.environmentId() + "\"");
      System.out.println("export ANTHROPIC_AGENT_ID=\"" + setup.agentId() + "\"");
      return;
    }

    String environmentId = requiredEnvironment("ANTHROPIC_ENVIRONMENT_ID");
    String agentId = requiredEnvironment("ANTHROPIC_AGENT_ID");
    String question =
        args.length == 0 ? "税に関わる部はどの局にありますか。交通に関わる部も教えてください。" : String.join(" ", args);

    System.out.println("A." + runner.ask(environmentId, agentId, question));
  }

  /** Agent・Environment・Skillは最初に一度だけ作成し、IDを保存して使い回す。 */
  SetupResult setup() throws IOException {
    ensureSkillDirectory();

    var environment =
        client
            .beta()
            .environments()
            .create(EnvironmentCreateParams.builder().name("sapporo-orgchart-environment").build());

    String skillId = uploadSkill();

    var agentV1 =
        client
            .beta()
            .agents()
            .create(
                AgentCreateParams.builder()
                    .name("sapporo-orgchart-agent")
                    .model(
                        BetaManagedAgentsModelConfigParams.builder()
                            .id(BetaManagedAgentsModel.CLAUDE_SONNET_5)
                            .build())
                    .system("札幌市の機構図に関する質問には、添付スキルに従い、custom toolの結果だけを根拠に日本語で簡潔に答えてください。")
                    // Skillはサンドボックス内のSKILL.mdやreferencesをreadツールで読む。
                    // agent_toolsetを追加すると、readを含む組み込みツールが既定で有効になる。
                    .addTool(
                        BetaManagedAgentsAgentToolset20260401Params.builder()
                            .type(
                                BetaManagedAgentsAgentToolset20260401Params.Type
                                    .AGENT_TOOLSET_20260401)
                            .build())
                    .addTool(
                        BetaManagedAgentsCustomToolParams.builder()
                            .type(BetaManagedAgentsCustomToolParams.Type.CUSTOM)
                            .name(TOOL_NAME)
                            .description("札幌市の機構図から、局一覧と局から部への対応をJSONで返す。入力は不要。")
                            .inputSchema(
                                BetaManagedAgentsCustomToolInputSchema.builder()
                                    .properties(
                                        BetaManagedAgentsCustomToolInputSchema.Properties.builder()
                                            .build())
                                    .required(ImmutableList.of())
                                    .build())
                            .build())
                    .build());

    var agent =
        client
            .beta()
            .agents()
            .update(
                AgentUpdateParams.builder()
                    .agentId(agentV1.id())
                    .version(agentV1.version())
                    .addCustomSkill(skillId)
                    .build());

    return new SetupResult(environment.id(), agent.id(), skillId);
  }

  /** Sessionを毎回作成し、custom tool要求が来たらJava側でOrgchartQaを実行する。 */
  String ask(String environmentId, String agentId, String question)
      throws IOException, InterruptedException {

    checkArgument(!Strings.isNullOrEmpty(environmentId), "environmentId is required");
    checkArgument(!Strings.isNullOrEmpty(agentId), "agentId is required");
    checkArgument(!Strings.isNullOrEmpty(question), "question is required");

    var session =
        client
            .beta()
            .sessions()
            .create(
                SessionCreateParams.builder().environmentId(environmentId).agent(agentId).build());

    client
        .beta()
        .sessions()
        .events()
        .send(
            EventSendParams.builder()
                .sessionId(session.id())
                .addUserMessageEvent(
                    ImmutableList.of(
                        BetaManagedAgentsUserMessageEventParams.Content.ofText(
                            BetaManagedAgentsTextBlock.builder()
                                .type(BetaManagedAgentsTextBlock.Type.TEXT)
                                .text(question)
                                .build())))
                .build());

    System.out.println("Q." + question);
    System.out.println();

    StringBuilder answer = new StringBuilder();

    try (StreamResponse<BetaManagedAgentsStreamSessionEvents> response =
        client.beta().sessions().events().streamStreaming(session.id())) {

      Iterator<BetaManagedAgentsStreamSessionEvents> events = response.stream().iterator();

      while (events.hasNext()) {
        BetaManagedAgentsStreamSessionEvents event = events.next();

        if (event.isAgentCustomToolUse()) {
          var toolUse = event.asAgentCustomToolUse();

          if (TOOL_NAME.equals(toolUse.name())) {
            String toolResult = executeToolSafely();
            System.out.println("tool_result: " + toolResult);

            client
                .beta()
                .sessions()
                .events()
                .send(
                    EventSendParams.builder()
                        .sessionId(session.id())
                        .addEvent(
                            BetaManagedAgentsUserCustomToolResultEventParams.builder()
                                .type(
                                    BetaManagedAgentsUserCustomToolResultEventParams.Type
                                        .USER_CUSTOM_TOOL_RESULT)
                                .customToolUseId(toolUse.id())
                                .addTextContent(toolResult)
                                .build())
                        .build());
          }
        } else if (event.isAgentMessage()) {
          event.asAgentMessage().content().forEach(block -> answer.append(block.text()));
        } else if (event.isSessionError()) {
          throw new IllegalStateException(
              "Managed Agents session error: " + event.asSessionError());
        }

        if (event.isSessionStatusIdle() && event.asSessionStatusIdle().stopReason().isEndTurn()) {
          break;
        }
      }
    }

    if (answer.isEmpty()) {
      throw new IllegalStateException("エージェントの回答テキストを取得できませんでした");
    }

    return answer.toString().strip();
  }

  private String executeToolSafely() {
    try {
      return OrgchartQa.extractJson();
    } catch (Exception exception) {
      String message =
          exception.getMessage() == null
              ? exception.getClass().getSimpleName()
              : exception.getMessage();
      return OrgchartQa.errorJson(message);
    }
  }

  private String uploadSkill() throws IOException {
    var builder =
        SkillCreateParams.builder()
            .displayTitle("sapporo-orgchart-qa-" + System.currentTimeMillis());

    ImmutableList.Builder<Path> filesBuilder = ImmutableList.builder();
    try (var paths = Files.walk(SKILL_DIRECTORY)) {
      paths
          .filter(Files::isRegularFile)
          .filter(path -> !path.getFileName().toString().equals(".DS_Store"))
          .sorted(Comparator.naturalOrder())
          .forEach(filesBuilder::add);
    }
    List<Path> files = filesBuilder.build();

    for (Path file : files) {
      byte[] content = Files.readAllBytes(file);
      String filename =
          SKILL_DIRECTORY.getFileName()
              + "/"
              + SKILL_DIRECTORY.relativize(file).toString().replace('\\', '/');
      InputStream input = new ByteArrayInputStream(content);

      builder.addFile(
          MultipartField.<InputStream>builder().value(input).filename(filename).build());
    }

    return client.beta().skills().create(builder.build()).id();
  }

  private static void ensureSkillDirectory() throws IOException {
    if (!Files.isRegularFile(SKILL_DIRECTORY.resolve("SKILL.md"))) {
      throw new IOException("SKILL.mdが見つかりません: " + SKILL_DIRECTORY.resolve("SKILL.md"));
    }
    if (!Files.isRegularFile(SKILL_DIRECTORY.resolve("references/sources.json"))) {
      throw new IOException(
          "sources.jsonが見つかりません: " + SKILL_DIRECTORY.resolve("references/sources.json"));
    }
  }

  private static String requiredEnvironment(String name) {
    String value = System.getenv(name);
    checkState(!Strings.isNullOrEmpty(value), "%s を設定してください", name);
    return value;
  }

  record SetupResult(String environmentId, String agentId, String skillId) {}
}

実行すると、下記のような出力となります。

Q. 税に関わる部はどの局にありますか。交通に関わる部も教えてください。

tool_result: {"source":"令和8年度 札幌市機構図","year":"令和8年度","bureaus":["危機管理局","総務局","デジタル戦略推進局","まちづくり政策局","財政局","市民文化局","スポーツ局","保健福祉局","子ども未来局","経済観光局","環境局","建設局","下水道河川局","都市局","交通局","水道局","病院局","消防局"],"departments":{"危機管理局":["危機管理部"],"総務局":["行政部","秘書部","国際部","広報部","職員部"],"デジタル戦略推進局":["スマートシティ推進部","情報システム部"],"まちづくり政策局":["政策企画部","都市計画部","総合交通計画部"],"財政局":["財政部","税政部","管財部"],"市民文化局":["地域振興部","市民生活部","文化部"],"スポーツ局":["スポーツ部"],"保健福祉局":["総務部","高齢保健福祉部","障がい保健福祉部","保険医療部","ウェルネス推進部"],"子ども未来局":["子ども育成部","子育て支援部"],"経済観光局":["産業振興部","経営雇用支援部","経済戦略推進部","観光・MICE推進部","農政部"],"環境局":["環境事業部","環境都市推進部"],"建設局":["総務部","土木部","みどりの推進部"],"下水道河川局":["経営管理部","事業推進部"],"都市局":["市街地整備部","建築部","建築指導部"],"交通局":["事業管理部","高速電車部"],"水道局":["総務部","給水部"],"病院局":["経営管理部","呼吸器内科部","消化器内科部","循環器内科部","腎臓内科部","糖尿病・内分泌内科部","リウマチ・免疫内科部","血液内科部","精神科部","脳神経内科部","小児科部","新生児内科部","外科部","乳腺外科部","整形外科部","形成外科部","脳神経外科部","呼吸器外科部","心臓血管外科部","皮膚科部","泌尿器科部","腎臓移植外科部","産婦人科部","眼科部","耳鼻咽喉科・甲状腺外科部","リハビリテーション科部","感染症内科部","放射線治療科部","放射線診断科部","麻酔科部","緩和ケア内科部","歯科口腔外科部","病理診断科部","救命救急センター部","臨床工学科部","栄養科部","放射線部","検査部","薬剤部","リハビリテーション部","看護部","医療品質総合管理部","地域連携センター部"],"消防局":["総務部","予防部","警防部","市民部","土木部","保健福祉部","学校教育部"]}}

A. 令和8年度の札幌市機構図によると、

- **税に関わる部**:「税政部」— **財政局**にあります。
- **交通に関わる部**:「事業管理部」「高速電車部」— **交通局**にあります。なお、まちづくり政策局にも「総合交通計画部」があります。

要点: SKILL.md は「このツールを呼んで結果だけで答えよ」という知識を与えるだけで、来年度版でも初見の問いでも効く。

まとめ

長い仕事は、1つのコンテキストウィンドウに収まりません。そのため、作業を複数セッションに分け、一貫した規律を設計します。それがハーネスエンジニアリングです。ハーネス構成は「initializer + coding agent」から「planner + generator + evaluator」と変化し、安定しましたが、モデルと共進性を持ちます。そのため、定期的に最小構成から見直し、evaluator で失敗パターンを見つけ、必要なハーネスだけを足し、不要なハーネスを外す必要があります。

ハーネスエンジニアリングにより、ソフトウェアエンジニアリングにパラダイムシフトが起きています。

elatt では、この「長い仕事を、人手をかけず、途切れさせずに完成させる」ハーネスづくりを実運用で日々やっています。設計や事業の相談などお気軽にお問い合わせからご連絡ください。

ハーネス設計が新しいソフトウェアエンジニアリングになる - 合同会社ふんどし