Open11

OpenDevinのコードリーディング

r-kagayar-kagaya

https://github.com/OpenDevin/OpenDevin

==============
STEP 0

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='ls', background=False, action=<ActionType.RUN: 'run'>)

OBSERVATION:
create_stock_graph.py  get_stock_data.py


==============
STEP 1

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='python get_stock_data.py', background=False, action=<ActionType.RUN: 'run'>)

OBSERVATION:
-bash: python: command not found


==============
STEP 2

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='apt install python3', background=False, action=<ActionType.RUN: 'run'>)

OBSERVATION:
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
python3 is already the newest version (3.10.6-1~22.04).
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.


==============
STEP 3

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='python get_stock_data.py', background=False, action=<ActionType.RUN: 'run'>)

OBSERVATION:
-bash: python: command not found


==============
STEP 4

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='python3 get_stock_data.py', background=False, action=<ActionType.RUN: 'run'>)

OBSERVATION:
Traceback (most recent call last):
  File "/workspace/get_stock_data.py", line 1, in <module>
    import yfinance as yf
ModuleNotFoundError: No module named 'yfinance'


==============
STEP 5

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
AgentThinkAction(thought="It seems I need to install the yfinance module to fetch stock data. I'll do that first before running the get_stock_data.py script again.", action=<ActionType.THINK: 'think'>)


==============
STEP 6

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!

ACTION:
CmdRunAction(command='pip install yfinance', background=False, action=<ActionType.RUN: 'run'>)




==============
STEP 7

PLAN:
I would like to get a year's worth of Apple stock prices and graph them!
r-kagayar-kagaya
  1. CmdOutputObservation (observation/code.py)

    • コマンド実行の結果を格納します。
    • contentフィールドには標準出力と標準エラー出力が含まれます。
    • exit_codeフィールドにはコマンドの終了コードが入ります。
    • commandcommand_idフィールドには、実行したコマンド文字列とプロセスIDが入ります。
  2. BrowserOutputObservation (observation/browser.py)

    • ブラウザでURLを開いた結果を格納します。
    • contentフィールドにはウェブページのbody要素のテキスト内容が入ります。
    • screenshotフィールドにはスクリーンショットのBase64エンコーディング済みバイナリデータが入ります。
    • urlフィールドには実際に開いたURLが入ります。
    • status_codeフィールドにはHTTPステータスコードが入ります。
  3. FileAccessObservation (observation/files.py)

    • ファイルの読み書き結果を格納します。
    • contentフィールドにはファイルの内容が入ります。
    • errorフィールドには、ファイルアクセス時のエラーメッセージが入ります。
  4. AgentErrorObservation (observation/agent.py)

    • エラーメッセージを格納します。
    • contentフィールドにエラーメッセージが入ります。
  5. AgentRecallObservation (observation/agent.py)

    • エージェントの思考内容を格納します。
    • contentフィールドに思考内容が入ります。

各Observationクラスは、それぞれのアクションの実行結果を表すデータを保持しています。主な観測結果としては、コマンド出力、ファイル内容、ブラウザ情報、エラーメッセージなどが含まれます。

これらの観測結果は、プランニングのフィードバックループにおいて非常に重要な役割を果たします。次のプロンプトの生成時に、これらの観測結果が過去の履歴としてプロンプトに組み込まれ、より適切なプランニングを可能にしています。

つまり、Observationオブジェクトには、アクションの実行結果がデータとして詰まっており、それがプランニングの質を左右する重要なコンテキスト情報になっているのです。

r-kagayar-kagaya

このファイル agent.py には SWE (Software Engineering) エージェントのクラス SWEAgent が実装されています。このエージェントは、コードの編集や実行などのソフトウェア開発タスクを扱うことを目的としています。

主な機能は以下の通りです:

  1. init

    • LLMオブジェクトを受け取り、エージェントを初期化します。
    • memory_windowmax_retries などのパラメータを設定できます。
    • running_memoryリストでエージェントの履歴を保持します。
    • 現在の操作対象ファイルとカーソル位置を追跡します。
  2. _remember

    • エージェントの行動と観測結果を running_memory に追加します。
  3. _think_act

    • プロンプトを構築し、LLMにクエリを送信します。
    • LLMの出力を parse_command 関数を使って解析し、Action オブジェクトを生成します。
  4. _update

    • FileReadActionFileWriteAction の後に、現在のファイルとカーソル位置を更新します。
  5. step

    • エージェントの1ステップの振る舞いを定義しています。
    • 過去のアクションと観測結果を running_memory に記録します。
    • プロンプトを構築し、_think_act を呼び出してアクションを生成します。
    • アクションが生成されない場合、最大 max_retries 回までプロンプトを修正して再試行します。
    • 生成されたアクションを返します。
  6. search_memory

    • クエリに一致する running_memory の項目を検索して返します。
  7. reset

    • エージェントの状態をリセットします。

SWEAgent は、プロンプトを構築し、LLMと対話することでアクションを生成しています。プロンプトには、タスクの目標、現在のファイル、カーソル位置、過去の履歴が含まれます。また、LLMからの出力を適切に解析し、実行可能なアクションに変換しています。

このエージェントは、ソフトウェア開発に特化したタスクを扱うことを目的としており、parse_command 関数ではコード編集や実行に関連するアクションが生成されます。エージェントの履歴を running_memory に保持することで、過去のコンテキストを反映したプロンプトを生成できます。

r-kagayar-kagaya

アクションオブジェクトとは、OpenDevinがエージェントとして実行すべき行動を表すオブジェクトです。

action.pyファイルに、様々なアクションオブジェクトのクラスが定義されています。主なアクションオブジェクトは以下の通りです:

  • CmdRunAction: コマンドを実行するアクション
  • CmdKillAction: 実行中のコマンドを停止するアクション
  • BrowseURLAction: URLを開くアクション
  • FileReadAction: ファイルを読み込むアクション
  • FileWriteAction: ファイルに書き込むアクション
  • AgentRecallAction: 情報を思い出すアクション
  • AgentThinkAction: 考える(プランを立てる)アクション
  • AgentSummarizeAction: 要約するアクション
  • AddTaskAction: プランにタスクを追加するアクション
  • ModifyTaskAction: タスクの状態を変更するアクション
  • AgentFinishAction: タスクの実行を完了するアクション

これらのアクションオブジェクトは、Actionクラスを継承した具象クラスとして実装されています。各アクションクラスには、そのアクションを実行するために必要な引数があります。例えば:

  • CmdRunActionにはcommandbackground引数があります
  • FileReadActionにはpath引数があります
  • AddTaskActionにはparentgoalsubtasks引数があります

prompt.parse_response関数は、LLMの出力をパースし、出力に含まれるアクション名と引数に基づいて対応するアクションオブジェクトのインスタンスを生成しています。

これらのアクションオブジェクトは、AgentControllerによって実際に実行され、その結果がObservationオブジェクトとして返されます。

つまり、アクションオブジェクトはOpenDevinエージェントの意図や行動を表現するデータ構造であり、プランニングの出力となるものです。適切なアクションオブジェクトを生成することが、プランニングの目的となります。

r-kagayar-kagaya

prompt.py

get_prompt関数

この関数は、PlannerAgentにLLMに送るプロンプトを生成しています。以下が主な処理の流れです:

  1. Planオブジェクトと履歴(ActionObservationのペアのリスト)を受け取る
  2. プロンプトのテンプレート文字列を読み込む
  3. Plan.task.to_dict()を呼び出し、プランの構造をJSON形式の文字列に変換する
  4. 履歴から最新の10件のみを取り出し、ActionObservationをJSON形式の辞書に変換する
  5. 現在実行中のタスクを特定し、そのタスクの説明文とヒントを生成する
  6. 上記で生成した値を組み込んだプロンプト文字列を返す

プロンプトのテンプレートには、以下の項目が含まれています:

  • タスクの説明
  • 現在のプランのJSON表現
  • 最新の履歴のJSON表現
  • 現在のタスク状況の説明
  • 次に取るべきアクションのヒント

特に注目すべき点は、ヒントの生成ロジックです。最新のアクションの種類に応じて、異なるヒントが設定されています。例えば:

  • RUNアクションの後は、出力を確認し計画に反映するよう促されます
  • THINKアクションの後は、思考を止め実行に移るよう促されます
  • まだアクションが無い場合は、lsコマンドでファイルを確認するよう促されます

このようにヒントを工夫することで、LLMにより適切なアクションを示唆することができます。

parse_response関数

この関数は、LLMからの出力を解析し、実際に実行するアクションオブジェクトに変換します。以下が主な処理の流れです:

  1. LLMの出力文字列を受け取る
  2. 出力文字列からJSON形式の部分文字列を抽出する
  3. JSON文字列をパースし、辞書オブジェクトに変換する
  4. action_from_dict関数を呼び出し、辞書オブジェクトからアクションオブジェクトを生成する
  5. 生成したアクションオブジェクトを返す

出力のJSON部分は、actionフィールドとそのアクションに必要なargsフィールドを含む必要があります。action_from_dict関数はこの規則に基づいてアクションオブジェクトを作成しています。

planner_agent.py

PlannerAgentクラス

このクラスが、プランニングエージェントの本体となる実装です。主要なメソッドは以下の2つです:

__init__ メソッド

このコンストラクタでは、引数としてLLMオブジェクトを受け取り、インスタンス変数に代入しています。これにより、このエージェントがどのLLMを使うかを決定できます。

step メソッド

この関数が、プランニングのメインループの中核となるロジックを実装しています。以下が主な処理の流れです:

  1. 現在のタスクの状態をチェックし、完了済みであればAgentFinishActionを返して終了する
  2. prompt.get_prompt関数を呼び出し、現在の状況に基づくプロンプトを取得する
  3. プロンプトをLLMに送信し、出力を受け取る
  4. prompt.parse_response関数を呼び出し、LLMの出力からアクションオブジェクトを生成する
  5. 生成したアクションオブジェクトを返す

つまり、stepメソッドは現在の状況をプロンプトに変換し、LLMにプロンプトを送信することで次のアクションを生成しているわけです。

search_memory メソッド

このメソッドは、クエリに基づいてエージェントのメモリを検索する役割を持ちますが、現在のところ空のリストを返すだけの単純な実装になっています。

プランニングのフロー

以上のコードを見ると、OpenDevinのプランニング機能は以下のような流れで動作していることがわかります:

  1. AgentControllerからPlannerAgent.stepが呼び出される
  2. stepメソッド内でget_prompt関数を呼び出し、現在の状況に基づくプロンプトを生成する
  3. プロンプトをLLMに送り、出力を受け取る
  4. 出力をparse_response関数で解析し、アクションオブジェクトに変換する
  5. アクションオブジェクトをAgentControllerに返す
  6. AgentControllerはアクションを実行し、その結果を次のstepの入力として使用する

このようにして、プロンプトとLLMの出力を介してプランニングが行われ、アクションが生成されていきます。プロンプトの内容を適切に設計することが、適切なプランを生成するための鍵となっています。

このプランニング機能は、主にプロンプト生成(get_prompt)、LLMとのインタラクション(stepメソッド)、出力の解析(parse_response)の3つの部分から構成されていることがわかります。

r-kagayar-kagaya
  1. agenthub/planner_agent.py

このファイルには、PlannerAgentクラスの実装が含まれています。PlannerAgentは、prompt.pyで生成されたプロンプトをLLMに送り、応答からアクションを生成する役割を担っています。

具体的には、stepメソッドがプランニングの中核となるロジックを含んでいます。ここでプロンプトの生成、LLMへの問い合わせ、応答の解析が行われています。

  1. agenthub/planner_agent/prompt.py

このファイルには、プロンプトの生成と応答の解析のロジックが実装されています。get_prompt関数とparse_response関数がその中心となります。

get_prompt関数では、現在のプラン構造や履歴、ヒントなどを組み込んだプロンプトを作成しています。parse_response関数では、LLMの出力からアクションオブジェクトを解析しています。

これら2つのファイルを詳しく読むことで、PlannerAgentがどのようにプランニングを行っているかを理解できるはずです。

特に注目するべき点は以下の通りです:

  • プロンプトにどのような情報が組み込まれているか
  • プロンプトからどのようにLLMにアクションを生成させているか
  • LLMの出力からどのようにアクションを解析しているか
  • アクションの種類とその振る舞いの違い
  • プランニングのループ全体の流れ

これらのロジックを追うことで、OpenDevinのプランニング機能の肝となる部分を把握できます。さらに詳しく知りたい場合は、plan.pyやaction、observation関連のファイルも参照する必要がありますが、上記2ファイルが最も中心的な役割を果たしています。

r-kagayar-kagaya

プロンプト例

プロンプトはget_prompt関数で生成されます。以下は簡単な例です:

# Task
You're a diligent software engineer AI. You can't see, draw, or interact with a browser, but you can read and write files, and you can run commands, and you can think.

You've been given the following task: 
Implement a simple web server in Python

## Plan
As you complete this task, you're building a plan and keeping track of your progress. Here's a JSON representation of your plan:

{
  "id": "0",
  "goal": "Implement a simple web server in Python",
  "state": "open",
  "subtasks": []
}

You're not currently working on any tasks. Your next action MUST be to mark a task as in_progress.

## History
Here is a recent history of actions you've taken in service of this plan, as well as observations you've made. This only includes the MOST RECENT ten actions--more happened before that.

[]

Your most recent action is at the bottom of that history.

## Action
What is your next thought or action? Your response must be in JSON format. 

It must be an object, and it must contain two fields:
* `action`, which is one of the actions below
* `args`, which is a map of key-value pairs, specifying the arguments for that action

... (アクション一覧は省略) ...

You MUST take time to think in between read, write, run, browse, and recall actions.

What is your next thought or action? Again, you must reply with JSON, and only with JSON.

You're not currently working on any tasks. Your next action MUST be to mark a task as in_progress.

モデルの出力例

{
  "action": "modify_task",
  "args": {
    "id": "0",
    "state": "in_progress"
  }
}

parse_response呼び出し

parse_response関数に上記JSONが渡され、ModifyTaskActionオブジェクトが生成されます。

生成されるPlan

上記の応答に基づき、Planが以下のように更新されます:

plan = Plan("Implement a simple web server in Python")
plan.task.to_dict()
# Output:
{
  "id": "0", 
  "goal": "Implement a simple web server in Python",
  "state": "in_progress",
  "subtasks": []
}

次のプロンプト

この応答を受けて、次のようなプロンプトが生成されるでしょう:

# Task
... (前と同じ)

## Plan
{
  "id": "0",
  "goal": "Implement a simple web server in Python", 
  "state": "in_progress",
  "subtasks": []
}

You're currently working on this task:
Implement a simple web server in Python.
If it's not achievable AND verifiable with a SINGLE action, you MUST break it down into subtasks NOW.

## History
[
  {
    "action": "modify_task",
    "args": {
      "id": "0",
      "state": "in_progress"
    }
  }
]

## Action
...
You should think about the next action to take.

つまり、プランのルートタスクが"in_progress"状態になり、そのタスクを達成するための次のアクションを求められます。

モデルからの応答によって、プランが徐々に構築・更新されていきます。サブタスクの追加、状態の変更などを経て、最終的にメインタスクが完了するとfinishアクションが返され、実行が終了します。

このようにして、プロンプトを介してモデルと対話しながら、段階的にPlanとTaskの構造が構築されていくわけです。

r-kagayar-kagaya

Plan作成時

Plan作成時には、メインゴールを表すルートタスクが作成されます。

def __init__(self, task: str):
    self.main_goal = task
    self.task = Task(parent=None, goal=task, subtasks=[])

ここでは、Taskクラスのコンストラクタを呼び出し、以下の引数を渡しています:

  • parent=None : ルートタスクなので親はNone
  • goal=task : メインゴールの文字列
  • subtasks=[] : 初期時点では空のサブタスクリスト

つまり、最初は単一のタスクからなるPlanが作成されます。

サブタスクの追加

サブタスクを追加する際は、add_subtaskメソッドが使用されます。

def add_subtask(self, parent_id: str, goal: str, subtasks: List = []):
    parent = self.get_task_by_id(parent_id)
    child = Task(parent=parent, goal=goal, subtasks=subtasks)
    parent.subtasks.append(child)
  1. get_task_by_idでIDから親タスクを特定します
  2. Taskクラスのコンストラクタを呼び出し、新しいサブタスクを作成します
    • 親タスクをparentに指定
    • ゴール文字列をgoalに指定
    • さらにサブタスクのリストを指定できます(再帰的にサブタスクを作れる)
  3. 作成したサブタスクを、親タスクのsubtasksリストに追加します

このように、タスクを順次追加していくことでPlanの階層構造が構築されていきます。

状態の更新

タスクの状態を更新する際は、set_subtask_stateメソッドが使用されます。

def set_subtask_state(self, id: str, state: str):
    task = self.get_task_by_id(id)
    task.set_state(state)
  1. get_task_by_idでIDからタスクを特定します
  2. そのタスクのset_stateメソッドを呼び出し、新しい状態を設定します

Task.set_stateメソッドの中では、サブタスクの状態も再帰的に更新される処理が行われています。

このようにして、Planには新しいタスクの追加や、既存のタスクの状態更新が行われ、タスク実行の進捗が反映されていきます。