6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントを自作して、やっと「ループエンジニアリング」の意味がわかった!(ソース全文あり)【第5回 「無料3,000リクエスト」を使い切る!】

6
Last updated at Posted at 2026-08-12

「無料3,000リクエスト」を使い切る! 最終回
※再現可能なソースコード全文は文末にあります。

SAKURA Internetは無料で3000リクエストも使えて太っ腹です。
夏休みの自由研究のため、AIエージェント作成の解説の記録!
400くらいリクエストを使っていました。
残りリクエストも夏休みも少なくこれが集大成。これで終わります。
結局3000リクエストは使いきれず余りました。
業務利用でなければ十分な量でした。
可能なら今後も継続して使いたいです!!!

AIエージェントを自作して、やっと「ループエンジニアリング」の意味がわかった

そりゃ、ループエンジニアリングの言い出しっぺのBoris Chernyさんも乗っかったPeter Steinbergerさんもエージェントの開発者。

この視点で語られることをイチアプリ作成で理解するのは無理かあったのだと。

そう気が付きました。


前回LLMチャット作ったので、AIエージェントを自作してみた。

前回、今回どちらも簡易であるわけだけど。(例によって動くコードは後半に)

最初は、

「LLM APIを呼んで、ツールを実行させればいいんでしょ?」

くらいに考えていた。

実際に作ってみる。

すると、思ったより普通に動く。

ファイルを読む。

ファイルを探す。

コードを書く。

コードを編集する。

コマンドを実行する。

そして、結果をまたLLMに渡す。

……あれ?

これ、Claude CodeやCodexの中で起きていることと、かなり近い。

AIエージェントの本体は、LLMそのものではない。

むしろ重要なのは、LLMをどう回すか。回し続けるかだ。

自作してみたら「ループ」が見えた

今回作ったエージェントは、かなりシンプル。

構成はこんな感じ。

ユーザー
   ↓
System Prompt
   ↓
Context
   ↓
LLM
   ↓
Tool Call
   ↓
Permission
   ↓
Tool実行
   ↓
Tool Result
   ↓
Contextへ追加
   ↓
LLM
   ↓
Tool Call
   ↓
……

つまり、

LLM → ツール → 結果 → LLM → ツール → 結果……

というループ。

実装がわかりやすい。(2重ループの箇所)

while self.turn_count < self.max_turns:
    self.turn_count += 1

    messages = self.context.build_messages()
    tools = self.context.get_tool_definitions()

    response = self.llm.chat(
        messages,
        tools=tools
    )

    if response.has_tool_calls():
        for tc in response.tool_calls:
            result = self.tools.execute(
                tc.name,
                tc.arguments
            )

            self.context.add_tool_result(
                tc.id,
                tc.name,
                result
            )
    else:
        final_response = response.content
        break

これだけでも、AIエージェントの核心がかなり見える。

AIに一回質問して終わり、ではない。

AIが考える。

↓

ツールを使う。

↓

結果を見る。

↓

もう一度考える。

↓

またツールを使う。

↓

結果を見る。

↓

目的を達成するまで続ける。

この「回し方」がエージェントだった。

そして「4つのエンジニアリング」がつながった

ここで、やっと腑に落ちた。

  • プロンプトエンジニアリング
  • コンテキストエンジニアリング
  • ハーネスエンジニアリング
  • ループエンジニアリング

AIエージェントを作るための4つの概念でしょ


① プロンプトエンジニアリング

まず、AIに何をさせるか。

今回のエージェントにもSystem Promptがある。

You are MyAgent, an autonomous coding assistant.

You help users by reading code,
writing code,
editing files,
running commands,
and fixing bugs.

AIに「あなたは何者なのか」「何をするのか」を与える。

これが最初の入口。


② コンテキストエンジニアリング

次に重要なのが、

何をAIに見せるか。

今回の実装ではContextManagerを作った。

System Prompt。

ユーザーの入力。

AIの回答。

Tool Call。

Tool Result。

これらを履歴として保持して、次のLLM呼び出しに渡す。

def build_messages(self):
    result = [
        {
            "role": "system",
            "content": self.system_prompt
        }
    ]

    result.extend(self.messages)

    return result

AIエージェントでは、①プロンプトと②コンテキストが

「①どんなプロンプトを書くか」 と「②次のAIに、何を記憶として渡すか」

として実装された。


③ ハーネスエンジニアリング

AIに自由にやらせるのは危ない。

例えば、rm -rf / なんて実行されたら終わる。

そこで、AIとツールの間にガードを入れる。

DANGEROUS_PATTERNS = [
    "rm -rf /",
    "rm -rf ~",
    "rm -rf *",
    "mkfs",
    ":(){ :|:& };:",
]

のような危険コマンドをブラックリストでブロック。

read_file   → auto
list_files  → auto
search_files → auto

write_file  → ask
edit_file   → ask
run_command → ask

のように、ツールごとに権限も設定できる。

AIに何をさせるかだけではなく、

AIが何をしてはいけないか

これがハーネス。


④ そしてループエンジニアリング

ここが今回、一番大きかった発見。

AIエージェントは、

Prompt  → LLM  → Answer

ではない。

Prompt → LLM → Tool → Result→Context → LLM → Tool → Result → Context → LLM → …

AIの能力を「1回の回答」ではなく「何回回せるか」で引き出す。

今回のMyAgentでは最大ターン数も設定している。

max_turns = 50

エージェントを回す。

while self.turn_count < self.max_turns:

できるだけ少ないループで回数を減らせるのがベスト。ここが設計の要でしょうね。

悪いループは、何やってんだAIになる

LLM → 間違える → LLM →  さらに間違える → LLM → もっと間違える

対策は ループ × コンテキスト × ツール × ガードレールの設計が必要


AIエージェントを作ると、LLMの見え方が変わる

ChatGPTのようなチャットだけを使っていると、

「AIが回答している」

ように見える。

でもエージェントを作ってみると、景色が変わる。

実際に起きていることは、

        ┌──────────────┐
        │     LLM      │
        └──────┬───────┘
               │
          Tool Call
               ↓
        ┌──────────────┐
        │     Tool     │
        └──────┬───────┘
               │
           Tool Result
               ↓
        ┌──────────────┐
        │   Context    │
        └──────┬───────┘
               │
               └────────→ LLM

これを延々と回している。

だから、

「AIエージェントを作る」=「LLMを賢くする」

ではない。

「LLMをどういう環境に置いて、どう回すかを設計する」

という話だった。


Claude CodeやCodexがすごい理由も少し見えてきた

ここまで自作してみると、

Claude CodeやCodexのようなCoding Agentが単なる「LLMチャット」ではないことがよくわかる。

重要なのは、

  • コンテキスト管理
  • ツール定義
  • ツール実行
  • 権限制御
  • エラー処理
  • プロンプト
  • 状態管理
  • ループ制御
  • 最大ターン数
  • ログ
  • ユーザー確認

などを全部まとめて設計していること。

今回の自作エージェントでも、実際に、

ReadFileTool
WriteFileTool
EditFileTool
ListFilesTool
SearchFilesTool
RunCommandTool

というツールを登録している。

そしてLLMが必要に応じて呼び出す。

Coding Agentは「制御システム + LLM + ツール + ループ 」が肝で

制御システムがコンテキストとハーネス。ループはループ。


結局、AIエージェント開発で重要なのは何なのか

今回、自作してみて整理するとこうなった。

レイヤー やること
プロンプト AIに何をさせるか
コンテキスト AIに何を見せるか
ハーネス AIをどう安全に制御するか
ツール AIに何ができるようにするか
ループ AIをどう繰り返し動かすか

そして全部をつなぐと、

                ┌─────────────┐
                │   Prompt    │
                └──────┬──────┘
                       ↓
                ┌─────────────┐
                │  Context    │
                └──────┬──────┘
                       ↓
                ┌─────────────┐
                │     LLM     │
                └──────┬──────┘
                       ↓
                ┌─────────────┐
                │    Tool     │
                └──────┬──────┘
                       ↓
                ┌─────────────┐
                │   Harness   │
                └──────┬──────┘
                       ↓
                ┌─────────────┐
                │    Result   │
                └──────┬──────┘
                       │
                       └────────→ Context
                                      │
                                      └──→ LLM

という循環になる。

これが、今回自作して一番腹落ちした部分。


「エージェントを作る」とは何だったのか

最初は、

「PythonでLLM APIを呼んで、ファイル操作できるようにする」

くらいのつもりだった。

でも実際に作ってみたら違った。

AIに能力を与えることではなく、AIが能力を使い続けられる仕組みを作ること。

AIが暴走しないように囲いを作ること。

必要な情報を次のループへ渡すこと。

その全部を設計する。

プロンプトエンジニアリング
コンテキストエンジニアリング
ハーネスエンジニアリング
ループエンジニアリング

という言葉が、やっと一つにつながった。

AIエージェント開発とは、LLMを中心にした「制御ループ」を設計すること。

ここまでわかると、Claude CodeやCodexを見る目も変わる。

「このループ、どう設計してるんだ?」になる。

だから、ここが妙に腹落ち。

ループエンジニアリングの言い出しっぺのBoris Chernyさんも
乗っかったPeter Steinbergerさんもエージェントの開発者。

ここで満足し終了した。

付録 ソースコード全文

クリックで展開

結構ボリューム大きいですが、Linuxで動作確認済み。

#初期化
Myagent.py --init
#設定ファイルにAPIキーを記載
vi config.json
#起動
Myagent.py

次の改善のためソースの更新しました。
APIキー入力のUX改善 → Sakura互換対応・エラー可視化 → ログレベル制御 → read_file改善

Myagent.py

_BUNDLED_PROMPTS = {"01-system": "You are MyAgent, an autonomous coding assistant. You help users by reading code, writing code, editing files, running commands, and fixing bugs.You have access to the following tools to interact with the user's filesystem and environment. Always think step by step before taking action.Guidelines:- Prefer reading files before modifying them- Write clean, correct code- Run commands to verify your work when appropriate- If a task seems complex, break it down into smaller steps- Always report what you did and what the results were", "02-tools": "You can invoke tools by emitting a JSON object in your response with this exact format:```json{\"tool\": \"tool_name\", \"arguments\": {\"arg1\": \"value1\", ...}}```After each tool execution, you will receive the result and can decide the next step.Read the contents of a file.- path (string, required): File path to readWrite content to a file (overwrites existing).- path (string, required): File path- content (string, required): File contentReplace text in an existing file.- path (string, required): File path- old_string (string, required): Text to replace- new_string (string, required): Replacement textList files in a directory.- path (string, required): Directory path- recursive (boolean, optional): List recursivelySearch for a pattern in files using grep-like search.- pattern (string, required): Search pattern- path (string, optional): Directory or file to search (default: current)Execute a shell command in the working directory.- command (string, required): Command to execute- timeout (integer, optional): Timeout in seconds", "03-rules": "1. Think-before-act: Always consider what you need to know before modifying anything.2. Verify after change: After editing a file, verify syntax or run tests if applicable.3. Prefer explicit: Do not make hidden changes; only modify what the user asked.4. Safety first: Never run destructive commands without user confirmation.5. Report clearly: Summarize actions and results in the final response.6. One tool at a time: Wait for the result of each tool before deciding the next.When you finish the task, provide a concise summary of what was done.", "10-observer-guide": "あなたはファイルの内容を深く理解することが目的です。読み取った内容を分析し、重要なポイントを整理してください。", "11-implementer-guide": "あなたはコードの作成・変更を行います。既存コードとの整合性を保ちながら、目的を達成する最小限の変更を心がけてください。", "12-researcher-guide": "あなたはコードベースを探索・調査しています。検索結果を整理し、必要な情報を正確に特定してください。", "13-executor-guide": "あなたはシェルコマンドを実行して結果を確認しています。出力を正確に解釈し、エラーがあれば原因を特定してください。", "14-summarizer-guide": "あなたはこれまでの作業を要約して報告します。重要なポイントと次のアクションを明確にしてください。", "15-archivist-guide": "あなたはこれまでの作業記録を整理し、次回のタスク引継に備えます。重要な決定事項と未解決項目を記録してください。"}
_BUNDLED_SEQ = ["01-system", "02-tools", "03-rules", "---role:observer---", "10-observer-guide", "---role:implementer---", "11-implementer-guide", "---role:researcher---", "12-researcher-guide", "---role:executor---", "13-executor-guide", "---role:summarizer---", "14-summarizer-guide", "---role:archivist---", "15-archivist-guide"]
# Module: myagent
__version__ = "0.7.0"

# Module: myagent.archivist
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
class ContextCompactor:
    COMPACTION_THRESHOLD = 0.7
    KEEP_RATIO = 0.3
    def __init__(self, max_history: int = 100):
        self.max_history = max_history
        self.compaction_threshold = int(max_history * self.COMPACTION_THRESHOLD)
        self._compressed_summary: Optional[str] = None
        self._compression_count = 0
        self._cumulative_files_accessed: set = set()
        self._cumulative_files_modified: set = set()
        self._cumulative_tools: set = set()
        self._cumulative_errors: List[str] = []
        self._cumulative_decisions: List[str] = []
    def is_compaction_needed(self, message_count: int) -> bool:
        return message_count >= self.compaction_threshold
    def compact(self, messages: List[Dict]) -> Tuple[List[Dict], str]:
        keep_count = max(int(len(messages) * self.KEEP_RATIO), 4)
        recent = messages[-keep_count:]
        older = messages[:-keep_count]
        for msg in older:
            self._extract_metadata(msg)
        summary = self._build_summary()
        self._compressed_summary = summary
        self._compression_count += 1
        summary_msg = {
            "role": "assistant",
            "content": f"[Context compaction #{self._compression_count}]\n{summary}",
        }
        return [summary_msg] + recent, summary
    def _extract_metadata(self, msg: Dict[str, Any]) -> None:
        role = msg.get("role", "")
        content = msg.get("content", "") or ""
        name = msg.get("name", "")
        if role == "user":
            text = content if len(content) < 80 else content[:77] + "..."
            self._cumulative_decisions.append(f"Q: {text}")
        elif role == "assistant":
            if content and len(content) < 120:
                self._cumulative_decisions.append(f"A: {content}")
            if "tool_calls" in msg:
                for tc in msg["tool_calls"]:
                    func = tc.get("function", {})
                    tname = func.get("name", "")
                    args = func.get("arguments", "{}")
                    if tname:
                        self._cumulative_tools.add(tname)
                        try:
                            arg_dict = json.loads(args)
                            path = arg_dict.get("path")
                            if path:
                                if tname in ("read_file", "list_files", "search_files"):
                                    self._cumulative_files_accessed.add(path)
                                elif tname in ("write_file", "edit_file"):
                                    self._cumulative_files_modified.add(path)
                        except json.JSONDecodeError:
                            pass
        elif role == "tool":
            if content.startswith("ERROR:") or content.startswith("PERMISSION_DENIED:"):
                self._cumulative_errors.append(f"{name}: {content[:60]}")
    def _build_summary(self) -> str:
        parts: List[str] = []
        if self._cumulative_files_modified:
            parts.append(f"Files modified: {', '.join(sorted(self._cumulative_files_modified))}")
        if self._cumulative_files_accessed:
            parts.append(f"Files accessed: {', '.join(sorted(self._cumulative_files_accessed))}")
        if self._cumulative_tools:
            parts.append(f"Tools: {', '.join(sorted(self._cumulative_tools))}")
        if self._cumulative_decisions:
            parts.append("Exchange:")
            for d in self._cumulative_decisions[-6:]:
                parts.append(f"  {d}")
        if self._cumulative_errors:
            parts.append("Errors:")
            for e in self._cumulative_errors[-3:]:
                parts.append(f"  {e}")
        return "\n".join(parts) if parts else "Previous context condensed."
    def save_state(self, summary: str, messages: List[Dict], state_dir: str = ".myagent") -> Path:
        state_path = Path(state_dir)
        state_path.mkdir(parents=True, exist_ok=True)
        state_file = state_path / "state.json"
        recent_context = []
        for msg in messages[-6:]:
            recent_context.append({
                "role": msg.get("role", ""),
                "content": msg.get("content", "")[:200],
            })
        data = {
            "summary": summary,
            "timestamp": datetime.now().isoformat(),
            "compression_count": self._compression_count,
            "recent_context": recent_context,
        }
        state_file.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        return state_file
    def load_state(self, state_dir: str = ".myagent") -> Optional[Dict]:
        state_file = Path(state_dir) / "state.json"
        if state_file.exists():
            return json.loads(state_file.read_text(encoding="utf-8"))
        return None
    def get_summary(self) -> Optional[str]:
        return self._compressed_summary

# Module: myagent.llm
import json
import logging
import os
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional
import requests
logger = logging.getLogger("myagent.llm")
class OptimizeStrategy(Enum):
    MINIMIZE_TOKENS = "min_tokens"
    MINIMIZE_REQUESTS = "min_requests"
class Counters:
    def __init__(self):
        self.total_requests = 0
        self.total_prompt_tokens = 0
        self.total_completion_tokens = 0
        self.total_tokens = 0
    def accumulate(self, usage: Optional[Dict[str, int]]) -> None:
        if usage is None:
            return
        self.total_requests += 1
        self.total_prompt_tokens += usage.get("prompt_tokens", 0)
        self.total_completion_tokens += usage.get("completion_tokens", 0)
        self.total_tokens += usage.get("total_tokens", 0)
    def log_current(self) -> None:
        logger.info(
            "Cumulative: requests=%d, prompt_tokens=%d, completion_tokens=%d, total_tokens=%d",
            self.total_requests,
            self.total_prompt_tokens,
            self.total_completion_tokens,
            self.total_tokens,
        )
    def summary(self) -> str:
        return (
            f"Requests: {self.total_requests}, "
            f"Tokens: {self.total_prompt_tokens}p/{self.total_completion_tokens}c = {self.total_tokens}"
        )
@dataclass
class ToolCall:
    id: str
    name: str
    arguments: Dict[str, Any]
@dataclass
class OptimizedRequest:
    messages: List[Dict[str, Any]]
    tools: Optional[List[Dict]]
    model: Optional[str]
@dataclass
class LLMResponse:
    content: Optional[str] = None
    tool_calls: List[ToolCall] = field(default_factory=list)
    raw_response: Optional[Dict] = None
    usage: Optional[Dict[str, int]] = None
    def has_tool_calls(self) -> bool:
        return len(self.tool_calls) > 0
class LLMProvider(ABC):
    @abstractmethod
    def chat(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> LLMResponse:
        pass
    @abstractmethod
    def optimize_request(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> OptimizedRequest:
        pass
    @abstractmethod
    def get_counters(self) -> Counters:
        pass
class OpenAICompatibleProvider(LLMProvider):
    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = "https://api.openai.com/v1",
        default_model: str = "gpt-4o-mini",
        timeout: int = 120,
        optimize: OptimizeStrategy = OptimizeStrategy.MINIMIZE_TOKENS,
        convert_tool_role: bool = False,
    ):
        self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
        self.base_url = base_url.rstrip("/")
        self.default_model = default_model
        self.timeout = timeout
        self.optimize = optimize
        self.convert_tool_role = convert_tool_role
        self.counters = Counters()
        if not self.api_key:
            raise ValueError(
                "API key is required. Set OPENAI_API_KEY env var or pass api_key."
            )
        masked_key = self.api_key[:8] + "..." + self.api_key[-4:] if len(self.api_key) > 12 else "..."
        logger.info(
            "Provider initialized: base_url=%s, model=%s, optimize=%s",
            self.base_url,
            self.default_model,
            self.optimize.name,
        )
        logger.debug("API key: %s", masked_key)
    def _prepare_tools(self, tools: List[Dict]) -> List[Dict]:
        return [
            {
                "type": "function",
                "function": {
                    "name": t["name"],
                    "description": t["description"],
                    "parameters": t.get("parameters", {"type": "object", "properties": {}}),
                },
            }
            for t in tools
        ]
    def optimize_request(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> OptimizedRequest:
        if self.optimize == OptimizeStrategy.MINIMIZE_TOKENS:
            return self._optimize_min_tokens(messages, tools, model)
        else:
            return self._optimize_min_requests(messages, tools, model)
    def _optimize_min_tokens(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> OptimizedRequest:
        tool_indices = [i for i, m in enumerate(messages) if m.get("role") == "tool"]
        if len(tool_indices) <= 4:
            return OptimizedRequest(messages=messages, tools=tools, model=model)
        keep_from = tool_indices[-4]
        optimized = messages[:1] + messages[keep_from:]
        saved = len(messages) - len(optimized)
        logger.info("Token optimization: removed %d older messages (%d -> %d)", saved, len(messages), len(optimized))
        return OptimizedRequest(messages=optimized, tools=tools, model=model)
    def _optimize_min_requests(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> OptimizedRequest:
        if len(messages) < 3:
            return OptimizedRequest(messages=messages, tools=tools, model=model)
        optimized: List[Dict[str, Any]] = []
        i = 0
        while i < len(messages):
            msg = messages[i]
            role = msg.get("role", "")
            if role == "tool":
                tool_results: List[str] = []
                tool_names: List[str] = []
                while i < len(messages) and messages[i].get("role") == "tool":
                    content = messages[i].get("content", "")
                    name = messages[i].get("name", "tool")
                    tool_results.append(f"[{name}]: {content[:150]}" + ("..." if len(content) > 150 else ""))
                    tool_names.append(name)
                    i += 1
                if len(tool_results) > 1:
                    merged = {
                        "role": "user",
                        "content": f"Tool execution results ({len(tool_results)} tools):\n" + "\n".join(tool_results),
                    }
                    optimized.append(merged)
                    logger.debug("Request optimization: merged %d tool results into 1 message", len(tool_results))
                else:
                    optimized.append(msg)
            elif role == "assistant" and i + 1 < len(messages) and messages[i + 1].get("role") != "tool":
                optimized.append(msg)
                i += 1
            else:
                optimized.append(msg)
                i += 1
        saved = len(messages) - len(optimized)
        if saved > 0:
            logger.info("Request optimization: merged %d messages (%d -> %d)", saved, len(messages), len(optimized))
        return OptimizedRequest(messages=optimized, tools=tools, model=model)
    def _convert_messages_for_sakura(
        self, messages: List[Dict[str, Any]]
    ) -> List[Dict[str, Any]]:
        converted: List[Dict[str, Any]] = []
        for msg in messages:
            if msg.get("role") == "tool":
                name = msg.get("name", "tool")
                content = msg.get("content", "")
                converted.append({
                    "role": "user",
                    "content": f"[Tool result from {name}]\n{content}",
                })
            else:
                converted.append(msg)
        return converted
    def chat(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> LLMResponse:
        optimized = self.optimize_request(messages, tools, model)
        final_messages = optimized.messages
        if self.convert_tool_role or "sakura" in self.base_url.lower():
            before = len([m for m in final_messages if m.get("role") == "tool"])
            final_messages = self._convert_messages_for_sakura(final_messages)
            if before > 0:
                logger.debug("Sakura compat: converted %d tool messages to user role", before)
        payload: Dict[str, Any] = {
            "model": model or self.default_model,
            "messages": final_messages,
        }
        if optimized.tools:
            payload["tools"] = self._prepare_tools(optimized.tools)
            payload["tool_choice"] = "auto"
        headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }
        url = f"{self.base_url}/chat/completions"
        logger.debug(
            "LLM request: model=%s, messages=%d, tools=%s",
            payload["model"],
            len(payload["messages"]),
            "yes" if optimized.tools else "no",
        )
        logger.debug(
            "Request payload:\n%s",
            json.dumps(payload, ensure_ascii=False, indent=2),
        )
        start_time = datetime.now()
        try:
            response = requests.post(
                url, headers=headers, json=payload, timeout=self.timeout
            )
            response.raise_for_status()
            data = response.json()
        except requests.exceptions.HTTPError as e:
            logger.error("=" * 60)
            logger.error("HTTP ERROR: %s", e.response.status_code)
            logger.error("Response body: %s", e.response.text)
            logger.error("--- Request details ---")
            logger.error("URL: %s", url)
            logger.error("Headers: %s", {k: v[:20] + "..." if k.lower() == "authorization" and len(v) > 30 else v for k, v in headers.items()})
            for i, msg in enumerate(payload.get("messages", [])):
                role = msg.get("role", "unknown")
                content = msg.get("content", "") or ""
                tool_calls = msg.get("tool_calls", [])
                content_preview = content[:100].replace("\n", " ") if isinstance(content, str) else str(content)[:100]
                if tool_calls:
                    tc_names = [tc.get("function", {}).get("name", "?") for tc in tool_calls]
                    logger.error("  msg[%d] role=%s tool_calls=%s", i, role, tc_names)
                else:
                    logger.error("  msg[%d] role=%s content=%s", i, role, content_preview)
            logger.error("=" * 60)
            raise
        except requests.exceptions.RequestException as e:
            logger.error("Request failed: %s", e)
            raise
        elapsed_ms = (datetime.now() - start_time).total_seconds() * 1000
        logger.info("LLM response received in %.1f ms", elapsed_ms)
        logger.debug(
            "Response body:\n%s",
            json.dumps(data, ensure_ascii=False, indent=2),
        )
        choice = data["choices"][0]
        message = choice["message"]
        content = message.get("content")
        tool_calls = []
        for tc in message.get("tool_calls", []):
            if tc["type"] == "function":
                func = tc["function"]
                tool_calls.append(
                    ToolCall(
                        id=tc["id"],
                        name=func["name"],
                        arguments=json.loads(func["arguments"]),
                    )
                )
        usage = data.get("usage")
        if usage:
            logger.info(
                "Tokens: prompt=%d, completion=%d, total=%d",
                usage.get("prompt_tokens", 0),
                usage.get("completion_tokens", 0),
                usage.get("total_tokens", 0),
            )
        self.counters.accumulate(usage)
        self.counters.log_current()
        if content:
            logger.debug("Response content: %s", content[:500])
        if tool_calls:
            for tc in tool_calls:
                logger.debug("Tool call: %s(%s)", tc.name, json.dumps(tc.arguments, ensure_ascii=False))
        return LLMResponse(
            content=content,
            tool_calls=tool_calls,
            raw_response=data,
            usage=usage,
        )
    def get_counters(self) -> Counters:
        return self.counters
class DebugProvider(LLMProvider):
    def __init__(self, default_model: str = "debug"):
        self.default_model = default_model
        self.counters = Counters()
    def optimize_request(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> OptimizedRequest:
        return OptimizedRequest(messages=messages, tools=tools, model=model)
    def chat(
        self,
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ) -> LLMResponse:
        logger.info("DEBUG mode: skipping API call")
        logger.debug("Messages: %d, Tools: %s", len(messages), "yes" if tools else "no")
        if tools:
            tool_names = [t["name"] for t in tools]
            logger.debug("Available tools: %s", ", ".join(tool_names))
        for msg in reversed(messages):
            role = msg.get("role", "")
            if role in ("user", "assistant"):
                content = msg.get("content", "") or ""
                logger.debug("Last %s message: %s", role, content[:200])
                break
        simulated_usage = {
            "prompt_tokens": sum(len(str(m.get("content", ""))) // 4 for m in messages),
            "completion_tokens": 20,
            "total_tokens": sum(len(str(m.get("content", ""))) // 4 for m in messages) + 20,
        }
        self.counters.accumulate(simulated_usage)
        self.counters.log_current()
        return LLMResponse(
            content="DEBUG: This is a test response. No API call was made.",
            usage=simulated_usage,
        )
    def get_counters(self) -> Counters:
        return self.counters
def create_provider(config: Dict[str, Any]) -> LLMProvider:
    api_key = config.get("api_key", "")
    if api_key == "DEBUG":
        logger.info("Using DebugProvider (dry-run, no API calls)")
        return DebugProvider(default_model=config.get("model", "debug"))
    provider_type = config.get("provider", "openai")
    optimize = OptimizeStrategy.MINIMIZE_TOKENS
    if provider_type in ("sakura", "local", "ollama"):
        optimize = OptimizeStrategy.MINIMIZE_REQUESTS
        logger.info("Provider '%s' detected: using MINIMIZE_REQUESTS strategy", provider_type)
    if provider_type in ("openai", "deepseek"):
        return OpenAICompatibleProvider(
            api_key=api_key,
            base_url=config.get("base_url", "https://api.openai.com/v1"),
            default_model=config.get("model", "gpt-4o-mini"),
            timeout=config.get("timeout", 120),
            optimize=optimize,
            convert_tool_role=config.get("convert_tool_role", False),
        )
    raise ValueError(f"Unknown provider type: {provider_type}")

# Module: myagent.permission
from typing import Dict
class PermissionManager:
    DEFAULT_LEVELS = {
        "read_file": "auto",
        "list_files": "auto",
        "search_files": "auto",
        "write_file": "ask",
        "edit_file": "ask",
        "run_command": "ask",
    }
    DANGEROUS_PATTERNS = [
        "rm -rf /",
        "rm -rf ~",
        "rm -rf *",
        "mkfs",
        ":(){ :|:& };:",
    ]
    def __init__(self, overrides: Dict[str, str] = None):
        self.levels = dict(self.DEFAULT_LEVELS)
        if overrides:
            self.levels.update(overrides)
    def check(self, tool_name: str, arguments: Dict = None) -> bool:
        level = self.levels.get(tool_name, "ask")
        if tool_name == "run_command" and arguments:
            cmd = arguments.get("command", "")
            for pattern in self.DANGEROUS_PATTERNS:
                if pattern in cmd:
                    print(f"[Permission] BLOCKED dangerous command: {cmd}")
                    return False
        if level == "auto":
            return True
        if level == "deny":
            print(f"[Permission] Tool '{tool_name}' is denied by policy.")
            return False
        arg_summary = ""
        if arguments:
            if tool_name in ("write_file", "edit_file"):
                arg_summary = f" path={arguments.get('path', '?')}"
            elif tool_name == "run_command":
                arg_summary = f" command={arguments.get('command', '?')[:60]}"
        prompt = f"Allow {tool_name}{arg_summary}? [y/N/s(always Skip)/a(always Allow)] "
        response = input(prompt).strip().lower()
        if response in ("a", "always"):
            self.levels[tool_name] = "auto"
            return True
        if response in ("s", "skip"):
            self.levels[tool_name] = "deny"
            return False
        return response in ("y", "yes")
    def set_level(self, tool_name: str, level: str) -> None:
        if level not in ("auto", "ask", "deny"):
            raise ValueError("Level must be auto, ask, or deny")
        self.levels[tool_name] = level
    def get_summary(self) -> str:
        lines = ["Permission levels:"]
        for tool, level in sorted(self.levels.items()):
            lines.append(f"  {tool}: {level}")
        return "\n".join(lines)

# Module: myagent.prompt_loader
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
class PromptLoader:
    DEFAULT_PROMPT = (
        "You are MyAgent, an autonomous coding assistant. "
        "You help users by reading code, writing code, editing files, "
        "running commands, and fixing bugs."
    )
    _ROLE_SECTION_PREFIX = "---role:"
    def __init__(self, prompts_dir: str):
        self.prompts_dir = Path(prompts_dir)
        self.seq_file = self.prompts_dir / "00-seq.txt"
        self._base_ids: Optional[List[str]] = None
        self._role_map: Optional[Dict[str, List[str]]] = None
    def _parse_sequence(self) -> Tuple[List[str], Dict[str, List[str]]]:
        if not self.seq_file.exists():
            return [], {}
        base_ids: List[str] = []
        role_map: Dict[str, List[str]] = {}
        current_role: Optional[str] = None
        with open(self.seq_file, "r", encoding="utf-8") as f:
            for line in f:
                stripped = line.split("#")[0].strip()
                if not stripped:
                    continue
                if stripped.startswith(self._ROLE_SECTION_PREFIX):
                    role_name = stripped[
                        len(self._ROLE_SECTION_PREFIX) :
                    ].rstrip("-").strip()
                    current_role = role_name
                    continue
                if current_role is not None:
                    role_map.setdefault(current_role, []).append(stripped)
                else:
                    base_ids.append(stripped)
        return base_ids, role_map
    def _ensure_parsed(self) -> Tuple[List[str], Dict[str, List[str]]]:
        if self._base_ids is None or self._role_map is None:
            self._base_ids, self._role_map = self._parse_sequence()
        return self._base_ids, self._role_map
    def _read_sequence(self) -> List[str]:
        base_ids, _ = self._ensure_parsed()
        return list(base_ids)
    def _load_prompt_file(self, prompt_id: str) -> Optional[str]:
        file_path = self.prompts_dir / f"{prompt_id}.txt"
        if not file_path.exists():
            print(f"[PromptLoader] Warning: {file_path} not found, skipping")
            return None
        with open(file_path, "r", encoding="utf-8") as f:
            lines = f.readlines()
        filtered = [line for line in lines if not line.strip().startswith("#")]
        return "".join(filtered).strip()
    def load(self, separator: str = "\n\n---\n\n") -> str:
        if "_BUNDLED_PROMPTS" in globals() and "_BUNDLED_SEQ" in globals():
            bp = globals()["_BUNDLED_PROMPTS"]
            bs = globals()["_BUNDLED_SEQ"]
            parts = []
            for pid in bs:
                if pid in bp:
                    parts.append(bp[pid])
            if parts:
                return separator.join(parts)
            return self.DEFAULT_PROMPT
        prompt_ids = self._read_sequence()
        if not prompt_ids:
            print("[PromptLoader] No sequence file found, using default prompt")
            return self.DEFAULT_PROMPT
        parts = []
        for pid in prompt_ids:
            content = self._load_prompt_file(pid)
            if content is not None:
                parts.append(content)
        if not parts:
            print("[PromptLoader] No prompt files loaded, using default prompt")
            return self.DEFAULT_PROMPT
        return separator.join(parts)
    def load_role(self, role: str, separator: str = "\n\n---\n\n") -> Optional[str]:
        if "_BUNDLED_PROMPTS" in globals() and "_BUNDLED_SEQ" in globals():
            bp = globals()["_BUNDLED_PROMPTS"]
            bs = globals()["_BUNDLED_SEQ"]
            role_map: Dict[str, List[str]] = {}
            current_role: Optional[str] = None
            for entry in bs:
                if entry.startswith("role:"):
                    current_role = entry[5:]
                    continue
                if current_role is not None:
                    role_map.setdefault(current_role, []).append(entry)
            if role in role_map:
                parts = []
                for pid in role_map[role]:
                    if pid in bp:
                        parts.append(bp[pid])
                if parts:
                    return separator.join(parts)
            return None
        _, role_map = self._ensure_parsed()
        if role not in role_map:
            return None
        parts = []
        for pid in role_map[role]:
            content = self._load_prompt_file(pid)
            if content is not None:
                parts.append(content)
        if not parts:
            return None
        return separator.join(parts)
    def list_roles(self) -> List[str]:
        _, role_map = self._ensure_parsed()
        return sorted(role_map.keys())

# Module: myagent.tools.base
from abc import ABC, abstractmethod
from typing import Any, Dict
class Tool(ABC):
    @property
    @abstractmethod
    def name(self) -> str:
        pass
    @property
    @abstractmethod
    def description(self) -> str:
        pass
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {},
        }
    @abstractmethod
    def execute(self, arguments: Dict[str, Any]) -> str:
        pass
    def to_definition(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "description": self.description,
            "parameters": self.parameters,
        }
    def _require(self, arguments: Dict[str, Any], key: str) -> Any:
        if key not in arguments:
            raise ValueError(f"Missing required argument: '{key}' for tool '{self.name}'")
        return arguments[key]

# Module: myagent.logger
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
DEFAULT_LOG_DIR = "logs"
_logger_cache: dict[str, logging.Logger] = {}
def get_logger(name: str) -> logging.Logger:
    if name in _logger_cache:
        return _logger_cache[name]
    logger = logging.getLogger(name)
    _logger_cache[name] = logger
    return logger
def _resolve_level(level: Optional[str]) -> int:
    if level is None:
        return logging.NOTSET
    mapping = {
        "DEBUG": logging.DEBUG,
        "INFO": logging.INFO,
        "WARNING": logging.WARNING,
        "WARN": logging.WARNING,
        "ERROR": logging.ERROR,
        "CRITICAL": logging.CRITICAL,
    }
    return mapping.get(level.upper(), logging.NOTSET)
def setup_logging(
    log_dir: Optional[str] = None,
    console_level: int = logging.INFO,
    file_level: int = logging.DEBUG,
    log_level: Optional[str] = None,
) -> Path:
    root_logger = logging.getLogger("myagent")
    root_logger.setLevel(logging.DEBUG)
    for handler in list(root_logger.handlers):
        root_logger.removeHandler(handler)
    if log_level is not None:
        resolved = _resolve_level(log_level)
        effective_console = resolved
        effective_file = resolved
    else:
        effective_console = console_level
        effective_file = file_level
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setLevel(effective_console)
    console_format = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(message)s",
        datefmt="%H:%M:%S",
    )
    console_handler.setFormatter(console_format)
    root_logger.addHandler(console_handler)
    log_dir_path = Path(log_dir or DEFAULT_LOG_DIR)
    log_dir_path.mkdir(parents=True, exist_ok=True)
    date_str = datetime.now().strftime("%Y-%m-%d")
    log_file = log_dir_path / f"myagent_{date_str}.log"
    file_handler = logging.FileHandler(str(log_file), encoding="utf-8")
    file_handler.setLevel(effective_file)
    file_format = logging.Formatter(
        "%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )
    file_handler.setFormatter(file_format)
    root_logger.addHandler(file_handler)
    root_logger.info("Logging initialized. File: %s, level: %s", log_file, log_level or "default")
    return log_file

# Module: myagent.context
from pathlib import Path
from typing import Any, Dict, List, Optional
class ContextManager:
    def __init__(self, system_prompt: str, max_history: int = 100):
        self.system_prompt = system_prompt
        self.max_history = max_history
        self.messages: List[Dict[str, Any]] = []
        self.tool_definitions: List[Dict[str, Any]] = []
        self._current_role: Optional[str] = None
        self.role_prompt: Optional[str] = None
        self._compactor = ContextCompactor(max_history=max_history)
    def set_tool_definitions(self, tools: List[Dict[str, Any]]) -> None:
        self.tool_definitions = tools
    def build_messages(self) -> List[Dict[str, Any]]:
        result: List[Dict[str, Any]] = [
            {"role": "system", "content": self.system_prompt}
        ]
        if self.role_prompt:
            result.append({"role": "system", "content": self.role_prompt})
        result.extend(self.messages)
        return result
    def set_role(self, role: Optional[str], role_prompt: Optional[str] = None) -> None:
        self._current_role = role
        self.role_prompt = role_prompt
    def get_role(self) -> Optional[str]:
        return self._current_role
    def add_user_message(self, content: str) -> None:
        self.messages.append({"role": "user", "content": content})
        self._trim_history()
    def add_assistant_message(
        self, content: Optional[str] = None, tool_calls: Optional[List[Dict]] = None
    ) -> None:
        msg: Dict[str, Any] = {"role": "assistant"}
        if content is not None:
            msg["content"] = content
        if tool_calls:
            msg["tool_calls"] = tool_calls
        self.messages.append(msg)
        self._trim_history()
    def add_tool_result(self, tool_call_id: str, name: str, result: str) -> None:
        self.messages.append(
            {
                "role": "tool",
                "tool_call_id": tool_call_id,
                "name": name,
                "content": str(result),
            }
        )
        self._trim_history()
    def get_tool_definitions(self) -> List[Dict[str, Any]]:
        return self.tool_definitions
    def get_messages(self) -> List[Dict[str, Any]]:
        return list(self.messages)
    def clear(self) -> None:
        self.messages = []
    def _trim_history(self) -> None:
        if self._compactor.is_compaction_needed(len(self.messages)):
            self.messages, summary = self._compactor.compact(self.messages)
    def get_compaction_summary(self) -> Optional[str]:
        return self._compactor.get_summary()
    def save_state(self) -> Optional["Path"]:
        summary = self._compactor.get_summary()
        if summary:
            return self._compactor.save_state(summary, self.messages)
        return None
    def load_state(self) -> Optional[Dict]:
        return self._compactor.load_state()
    def estimate_tokens(self) -> int:
        total = 0
        for msg in self.messages:
            content = msg.get("content", "")
            if content:
                total += len(content) // 4
        return total

# Module: myagent.model_router
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger("myagent.model_router")
class ModelRouter:
    def __init__(
        self,
        default_config: Dict[str, Any],
        role_configs: Optional[Dict[str, Dict[str, Any]]] = None,
    ):
        self.default_config = default_config
        self.role_configs = role_configs or {}
        self._providers: Dict[str, LLMProvider] = {}
        self._default_provider = self._create_provider("default", default_config)
    def _create_provider(self, name: str, config: Dict[str, Any]) -> LLMProvider:
        merged = dict(self.default_config)
        merged.update(config)
        try:
            provider = create_provider(merged)
            logger.info("ModelRouter: created '%s' provider (model=%s)", name, merged.get("model", "?"))
            return provider
        except Exception as e:
            logger.error("ModelRouter: failed to create '%s' provider: %s", name, e)
            raise
    def get_provider(self, role: Optional[str] = None) -> LLMProvider:
        if role is None or role not in self.role_configs:
            return self._default_provider
        if role not in self._providers:
            self._providers[role] = self._create_provider(role, self.role_configs[role])
        return self._providers[role]
    def chat(
        self,
        role: Optional[str],
        messages: List[Dict[str, Any]],
        tools: Optional[List[Dict]] = None,
        model: Optional[str] = None,
    ):
        provider = self.get_provider(role)
        return provider.chat(messages, tools=tools, model=model)
    def get_all_counters(self) -> List[Dict[str, Any]]:
        results = [{"name": "default", **self._default_provider.get_counters().__dict__}]
        for role, provider in self._providers.items():
            results.append({"name": role, **provider.get_counters().__dict__})
        return results
    def get_total_counters(self) -> Counters:
        total = Counters()
        total.__dict__.update(self._default_provider.get_counters().__dict__)
        for provider in self._providers.values():
            pc = provider.get_counters()
            total.total_requests += pc.total_requests
            total.total_prompt_tokens += pc.total_prompt_tokens
            total.total_completion_tokens += pc.total_completion_tokens
            total.total_tokens += pc.total_tokens
        return total

# Module: myagent.tools.registry
from typing import Any, Dict, List
class ToolRegistry:
    def __init__(self):
        self._tools: Dict[str, Tool] = {}
    def register(self, tool: Tool) -> None:
        if tool.name in self._tools:
            raise ValueError(f"Tool '{tool.name}' is already registered")
        self._tools[tool.name] = tool
    def register_all(self, *tools: Tool) -> None:
        for tool in tools:
            self.register(tool)
    def get(self, name: str) -> Tool:
        if name not in self._tools:
            raise KeyError(f"Tool '{name}' not found in registry")
        return self._tools[name]
    def execute(self, tool_name: str, arguments: Dict[str, Any]) -> str:
        tool = self.get(tool_name)
        try:
            return tool.execute(arguments)
        except Exception as e:
            return f"ERROR: {type(e).__name__}: {e}"
    def list_tools(self) -> List[str]:
        return list(self._tools.keys())
    def get_definitions(self) -> List[Dict[str, Any]]:
        return [tool.to_definition() for tool in self._tools.values()]
    def __contains__(self, name: str) -> bool:
        return name in self._tools

# Module: myagent.tools.search_files
import fnmatch
import math
import os
import re
from typing import Any, Dict, List, Tuple
class SearchFilesTool(Tool):
    @property
    def name(self) -> str:
        return "search_files"
    @property
    def description(self) -> str:
        return (
            "Search for a text pattern in files using substring or regex search. "
            "Results are ranked by relevance score (higher = more important)."
        )
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "pattern": {
                    "type": "string",
                    "description": "Text or regex pattern to search for",
                },
                "path": {
                    "type": "string",
                    "description": "Directory or file to search (default: current directory)",
                },
                "regex": {
                    "type": "boolean",
                    "description": "Use regex matching (default: false = literal search)",
                },
                "max_results": {
                    "type": "integer",
                    "description": "Maximum number of results to return (default: 50)",
                },
                "file_pattern": {
                    "type": "string",
                    "description": "Glob pattern for files to search (e.g., '*.py')",
                },
                "rank": {
                    "type": "boolean",
                    "description": "Rank results by BM25-like relevance score (default: true)",
                },
            },
            "required": ["pattern"],
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        pattern = self._require(arguments, "pattern")
        path = arguments.get("path", ".")
        use_regex = arguments.get("regex", False)
        max_results = arguments.get("max_results", 50)
        file_pattern = arguments.get("file_pattern")
        rank_results = arguments.get("rank", True)
        try:
            if use_regex:
                compiled = re.compile(pattern)
            else:
                compiled = None
            target = os.path.abspath(path)
            if os.path.isfile(target):
                results = self._search_file(target, pattern, compiled, use_regex)
                ranked = self._rank_results(results, pattern, use_regex, rank=rank_results)
                return self._format_results(ranked, max_results)
            if os.path.isdir(target):
                results = self._search_dir(target, pattern, compiled, use_regex, file_pattern, max_results)
                ranked = self._rank_results(results, pattern, use_regex, rank=rank_results)
                return self._format_results(ranked, max_results)
            return f"ERROR: Path not found: {target}"
        except re.error as e:
            return f"ERROR: Invalid regex pattern: {e}"
        except Exception as e:
            return f"ERROR: {e}"
    def _search_file(self, file_path: str, pattern: str, compiled, use_regex: bool) -> List[Tuple[str, int, str]]:
        results = []
        if self._is_binary(file_path):
            return results
        try:
            with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                for line_num, line in enumerate(f, start=1):
                    try:
                        if use_regex:
                            if compiled.search(line):
                                results.append((file_path, line_num, line.rstrip("\n")))
                        else:
                            if pattern in line:
                                results.append((file_path, line_num, line.rstrip("\n")))
                    except UnicodeDecodeError:
                        continue
        except (OSError, PermissionError):
            pass
        return results
    def _search_dir(self, root: str, pattern: str, compiled, use_regex: bool, file_pattern: str, max_results: int) -> List[Tuple[str, int, str]]:
        results = []
        exclude_dirs = {".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", ".claude", ".idea", ".vscode", "dist", "build"}
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
            for filename in filenames:
                if file_pattern and not fnmatch.fnmatch(filename, file_pattern):
                    continue
                file_path = os.path.join(dirpath, filename)
                results.extend(self._search_file(file_path, pattern, compiled, use_regex))
                if len(results) >= max_results * 3:
                    return results[:max_results * 3]
        return results
    def _rank_results(self, results: List[Tuple[str, int, str]], pattern: str, use_regex: bool, rank: bool = True) -> List[Tuple[str, int, str, float]]:
        if not results:
            return []
        if not rank:
            return [(p, n, l, 0.0) for p, n, l in results]
        scored = []
        pattern_words = self._tokenize(pattern)
        pattern_lower = pattern.lower()
        for file_path, line_num, line in results:
            line_lower = line.lower()
            score = self._compute_score(line_lower, pattern_lower, pattern_words, line)
            scored.append((file_path, line_num, line, score))
        scored.sort(key=lambda x: x[3], reverse=True)
        return scored
    def _tokenize(self, text: str) -> List[str]:
        tokens = re.findall(r"\b\w+\b", text.lower())
        return [t for t in tokens if len(t) > 1]
    def _compute_score(self, line_lower: str, pattern_lower: str, pattern_words: List[str], raw_line: str) -> float:
        score = 0.0
        if pattern_lower in line_lower:
            score += 10.0
            occurrences = line_lower.count(pattern_lower)
            score += min(occurrences - 1, 3) * 2.0
        line_words = self._tokenize(raw_line)
        matched_words = sum(1 for pw in pattern_words if pw in line_words)
        if pattern_words:
            word_match_ratio = matched_words / len(pattern_words)
            score += word_match_ratio * 5.0
        stripped = raw_line.strip()
        if stripped.startswith("class ") or stripped.startswith("def "):
            score += 3.0
        if stripped.startswith("import ") or stripped.startswith("from "):
            score += 1.0
        if stripped.startswith("#"):
            score += 0.5
        if len(raw_line) > 200:
            score *= 0.7
        elif len(raw_line) < 20:
            score *= 0.9
        return score
    def _format_results(self, results: List[Tuple[str, int, str, float]], max_results: int) -> str:
        if not results:
            return "No matches found."
        lines = [f"Found {len(results)} matches (top {min(len(results), max_results)} by relevance):"]
        display_order = sorted(results[:max_results], key=lambda x: (x[0], x[1]))
        for file_path, line_num, line_text, score in display_order:
            display_line = line_text
            if len(display_line) > 200:
                display_line = display_line[:200] + " ... [truncated]"
            lines.append(f"  {file_path}:{line_num} [score:{score:.1f}] {display_line}")
        if len(results) > max_results:
            lines.append(f"\n... and {len(results) - max_results} more matches")
        return "\n".join(lines)
    def _is_binary(self, file_path: str) -> bool:
        try:
            with open(file_path, "rb") as f:
                chunk = f.read(4096)
                return b"\x00" in chunk
        except:
            return True

# Module: myagent.tools.list_files
import os
from typing import Any, Dict
class ListFilesTool(Tool):
    @property
    def name(self) -> str:
        return "list_files"
    @property
    def description(self) -> str:
        return "List files and directories at a given path. Can list recursively."
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Directory path to list (default: current directory)",
                },
                "recursive": {
                    "type": "boolean",
                    "description": "List recursively (default: false)",
                },
                "max_depth": {
                    "type": "integer",
                    "description": "Maximum recursion depth (default: 3 for recursive)",
                },
            },
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        path = arguments.get("path", ".")
        recursive = arguments.get("recursive", False)
        max_depth = arguments.get("max_depth", 3)
        try:
            target = os.path.abspath(path)
            if not os.path.exists(target):
                return f"ERROR: Path not found: {target}"
            if not os.path.isdir(target):
                return f"ERROR: Not a directory: {target}"
            lines = []
            self._list_dir(target, "", lines, recursive, 0, max_depth)
            return "\n".join(lines) if lines else "(empty directory)"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"
        except Exception as e:
            return f"ERROR: {e}"
    def _list_dir(self, root: str, prefix: str, lines: list, recursive: bool, depth: int, max_depth: int) -> None:
        try:
            entries = sorted(os.listdir(root))
        except PermissionError:
            lines.append(f"{prefix}[permission denied]")
            return
        for i, entry in enumerate(entries):
            entry_path = os.path.join(root, entry)
            is_last = i == len(entries) - 1
            connector = "└── " if is_last else "├── "
            indent = "    " if is_last else ""
            if os.path.isdir(entry_path):
                lines.append(f"{prefix}{connector}{entry}/")
                if recursive and depth < max_depth:
                    self._list_dir(entry_path, prefix + indent, lines, recursive, depth + 1, max_depth)
                elif recursive and depth >= max_depth:
                    lines.append(f"{prefix}{indent}...")
            else:
                lines.append(f"{prefix}{connector}{entry}")

# Module: myagent.tools.write_file
import os
from typing import Any, Dict
class WriteFileTool(Tool):
    @property
    def name(self) -> str:
        return "write_file"
    @property
    def description(self) -> str:
        return "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Can optionally create parent directories."
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to write",
                },
                "content": {
                    "type": "string",
                    "description": "Content to write to the file",
                },
                "create_dirs": {
                    "type": "boolean",
                    "description": "Create parent directories if they don't exist (default: true)",
                },
            },
            "required": ["path", "content"],
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        path = self._require(arguments, "path")
        content = self._require(arguments, "content")
        create_dirs = arguments.get("create_dirs", True)
        try:
            if create_dirs:
                parent = os.path.dirname(os.path.abspath(path))
                if parent and not os.path.exists(parent):
                    os.makedirs(parent, exist_ok=True)
            with open(path, "w", encoding="utf-8") as f:
                f.write(content)
            lines = content.count("\n") + 1 if content else 0
            return f"Successfully wrote {len(content)} characters, {lines} lines to {path}"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"
        except Exception as e:
            return f"ERROR: {e}"

# Module: myagent.tools.run_command
import os
import subprocess
import tempfile
from typing import Any, Dict
class RunCommandTool(Tool):
    @property
    def name(self) -> str:
        return "run_command"
    @property
    def description(self) -> str:
        return "Execute a shell command in the current working directory. Use this to run tests, check syntax, or use git."
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Shell command to execute",
                },
                "timeout": {
                    "type": "integer",
                    "description": "Timeout in seconds (default: 60)",
                },
                "cwd": {
                    "type": "string",
                    "description": "Working directory for the command (default: current directory)",
                },
            },
            "required": ["command"],
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        command = self._require(arguments, "command")
        timeout = arguments.get("timeout", 60)
        try:
            cwd = arguments.get("cwd", os.getcwd())
        except FileNotFoundError:
            cwd = arguments.get("cwd", "/tmp")
        try:
            result = subprocess.run(
                command,
                shell=True,
                capture_output=True,
                text=True,
                timeout=timeout,
                cwd=cwd,
            )
            output_lines = []
            if result.stdout:
                output_lines.append("STDOUT:")
                output_lines.append(result.stdout.rstrip())
            if result.stderr:
                output_lines.append("STDERR:")
                output_lines.append(result.stderr.rstrip())
            output_lines.append(f"\nExit code: {result.returncode}")
            return "\n".join(output_lines)
        except subprocess.TimeoutExpired:
            return f"ERROR: Command timed out after {timeout} seconds"
        except Exception as e:
            return f"ERROR: {type(e).__name__}: {e}"

# Module: myagent.tools.edit_file
from typing import Any, Dict
class EditFileTool(Tool):
    @property
    def name(self) -> str:
        return "edit_file"
    @property
    def description(self) -> str:
        return "Replace text in an existing file. old_string must match exactly (including whitespace)."
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Path to the file to edit",
                },
                "old_string": {
                    "type": "string",
                    "description": "Exact text to replace",
                },
                "new_string": {
                    "type": "string",
                    "description": "Replacement text",
                },
            },
            "required": ["path", "old_string", "new_string"],
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        path = self._require(arguments, "path")
        old_string = self._require(arguments, "old_string")
        new_string = self._require(arguments, "new_string")
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
            if old_string not in content:
                if old_string.strip() in content.strip():
                    return (
                        f"ERROR: old_string not found exactly in {path}. "
                        "Note: old_string must match exactly including surrounding whitespace."
                    )
                return f"ERROR: old_string not found in {path}"
            count = content.count(old_string)
            if count > 1:
                return (
                    f"ERROR: Found {count} occurrences of old_string in {path}. "
                    "Please make the old_string more specific (include more context)."
                )
            new_content = content.replace(old_string, new_string)
            with open(path, "w", encoding="utf-8") as f:
                f.write(new_content)
            old_lines = old_string.count("\n")
            new_lines = new_string.count("\n")
            delta = new_lines - old_lines
            return f"Successfully edited {path} (+{delta} lines)"
        except FileNotFoundError:
            return f"ERROR: File not found: {path}"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"
        except Exception as e:
            return f"ERROR: {e}"

# Module: myagent.tools.read_file
from typing import Any, Dict
DEFAULT_PAGE_SIZE = 25
class ReadFileTool(Tool):
    @property
    def name(self) -> str:
        return "read_file"
    @property
    def description(self) -> str:
        return (
            "Read the contents of a file at the given path. "
            "By default reads the entire file. "
            "Use limit=N (or page=N) to read in chunks for large files."
        )
    @property
    def parameters(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Absolute or relative path to the file",
                },
                "offset": {
                    "type": "integer",
                    "description": f"Line number to start reading from (1-indexed). Ignored if page is set.",
                },
                "limit": {
                    "type": "integer",
                    "description": "Maximum number of lines to read. Set explicitly for large files; omitted reads entire file.",
                },
                "page": {
                    "type": "integer",
                    "description": f"Optional page number (1-indexed). Each page is {DEFAULT_PAGE_SIZE} lines. Only used when pagination is needed.",
                },
            },
            "required": ["path"],
        }
    def execute(self, arguments: Dict[str, Any]) -> str:
        path = self._require(arguments, "path")
        offset = arguments.get("offset")
        limit = arguments.get("limit")
        page = arguments.get("page")
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                all_lines = f.readlines()
        except FileNotFoundError:
            return f"ERROR: File not found: {path}"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"
        except Exception as e:
            return f"ERROR: {e}"
        total_lines = len(all_lines)
        if total_lines == 0:
            return "(empty file)"
        paginate = page is not None or limit is not None
        effective_limit = DEFAULT_PAGE_SIZE
        if limit is not None and limit > 0:
            effective_limit = limit
        if page is not None and page > 0:
            start = (page - 1) * effective_limit
        elif offset is not None and offset > 0:
            start = offset - 1
        else:
            start = 0
        start = max(0, min(start, total_lines))
        if paginate:
            end = min(start + effective_limit, total_lines)
        else:
            end = total_lines
        if start >= total_lines:
            return (
                f"ERROR: Requested position exceeds file length "
                f"({total_lines} lines). Use a smaller offset/page."
            )
        selected = all_lines[start:end]
        numbered_lines = []
        for i, line in enumerate(selected, start=start + 1):
            numbered_lines.append(f"{i:4d}: {line}")
        result = "".join(numbered_lines)
        if paginate and end < total_lines:
            total_pages = (total_lines + effective_limit - 1) // effective_limit
            current_page = (start // effective_limit) + 1
            footer_parts = [
                f"... ({total_lines - end} more lines)",
                f"[Page {current_page}/{total_pages}{total_lines} lines total]",
            ]
            if current_page < total_pages:
                footer_parts.append(
                    f"Next: page={current_page + 1} (or offset={end + 1})"
                )
            result += "\n" + "\n".join(footer_parts)
        elif paginate:
            current_page = (start // effective_limit) + 1
            total_pages = (total_lines + effective_limit - 1) // effective_limit
            footer_parts = [
                f"[Page {current_page}/{total_pages}{total_lines} lines total]",
            ]
            result += "\n" + "\n".join(footer_parts)
        return result.rstrip()

# Module: myagent.agent
import json
import logging
import os
from typing import Any, Dict, List, Optional, Tuple, Union
logger = logging.getLogger("myagent.agent")
ROLE_MAP: Dict[str, str] = {
    "read_file": "observer",
    "list_files": "observer",
    "write_file": "implementer",
    "edit_file": "implementer",
    "search_files": "researcher",
    "run_command": "executor",
}
KEYWORD_ROLE_MAP: List[Tuple[Tuple[str, ...], str]] = [
    (("write", "create", "edit", "add", "implement", "modify", "update"), "implementer"),
    (("read", "show", "list", "display", "look", "view"), "observer"),
    (("search", "find", "grep", "locate", "lookup"), "researcher"),
    (("run", "execute", "build", "test", "deploy", "compile"), "executor"),
    (("summarize", "explain", "report", "describe"), "summarizer"),
    (("archive", "save", "compress", "compact", "persist"), "archivist"),
    (("refactor", "restructure", "rewrite", "improve"), "refactor"),
    (("review", "check", "audit", "inspect"), "reviewer"),
    (("debug", "fix", "troubleshoot", "diagnose"), "debugger"),
]
class Agent:
    def __init__(
        self,
        llm: Union[LLMProvider, ModelRouter],
        tool_registry: ToolRegistry,
        permission: PermissionManager,
        prompt_loader: PromptLoader,
        max_turns: int = 50,
        log_level: Optional[str] = None,
    ):
        self.llm = llm
        self.tools = tool_registry
        self.permission = permission
        self.prompt_loader = prompt_loader
        self.max_turns = max_turns
        self.log_level = log_level
        self._using_router = isinstance(llm, ModelRouter)
        system_prompt = self.prompt_loader.load()
        logger.debug("System prompt length: %d chars", len(system_prompt))
        self.context = ContextManager(system_prompt=system_prompt)
        self.context.set_tool_definitions(self.tools.get_definitions())
        state = self.context.load_state()
        if state:
            prev_summary = state.get("summary", "")
            if prev_summary:
                self.context.system_prompt += (
                    f"\n\n[Previous session #{state.get('compression_count', 0)}]\n"
                    f"{prev_summary}"
                )
                logger.info(
                    "Loaded previous session state (compression #%d)",
                    state.get("compression_count", 0),
                )
        self.turn_count = 0
        logger.info("Agent initialized. max_turns=%d", max_turns)
    def _chat(self, messages, tools=None):
        if self._using_router:
            return self.llm.chat(self.context.get_role(), messages, tools=tools)
        return self.llm.chat(messages, tools=tools)
    def _get_counters(self):
        if self._using_router:
            return self.llm.get_total_counters()
        return self.llm.get_counters()
    def _resolve_role_from_keywords(self, text: str) -> Optional[str]:
        text_lower = text.lower()
        for keywords, role in KEYWORD_ROLE_MAP:
            if any(kw in text_lower for kw in keywords):
                return role
        return None
    def _resolve_role_from_tool(self, tool_name: str) -> Optional[str]:
        return ROLE_MAP.get(tool_name)
    def _apply_role(self, role: Optional[str]) -> None:
        if role is None:
            if self.context.get_role() is not None:
                logger.info("Role cleared (back to base)")
                self.context.set_role(None)
            return
        role_prompt = self.prompt_loader.load_role(role)
        if role_prompt is None:
            if self.context.get_role() is not None:
                logger.debug("Role '%s' not defined, clearing previous role", role)
                self.context.set_role(None)
            return
        if self.context.get_role() != role:
            logger.info("Role switched: %s", role)
            self.context.set_role(role, role_prompt)
    def run(self, user_request: str) -> str:
        logger.info(
            "Starting task: %s%s",
            user_request[:60],
            "..." if len(user_request) > 60 else "",
        )
        print(f"\n{'='*60}")
        print(f" MyAgent - Task: {user_request[:60]}{'...' if len(user_request) > 60 else ''}")
        print(f"{'='*60}\n")
        if self.log_level is not None:
            header_task = user_request[:80] + ("..." if len(user_request) > 80 else "")
            logger.info("===== Agent started: %s =====", header_task)
        if self._is_archivist_request(user_request):
            self._handle_archivist_request(user_request)
            return "Session state saved."
        self.context.add_user_message(user_request)
        initial_role = self._resolve_role_from_keywords(user_request)
        self._apply_role(initial_role)
        final_response = ""
        last_tool_name: Optional[str] = None
        while self.turn_count < self.max_turns:
            self.turn_count += 1
            logger.info("--- Turn %d ---", self.turn_count)
            print(f"\n--- Turn {self.turn_count} ---")
            if self.log_level is not None:
                logger.info("--- Turn %d ---", self.turn_count)
            summary = self.context.get_compaction_summary()
            if summary:
                logger.info("Context compacted: %s", summary[:100])
                print("[Context compacted]")
            if last_tool_name and self.turn_count > 1:
                tool_role = self._resolve_role_from_tool(last_tool_name)
                self._apply_role(tool_role)
                last_tool_name = None
            messages = self.context.build_messages()
            tools = self.context.get_tool_definitions()
            logger.debug("Sending %d messages to LLM (role=%s)", len(messages), self.context.get_role() or "none")
            try:
                response = self._chat(messages, tools=tools)
            except Exception as e:
                logger.error("LLM error at turn %d: %s", self.turn_count, e, exc_info=True)
                return f"LLM Error: {e}"
            if response.has_tool_calls():
                logger.info("Received %d tool call(s)", len(response.tool_calls))
                history_tool_calls = []
                for tc in response.tool_calls:
                    history_tool_calls.append({
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.name,
                            "arguments": json.dumps(tc.arguments),
                        },
                    })
                self.context.add_assistant_message(
                    content=response.content,
                    tool_calls=history_tool_calls,
                )
                for tc in response.tool_calls:
                    last_tool_name = tc.name
                    arg_str = json.dumps(tc.arguments, ensure_ascii=False)
                    logger.info("Executing tool: %s(%s)", tc.name, arg_str)
                    print(f"\n[Tool] {tc.name}({arg_str})")
                    if self.log_level is not None:
                        logger.info("[Tool] %s(%s)", tc.name, arg_str)
                    if tc.name not in self.tools:
                        result = f"ERROR: Unknown tool '{tc.name}'"
                        logger.warning("Unknown tool requested: %s", tc.name)
                        print(f"[Result] {result}")
                        self.context.add_tool_result(tc.id, tc.name, result)
                        continue
                    permitted = self.permission.check(tc.name, tc.arguments)
                    logger.debug("Permission check for %s: %s", tc.name, permitted)
                    if not permitted:
                        result = f"PERMISSION_DENIED: User declined to execute {tc.name}"
                        logger.warning("Permission denied for %s", tc.name)
                        print(f"[Result] {result}")
                        self.context.add_tool_result(tc.id, tc.name, result)
                        continue
                    result = self.tools.execute(tc.name, tc.arguments)
                    result_preview = result[:200] + ("..." if len(result) > 200 else "")
                    logger.info("Tool result: %s", result_preview)
                    print(f"[Result] {result[:500]}{'...' if len(result) > 500 else ''}")
                    if self.log_level is not None:
                        logger.info("[Result] %s", result_preview)
                    self.context.add_tool_result(tc.id, tc.name, result)
            else:
                self.context.add_assistant_message(content=response.content)
                final_response = response.content or "(no response)"
                logger.info("Agent finished at turn %d", self.turn_count)
                if self.log_level is not None:
                    logger.info("===== Agent finished =====")
                print(f"\n{'='*60}")
                print(" Agent finished")
                print(f"{'='*60}")
                break
        else:
            final_response = f"Reached max turns ({self.max_turns}). Last response: {response.content or '(none)'}"
            logger.warning("Reached max_turns limit (%s)", self.max_turns)
        counters = self._get_counters()
        summary = counters.summary()
        logger.info("Task complete — %s", summary)
        print(f"\n[{summary}]\n")
        return final_response
    def reset(self) -> None:
        self.context.clear()
        self.turn_count = 0
        logger.info("Conversation reset")
    def _is_archivist_request(self, text: str) -> bool:
        text_lower = text.lower().strip()
        return text_lower in ("archive", "save", "persist")
    def _handle_archivist_request(self, user_request: str) -> None:
        state_path = self.context.save_state()
        summary = self.context.get_compaction_summary()
        saved_msg = f"State saved"
        if state_path:
            saved_msg += f" to {state_path}"
        if summary:
            saved_msg += f" (compression summary generated)"
        logger.info("Archivist: %s", saved_msg)
        print(saved_msg)
    def get_conversation_summary(self) -> str:
        messages = self.context.get_messages()
        user_msgs = sum(1 for m in messages if m.get("role") == "user")
        assistant_msgs = sum(1 for m in messages if m.get("role") == "assistant")
        tool_results = sum(1 for m in messages if m.get("role") == "tool")
        current_role = self.context.get_role()
        compaction = self.context.get_compaction_summary()
        summary = (
            f"Conversation: {len(messages)} messages, "
            f"{user_msgs} user, {assistant_msgs} assistant, {tool_results} tool results"
            f"{f', role={current_role}' if current_role else ''}"
            f"{f' [compacted]' if compaction else ''}"
        )
        logger.debug("Summary: %s", summary)
        return summary

# Module: myagent.main
import argparse
import getpass
import json
import os
import sys
from pathlib import Path
logger = get_logger(__name__)
def _log_print(level: Optional[str], msg: str, *args) -> None:
    if level is not None:
        logger.info(msg, *args)
def load_config(config_path: str) -> dict:
    with open(config_path, "r", encoding="utf-8") as f:
        return json.load(f)
def resolve_api_key(llm_config: dict) -> str:
    api_key = llm_config.get("api_key", "")
    if api_key == "DEBUG":
        logger.info("DEBUG mode: skipping API key resolution")
        return api_key
    if isinstance(api_key, str) and api_key.startswith("${") and api_key.endswith("}"):
        env_var = api_key[2:-1]
        api_key = os.environ.get(env_var, "")
        llm_config["api_key"] = api_key
        logger.debug(
            "Resolved API key from env var %s: %s",
            env_var,
            "set" if api_key else "empty",
        )
    if not api_key:
        provider = llm_config.get("provider", "openai")
        print(f"API key not configured. Enter your API key for {provider}.")
        print("(入力は画面に表示されません — そのまま貼り付けてEnterを押してください)")
        prompt_msg = "API key: "
        try:
            api_key = getpass.getpass(prompt_msg)
        except (EOFError, OSError):
            api_key = input(prompt_msg).strip()
        llm_config["api_key"] = api_key
        logger.info("API key entered via interactive prompt.")
    return api_key
def setup_tools() -> ToolRegistry:
    registry = ToolRegistry()
    registry.register_all(
        ReadFileTool(),
        WriteFileTool(),
        EditFileTool(),
        ListFilesTool(),
        SearchFilesTool(),
        RunCommandTool(),
    )
    return registry
CONFIG_SKELETON = {
    "llm": {
        "provider": "deepseek",
        "api_key": "${API_KEY}",
        "base_url": "https://api.openai.com/v1",
        "model": "gpt-4o-mini",
        "timeout": 120,
    },
    "permissions": {
        "read_file": "auto",
        "list_files": "auto",
        "search_files": "auto",
        "write_file": "ask",
        "edit_file": "ask",
        "run_command": "ask",
    },
}
def init_config(config_path: str, force: bool = False) -> int:
    path = Path(config_path)
    if path.exists() and not force:
        print(f"Config file already exists: {path}")
        answer = input("Overwrite? [y/N]: ").strip().lower()
        if answer not in ("y", "yes"):
            print("Aborted.")
            return 1
    with open(path, "w", encoding="utf-8") as f:
        json.dump(CONFIG_SKELETON, f, indent=2, ensure_ascii=False)
        f.write("\n")
    print(f"Created: {path}")
    print("\nNext steps:")
    print("  1. Set your API key via environment variable (API_KEY)")
    print("  2. Or edit the file directly to set 'api_key'")
    print(f"  3. Run: python -m myagent.main -i")
    return 0
def main():
    parser = argparse.ArgumentParser(
        description="MyAgent - Autonomous Coding Agent",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "request",
        nargs="?",
        help="Task to perform (e.g., 'create hello.py')",
    )
    parser.add_argument(
        "--config",
        default="config.json",
        help="Path to config file (default: config.json)",
    )
    parser.add_argument(
        "--prompts",
        default="prompts",
        help="Path to prompts directory (default: prompts)",
    )
    parser.add_argument(
        "--max-turns",
        type=int,
        default=50,
        help="Maximum agent turns (default: 50)",
    )
    parser.add_argument(
        "--interactive",
        "-i",
        action="store_true",
        help="Run in interactive mode",
    )
    parser.add_argument(
        "--log-dir",
        default="logs",
        help="Directory for log files (default: logs)",
    )
    parser.add_argument(
        "--init",
        action="store_true",
        help="Create a skeleton config.json and exit",
    )
    parser.add_argument(
        "--force",
        "-f",
        action="store_true",
        help="Overwrite existing config when using --init",
    )
    args = parser.parse_args()
    if args.init:
        return init_config(args.config, force=args.force)
    config_path = os.path.abspath(args.config)
    if not os.path.exists(config_path):
        print(f"Error: Config file not found: {config_path}")
        print("Create a config.json with your API settings.")
        sys.exit(1)
    config = load_config(config_path)
    log_level = config.get("log_level")
    log_file = setup_logging(log_dir=args.log_dir, log_level=log_level)
    llm_config = config.get("llm", {})
    resolve_api_key(llm_config)
    logger.debug("Initializing LLM provider")
    role_configs = config.get("models")
    if role_configs:
        llm = ModelRouter(llm_config, role_configs)
        logger.info("ModelRouter enabled with %d role configs", len(role_configs))
    else:
        llm = create_provider(llm_config)
    logger.debug("Registering tools")
    tools = setup_tools()
    perm_overrides = config.get("permissions", {})
    permission = PermissionManager(perm_overrides)
    logger.debug("Loading prompts from: %s", args.prompts)
    prompt_loader = PromptLoader(args.prompts)
    logger.debug("Creating agent with max_turns=%d", args.max_turns)
    agent = Agent(
        llm=llm,
        tool_registry=tools,
        permission=permission,
        prompt_loader=prompt_loader,
        max_turns=args.max_turns,
        log_level=log_level,
    )
    _log_print(log_level, "MyAgent v%s", VERSION)
    print(f"MyAgent v{VERSION}")
    print(f"  Config:  {config_path}")
    print(f"  Prompts: {args.prompts}/")
    print()
    _log_print(log_level, "System prompt loaded from: %s/", args.prompts)
    print(f"System prompt loaded from: {args.prompts}/")
    _log_print(log_level, "Log file: %s", log_file)
    print(f"Log file: {log_file}")
    _log_print(log_level, "Available tools: %s", ", ".join(tools.list_tools()))
    print(f"Available tools: {', '.join(tools.list_tools())}")
    summary_text = permission.get_summary()
    _log_print(log_level, "%s", summary_text)
    print(summary_text)
    print()
    if args.interactive or not args.request:
        _log_print(log_level, "MyAgent Interactive Mode")
        _log_print(log_level, "Type 'exit' or 'quit' to exit, 'reset' to clear history.")
        print("MyAgent Interactive Mode")
        print("Type 'exit' or 'quit' to exit, 'reset' to clear history.\n")
        while True:
            try:
                user_input = input(">>> ").strip()
            except (EOFError, KeyboardInterrupt):
                _log_print(log_level, "Exiting...")
                print("\nExiting...")
                break
            if not user_input:
                continue
            if user_input.lower() in ("exit", "quit"):
                _log_print(log_level, "Exiting...")
                print("Exiting...")
                break
            if user_input.lower() == "reset":
                agent.reset()
                _log_print(log_level, "Conversation reset.")
                print("Conversation reset.")
                continue
            if user_input.lower() == "summary":
                _log_print(log_level, "Summary: %s", agent.get_conversation_summary())
                print(agent.get_conversation_summary())
                continue
            if user_input.lower() == "stats":
                counters = agent.llm.get_counters()
                _log_print(log_level, "Requests: %d, Prompt: %d, Completion: %d, Total: %d",
                         counters.total_requests, counters.total_prompt_tokens,
                         counters.total_completion_tokens, counters.total_tokens)
                print(f"API Statistics:")
                print(f"  Requests:       {counters.total_requests}")
                print(f"  Prompt tokens:  {counters.total_prompt_tokens}")
                print(f"  Completion:     {counters.total_completion_tokens}")
                print(f"  Total tokens:   {counters.total_tokens}")
                continue
            if user_input.lower() in ("archive", "save"):
                state_path = agent.context.save_state()
                if state_path:
                    _log_print(log_level, "Session archived: %s", state_path)
                    print(f"Session archived: {state_path}")
                else:
                    _log_print(log_level, "Session not yet compacted; nothing to archive.")
                    print("Session not yet compacted; nothing to archive.")
                continue
            result = agent.run(user_input)
            print(f"\n{result}\n")
    else:
        result = agent.run(args.request)
        print(result)
if __name__ == "__main__":
    main()
6
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?