Fragments of verbose memory

冗長な記憶の断片 - Web技術のメモをほぼ毎日更新

Mar 6, 2026 - 日記

Agent Skillsに対応した最小のAIエージェントをPythonで書いてみる

minimal-agent-skills-python cover image

AnthropicAgent Skills overview を読んでいて、これは大きなフレームワークから入るより、まず最小構成で動きを見た方が早そうだなと思いました。

今回は、公式仕様を完全に再現する実装ではなく、Agent Skills の考え方を理解するためのローカル実験として作ります。

Agent Skills は、ざっくり言うと「エージェントに渡す再利用可能な作業手順」です。毎回長いプロンプトを書く代わりに、よく使う手順をスキル(skill: 再利用できる手順書の単位)として切り出しておくイメージです。

本記事では、Python だけで動く「最小のスキル対応エージェント」を作ります。前半で「スキルを見つける」「選ぶ」「実行する」という流れをローカルで再現し、後半で実際に LLM(Large Language Model: 大規模言語モデル)へ接続するところまでやります。

何を作るのか

今回の実験で作るのは、次の4つだけを持つ小さなエージェントです。

  • skills/ 配下の Markdown を読む
  • ユーザーの依頼文に合うスキルを1つ選ぶ
  • スキルに書かれた手順と入力をまとめて実行プランとして返す
  • 実行プランを LLM に渡して最終応答を生成する

かなり単純ですが、Agent Skills の肝はだいたいここにあります。 「スキルがあると、エージェントの振る舞いがどう変わるのか」を確認するには、このくらいのサイズがちょうどいいです。

スキルの最小フォーマット

まずはスキルファイルを1つ用意します。 今回は fix-python-test という名前で、Python テスト修正用のスキルを置きます。

このスキルは、依頼文に pytesttest が含まれると選ばれる想定です。 Markdown(軽量マークアップ記法)の front matter(メタデータを先頭に書くヘッダ)っぽい構造を自前でゆるく読み取るだけなので、特別なライブラリは不要です。

なお、ここで使う triggers は公式仕様そのものではなく、「どの依頼でこのスキルを選ぶか」を見やすくするために足した簡略化フィールドです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# skills/fix-python-test/SKILL.md
---
name: fix-python-test
description: pytestの失敗を調査して修正するための手順
triggers: pytest,test,unit test
---

1. まず失敗しているテスト名とエラーメッセージを確認する
2. 再現手順を短くまとめる
3. 原因候補を3つまで列挙する
4. 最小の修正案を提案する
5. 修正後の確認手順を書く

この時点では、まだ「賢いこと」はしていません。 ただ、手順がコードの外に出たことで、エージェント本体はかなり薄くできます。

最小エージェントの実装

以下のコードは、スキルを読み込み、入力文に最も合いそうなものを選び、実行プランを返す最小実装です。

  • Why: Agent Skills の基本動作を Python だけで観察するため
  • Prereq: Python 3.10 以上
  • What: skills/*/SKILL.md を読み、依頼文に応じたプランを標準出力へ出す
  • Verify: python minimal_agent.py "pytest が落ちるので原因を調べたい" で該当スキルが選ばれる
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import sys


@dataclass
class Skill:
    name: str
    description: str
    triggers: list[str]
    body: str


def parse_skill(path: Path) -> Skill:
    text = path.read_text(encoding="utf-8")
    lines = text.splitlines()

    if len(lines) < 3 or lines[0].strip() != "---":
        raise ValueError(f"invalid skill header: {path}")

    metadata: dict[str, str] = {}
    body_start = 0

    for index in range(1, len(lines)):
        line = lines[index].strip()
        if line == "---":
            body_start = index + 1
            break
        key, value = line.split(":", 1)
        metadata[key.strip()] = value.strip()

    triggers = [item.strip().lower() for item in metadata["triggers"].split(",")]
    body = "\n".join(lines[body_start:]).strip()

    return Skill(
        name=metadata["name"],
        description=metadata["description"],
        triggers=triggers,
        body=body,
    )


def load_skills(root: Path) -> list[Skill]:
    return [parse_skill(path) for path in root.glob("*/SKILL.md")]


def score_skill(skill: Skill, user_request: str) -> int:
    request = user_request.lower()
    score = 0

    for trigger in skill.triggers:
        if trigger in request:
            score += 10

    if skill.name.lower() in request:
        score += 5

    return score


def choose_skill(skills: list[Skill], user_request: str) -> Skill | None:
    ranked = sorted(skills, key=lambda skill: score_skill(skill, user_request), reverse=True)
    if not ranked or score_skill(ranked[0], user_request) == 0:
        return None
    return ranked[0]


def build_plan(skill: Skill, user_request: str) -> str:
    return f"""selected_skill: {skill.name}
description: {skill.description}
user_request: {user_request}

instructions:
{skill.body}
"""


def main() -> int:
    if len(sys.argv) < 2:
        print('usage: python minimal_agent.py "your request"')
        return 1

    user_request = sys.argv[1]
    skills = load_skills(Path("skills"))
    selected = choose_skill(skills, user_request)

    if selected is None:
        print("selected_skill: none")
        print("description: no matching skill")
        print(f"user_request: {user_request}")
        return 0

    print(build_plan(selected, user_request))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

ポイントは、エージェント本体が「問題を全部解く」のではなく、「どの手順書を使うか」を先に決めていることです。 この分離があるだけで、挙動の観察と改善がかなりやりやすくなります。

実行してみる

試すために、次のようなディレクトリ構成を作ります。

1
2
3
4
5
.
├── minimal_agent.py
└── skills
    └── fix-python-test
        └── SKILL.md

準備できたら、以下のように実行します。

このコマンドは、依頼文を1つ渡してスキル選択結果を表示するだけです。 ファイルの書き換えや外部API呼び出しはありません。

1
python minimal_agent.py "pytest が落ちるので原因を調べたい"

期待される出力は次のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
selected_skill: fix-python-test
description: pytestの失敗を調査して修正するための手順
user_request: pytest が落ちるので原因を調べたい

instructions:
1. まず失敗しているテスト名とエラーメッセージを確認する
2. 再現手順を短くまとめる
3. 原因候補を3つまで列挙する
4. 最小の修正案を提案する
5. 修正後の確認手順を書く

これだけでも、「リクエストに応じて行動方針が切り替わる」感触は十分あります。 個人的には、この段階で一度作っておくと、あとで本物の LLM をつなぐ時にかなり迷いにくいです。

LLMにつなぐ

ここまでで、スキル選択まではできました。 次は、この実行プランをそのまま LLM に渡して、最終応答を生成します。

今回は Anthropic API ドキュメントAnthropic Python SDK を使います。 やることは単純で、選ばれたスキル本文を system prompt 側へ寄せ、ユーザー依頼は user message として Messages API へ渡します。

モデル名はリリースに合わせて変わるので、記事ではコード内に固定値をベタ書きせず、環境変数で切り替えられる形にしておきます。

準備

この例では API キーを環境変数から読みます。 外部 API を呼び出すため、利用量に応じて課金が発生する点には注意してください。 依存を追加し、ANTHROPIC_API_KEYANTHROPIC_MODEL を設定してください。

1
2
3
python -m pip install anthropic
export ANTHROPIC_API_KEY="your_api_key"
export ANTHROPIC_MODEL="claude-sonnet-4-5"

LLM接続版の実装

以下のコードは、先ほどの最小エージェントに LLM 呼び出しを追加した版です。

  • Why: スキル選択結果をそのままモデルへ渡し、最終応答まで返すため
  • Prereq: Python 3.10 以上、anthropic パッケージ、ANTHROPIC_API_KEYANTHROPIC_MODEL
  • What: スキルを選んだあと、選択された手順を system prompt に埋め込んで Messages API で Claude を呼び出す
  • Verify: python minimal_agent.py "pytest が落ちるので原因を調べたい" で、スキル名と自然文の応答が返る
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
import os
import sys

from anthropic import Anthropic


@dataclass
class Skill:
    name: str
    description: str
    triggers: list[str]
    body: str


def parse_skill(path: Path) -> Skill:
    text = path.read_text(encoding="utf-8")
    lines = text.splitlines()

    if len(lines) < 3 or lines[0].strip() != "---":
        raise ValueError(f"invalid skill header: {path}")

    metadata: dict[str, str] = {}
    body_start = 0

    for index in range(1, len(lines)):
        line = lines[index].strip()
        if line == "---":
            body_start = index + 1
            break
        key, value = line.split(":", 1)
        metadata[key.strip()] = value.strip()

    triggers = [item.strip().lower() for item in metadata["triggers"].split(",")]
    body = "\n".join(lines[body_start:]).strip()

    return Skill(
        name=metadata["name"],
        description=metadata["description"],
        triggers=triggers,
        body=body,
    )


def load_skills(root: Path) -> list[Skill]:
    return [parse_skill(path) for path in root.glob("*/SKILL.md")]


def score_skill(skill: Skill, user_request: str) -> int:
    request = user_request.lower()
    score = 0

    for trigger in skill.triggers:
        if trigger in request:
            score += 10

    if skill.name.lower() in request:
        score += 5

    return score


def choose_skill(skills: list[Skill], user_request: str) -> Skill | None:
    ranked = sorted(skills, key=lambda skill: score_skill(skill, user_request), reverse=True)
    if not ranked or score_skill(ranked[0], user_request) == 0:
        return None
    return ranked[0]


def build_system_prompt(skill: Skill | None) -> str:
    base = "You are a concise and practical coding assistant. Reply in Japanese."

    if skill is None:
        return base

    return f"""{base}

You must follow this selected skill.
skill_name: {skill.name}
skill_description: {skill.description}

skill_instructions:
{skill.body}
"""


def call_llm(user_request: str, skill: Skill | None) -> str:
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        raise RuntimeError("ANTHROPIC_API_KEY is not set")

    client = Anthropic(api_key=api_key)
    response = client.messages.create(
        model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5"),
        max_tokens=800,
        system=build_system_prompt(skill),
        messages=[
            {
                "role": "user",
                "content": user_request,
            }
        ],
    )
    return response.content[0].text


def main() -> int:
    if len(sys.argv) < 2:
        print('usage: python minimal_agent.py "your request"')
        return 1

    user_request = sys.argv[1]
    skills = load_skills(Path("skills"))
    selected = choose_skill(skills, user_request)

    if selected is not None:
        print(f"selected_skill: {selected.name}\n")
    else:
        print("selected_skill: none\n")

    answer = call_llm(user_request, selected)
    print(answer)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

ここで重要なのは、スキルを「検索用データ」ではなく「行動方針」として system prompt に渡していることです。 この形にすると、ユーザー入力は短くても、モデル側には必要な手順が補われた状態で届きます。

ANTHROPIC_MODEL を環境変数にしておくと、新しいモデルが出た時もコード本体を触らずに差し替えできます。

実行例

このコマンドは外部 API を呼び出します。 トークン消費が発生するので、試す回数が多い時は注意してください。

1
python minimal_agent.py "pytest が落ちるので原因を調べたい。原因候補と最小修正案を出して"

返答イメージは次のようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
selected_skill: fix-python-test

まず失敗しているテスト名とエラーメッセージを確認してください。
そのうえで再現手順を1〜2行で整理します。

現時点での原因候補は次の3つです。
1. 依存パッケージのバージョン差異
2. テストデータの初期化漏れ
3. 期待値変更に対してテストが追従していない

最小修正案としては、失敗しているテストケースだけを対象に入力データか期待値を見直すのがよさそうです。

実際にやってみると見えてくること

LLM 接続まで入れると、「スキルがあるだけで応答の形がかなり安定する」感触が出てきます。 同じ依頼でも、手順なしで投げるより、やってほしい順番を先に渡した方がブレにくいです。

一方で、この実装にも限界はあります。

  1. スキル選択はまだキーワード一致で粗い
  2. tool use(外部ツール呼び出し)は未実装
  3. 複数スキルの合成はしていない
  4. 実行結果の記録や再学習もない
  5. APIエラー時の例外処理やリトライも未実装

つまり、これは「最小だけど実用の入口には立てる」くらいの位置づけです。 個人的には、概念理解だけで終わらず、実際にモデルへつないでみるとスキルの価値がかなり腹落ちしました。

この実験で見えたこと

実際に最小版を書いてみると、Agent Skills は「高度な推論機構」というより、「エージェントの行動を外出しして再利用するための設計パターン」として理解すると腑に落ちやすいです。

つまり、価値の中心はモデルの賢さそのものではなく、以下のような運用面にあります。

  • よく使う手順をコード外へ分離できる
  • エージェント本体を小さく保てる
  • 手順ごとの差分レビューがしやすい
  • 同じ依頼に対する振る舞いを安定させやすい

このへんは、プロンプトを1ファイルに全部詰め込んでいた頃よりかなり扱いやすいです。 逆に言うと、スキルの粒度や命名が雑だと、すぐ混乱します。最小実装でもその片鱗は見えました。

PyPIライブラリを使うとどう書けるか

ここまでは勉強のために、あえてライブラリを使わずに書きました。 ただ、実際に使うものとして育てるなら、front matter の解析や入力バリデーションのような定番処理は、PyPI の既存ライブラリに任せた方が楽です。

たとえば、次のような構成が扱いやすいと思います。

この形にすると、自前で書くのは「読み込む」「選ぶ」「プロンプトを組み立てる」くらいで済みます。 車輪の再発明を減らしつつ、全体の見通しも保ちやすいです。

依存の追加

1
python -m pip install anthropic python-frontmatter pydantic rapidfuzz

PyPI活用版の実装例

以下は、「実際に PyPI ライブラリを使うとこんな感じ」という最小例です。

  • Why: front matter 解析やバリデーションを既存ライブラリへ寄せるため
  • Prereq: Python 3.10 以上、anthropicpython-frontmatterpydanticrapidfuzzANTHROPIC_API_KEY
  • What: スキル読み込みと検証をライブラリに任せ、類似度ベースでスキルを1つ選んで Claude を呼び出す
  • Verify: python minimal_agent.py "pytest が落ちるので原因を調べたい"fix-python-test が選ばれて自然文の応答が返る
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
from __future__ import annotations

from pathlib import Path
import os
import sys

import frontmatter
from anthropic import Anthropic
from pydantic import BaseModel, Field
from rapidfuzz import fuzz


class Skill(BaseModel):
    name: str
    description: str
    triggers: list[str] = Field(default_factory=list)
    body: str


def parse_skill(path: Path) -> Skill:
    post = frontmatter.load(path)
    return Skill(
        name=post["name"],
        description=post["description"],
        triggers=post.get("triggers", []),
        body=post.content.strip(),
    )


def load_skills(root: Path) -> list[Skill]:
    return [parse_skill(path) for path in root.glob("*/SKILL.md")]


def score_skill(skill: Skill, user_request: str) -> int:
    query = user_request.lower()
    candidates = [skill.name, skill.description, *skill.triggers]
    scores = [fuzz.partial_ratio(query, candidate.lower()) for candidate in candidates]
    return max(scores, default=0)


def choose_skill(skills: list[Skill], user_request: str) -> Skill | None:
    ranked = sorted(skills, key=lambda skill: score_skill(skill, user_request), reverse=True)
    if not ranked or score_skill(ranked[0], user_request) < 60:
        return None
    return ranked[0]


def build_system_prompt(skill: Skill | None) -> str:
    base = "You are a concise and practical coding assistant. Reply in Japanese."
    if skill is None:
        return base

    return f"""{base}

You must follow this selected skill.
skill_name: {skill.name}
skill_description: {skill.description}

skill_instructions:
{skill.body}
"""


def call_llm(user_request: str, skill: Skill | None) -> str:
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        raise RuntimeError("ANTHROPIC_API_KEY is not set")

    client = Anthropic(api_key=api_key)
    response = client.messages.create(
        model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-5"),
        max_tokens=800,
        system=build_system_prompt(skill),
        messages=[{"role": "user", "content": user_request}],
    )
    return response.content[0].text


def main() -> int:
    if len(sys.argv) < 2:
        print('usage: python minimal_agent.py "your request"')
        return 1

    user_request = sys.argv[1]
    skills = load_skills(Path("skills"))
    selected = choose_skill(skills, user_request)

    if selected is not None:
        print(f"selected_skill: {selected.name}\n")
    else:
        print("selected_skill: none\n")

    print(call_llm(user_request, selected))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

やっていること自体はほぼ同じですが、front matter の解析や入力検証を自前で持たなくてよくなります。 勉強用としては手書き版に意味がありますが、普段使いするならこのくらいライブラリに寄せた方が保守しやすいです。

まとめ

Agent Skills の動作を理解したいだけなら、最初から重いエージェント基盤に乗る必要はないと思います。 まずは Python で「スキルを読む」「選ぶ」「手順を返す」を作り、そこに LLM 接続を薄く足すだけでも、かなり雰囲気が掴めます。

次にやるなら、allowed_tools のような権限制御を足すか、スキル選択を埋め込み検索や LLM 判定に置き換えるのが自然だと思います。 興味のある方は、まず2〜3個のスキルだけ作って、自分の手元タスクでどれだけ応答が安定するかを試してみてください。

参考リンク