Re:ゼロから始めるML生活

どちらかといえばエミリア派です

Coding Agentを自作してみる

最近こちらの記事を見てました。

martinfowler.com

内容は独自にCoding Agentを作る話なんですが、興味深かったです。 自分は「オレオレCoding Agentを作ってやるぜ!!」とまでは思ってないんですが、話題のCoding Agent CLIについて、原始的なものでも作れるのか試してみたので今回はそのメモです。

Coding Agent CLI

ちょっと生成AI関係について知っている人なら下記のツールを使ったことがある方は多いんじゃないでしょうか?

CLI上で命令するとAgentくんが動いていろんなタスクをこなしてくれて、MCPかなんか使うとツールを使ってコーディング以外も色々できる君ですね。

メジャーなツールの実装とPydantic AIでの自作例

claude codeに関しては多分実装が公開されてなさそうなのですが、codexはrust、gemini-cliについてはTypeScriptで実装されていて、OSSになっているので実装を確認することができます。

rustやTypeScriptに明るい方は実際の実装を覗いてみるのが早いと思いますが、自分の場合はこちらのブログでPythonを使ってどんな感じで動いているのか確認してみました。

martinfowler.com

早い話、Pydantic AIで自作Coding Agentを作っています。 こちらを参考に自分でも作ってみたいと思います。

Pydantic AIを使って作ってみる

もとのブログ記事に習ってPydantic AIをつかってやってみたいと思います。

ai.pydantic.dev

まずは普通にPydantic AIを使う

まずは普通にPydantic AIを使ってみようと思います。 こちらを使って、まずは生成AIのAPIとやり取りができるようになることを確認します。

まずは仮想環境を作っていきます。

uv init
uv add pydantic_ai

次にAPI_KEYを設定したいと思います。

export OPENAI_API_KEY=XXXX

これで準備完了です。最初のコードはこんな感じにします。

import os
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
from pydantic_ai.providers.openai import OpenAIProvider

openai_api_key = os.environ.get("OPENAI_API_KEY")

model = OpenAIModel("gpt-4o", provider=OpenAIProvider(api_key=openai_api_key))

agent = Agent(
    # instructions=instructions,
    model=model,
)

if __name__ == "__main__":
  agent.to_cli_sync()

動かしてみるとこんな感じです。

uv run python main.py
pydantic-ai ➤ こんにちは
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role='assistant', tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='ZXk4cRvrJWKL7k')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='こんにちは', function_call=None, 
refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='sQaSuu3IYSL')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='Sgv29nEgw507Mxf')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='今日は', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='TMwubbPs7XHVd')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='vif3rTm6tyQvBse')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='artRivxV3O6Xi1z')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='よう', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='APt5ygCLOFSCwG')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='なお', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='QMBkN0gQVlVPpo')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='tV28s6zfMyNnKkE')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='5B6xVrqSrZ5RKuD')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='lp3n1CDZ97oFUbb')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='ZZ7B3PcLvIovkTP')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='できます', function_call=None, refusal=None,
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='NUUIh7sYV5UO')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='gvBdg2PdGLGmFAf')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='9M54CkPHeFOUtJP')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[Choice(delta=ChoiceDelta(content=None, function_call=None, refusal=None, 
role=None, tool_calls=None), finish_reason='stop', index=0, logprobs=None)], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=None, obfuscation='lRHhGv8S92')
ChatCompletionChunk(id='chatcmpl-CMZyZ6CG4SQImJFLuC5ykiB5gqGwp', choices=[], created=1759497543, model='gpt-4o-2024-08-06', 
object='chat.completion.chunk', service_tier='default', system_fingerprint='fp_cbf1785567', usage=CompletionUsage(completion_tokens=15, 
prompt_tokens=39, total_tokens=54, completion_tokens_details=CompletionTokensDetails(accepted_prediction_tokens=0, audio_tokens=0, reasoning_tokens=0, 
rejected_prediction_tokens=0), prompt_tokens_details=PromptTokensDetails(audio_tokens=0, cached_tokens=0)), obfuscation='Mq09w6zlLwD4fJi')
こんにちは!今日はどのようなお手伝いができますか?  

ちゃんと応答が返ってきました。ちゃんとやり取りできてそうですね。

周辺機能を用意する

生成AIとやり取りできるようになったところで、今度はAgentが使う周辺機能を用意していきます。 Agent自身が自分であれこれタスクを完結させるようにするにはファイルを読み書きができればひとまず動きそうです。

書いてみるとこんな感じです。

import asyncio
import os
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

openai_api_key = os.environ.get("OPENAI_API_KEY")

model = OpenAIChatModel("gpt-4o", provider=OpenAIProvider(api_key=openai_api_key))

instructions = """
あなたはコードベースの保守と開発を担当する専門エージェントとして振る舞ってください。
"""

filesystem_server = MCPServerStdio(
    command="npx",
    args=["-y", "@modelcontextprotocol/server-filesystem", os.getcwd()],
)

code_reasoning = MCPServerStdio(
    command="npx",
    args=["-y", "@mettamatt/code-reasoning"],
    tool_prefix="code_reasoning",
)

agent = Agent(
    instructions=instructions,
    model=model,
    mcp_servers=[filesystem_server, code_reasoning],
)


async def main():
    async with agent.run_mcp_servers():
        await agent.to_cli()

if __name__ == "__main__":
    asyncio.run(main())

MCPを使うと、いい感じに生成AIが判断して勝手に使ってくれるようになるのであまり深いことは考えることはありませんね。(勝手に動いてしまうのでその分危険ではありますが)

その他コーディングエージェントを作る際の機能にはこちらなどが参考になりました。 azukiazusa.dev

使ってみる

実際に動かしてみるとこんな感じです。

Secure MCP Filesystem Server running on stdio
Client does not support MCP Roots, using allowed directories set from server args: [ '/Users/tsuno/Programs/pydantic-agent' ]
[info] Code-Reasoning logic ready { config: { debug: false, promptsEnabled: true } }
[info] Server initialized { version: '0.7.0', promptsEnabled: true }
Prompt values will be stored at: /Users/tsuno/.code-reasoning/prompt_values.json
[info] Prompts capability enabled
[notice] 🚀 Code-Reasoning MCP Server ready.
pydantic-ai ➤ pythonでflaskのhttpサーバーを作ってください。ファイル名は"flask_server.py"にして。

----- 省略 -----

The Flask HTTP server has been created successfully in the file named flask_server.py. You can run it using the command python flask_server.py, and it  
will start the server at http://0.0.0.0:5000/. Accessing this URL will display "Hello, World!".                                                         
pydantic-ai ➤

これで作られたファイルを見てみると

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

ちゃんとpythonコードを書いたファイルが作られてますね、ちょっと感動…。 もちろんちゃんと動きます。

uv run python flask_server.py
 * Serving Flask app 'flask_server'
 * Debug mode: off
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.20:5000

編集ができるかについてもやってみます。

Secure MCP Filesystem Server running on stdio
Client does not support MCP Roots, using allowed directories set from server args: [ '/Users/tsuno/Programs/pydantic-agent' ]
[info] Code-Reasoning logic ready { config: { debug: false, promptsEnabled: true } }
[info] Server initialized { version: '0.7.0', promptsEnabled: true }
Prompt values will be stored at: /Users/tsuno/.code-reasoning/prompt_values.json
[info] Prompts capability enabled
[notice] 🚀 Code-Reasoning MCP Server ready.
pydantic-ai ➤ flask_server.pyについて、公開するポート番号を3000にして

----- 省略 -----

flask_server.py のポート番号を3000に変更しました。これで、Flaskサーバーはポート3000で公開されるようになりました。                                       
pydantic-ai ➤

実際にファイルを見てみると、

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=3000)

とちゃんと期待通り変更されているわけです。 すごいですね。

感想

めちゃくちゃ原始的なコーディングエージェントであればこれくらいで済むわけですし、必要に応じてMCPサーバーを追加していけばもっといろんなことができますね。

ただしやっぱりClaude Code やCodexには遠く及ばないので、改めて広く使われてるコーディングエージェントツールはよく出来てるなあ、などと思った次第でした。