devpilot-agentic-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent/config.py ADDED
@@ -0,0 +1,232 @@
1
+ """
2
+ agent/config.py
3
+ ───────────────
4
+ Central configuration for DevPilot.
5
+
6
+ All settings are read from environment variables (or a .env file).
7
+ API keys are NEVER hardcoded. Missing keys raise a clear ConfigError
8
+ so the user knows exactly what to set before running.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Literal
17
+
18
+ from dotenv import load_dotenv
19
+
20
+ # ── Load .env file if present (safe to call even if file is missing) ─────────
21
+ load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env", override=False)
22
+
23
+
24
+ # ── Custom exception ─────────────────────────────────────────────────────────
25
+
26
+ class ConfigError(Exception):
27
+ """Raised when required configuration is missing or invalid."""
28
+
29
+
30
+ # ── Supported providers ───────────────────────────────────────────────────────
31
+
32
+ Provider = Literal["anthropic", "openai"]
33
+
34
+ PROVIDER_DEFAULTS: dict[str, str] = {
35
+ "anthropic": "claude-opus-4-5",
36
+ "openai": "gpt-4o",
37
+ }
38
+
39
+ # Keys required per provider (checked lazily so we can build/test without keys)
40
+ REQUIRED_ENV_KEYS: dict[str, str] = {
41
+ "anthropic": "ANTHROPIC_API_KEY",
42
+ "openai": "OPENAI_API_KEY",
43
+ }
44
+
45
+ # Models that support extended thinking (Anthropic only)
46
+ THINKING_CAPABLE_MODELS: set[str] = {
47
+ "claude-3-7-sonnet-20250219",
48
+ "claude-3-5-sonnet-20241022",
49
+ "claude-opus-4-5",
50
+ "claude-opus-4-20250514",
51
+ }
52
+
53
+
54
+ # ── Config dataclass ──────────────────────────────────────────────────────────
55
+
56
+ @dataclass
57
+ class Config:
58
+ # Provider + model
59
+ provider: Provider
60
+ model: str
61
+ base_url: str | None # Custom endpoint for Ollama / local models
62
+
63
+ # Agentic loop
64
+ max_iterations: int
65
+
66
+ # Safety
67
+ no_confirm: bool
68
+
69
+ # A2A server
70
+ a2a_port: int
71
+ a2a_token: str | None
72
+
73
+ # Workspace
74
+ workdir: str
75
+
76
+ # Extended thinking (Anthropic Claude only)
77
+ extended_thinking: bool
78
+ thinking_budget: int
79
+
80
+ # Feature flags
81
+ web_search_enabled: bool
82
+ memory_enabled: bool
83
+ a2a_enabled: bool
84
+
85
+ # Sessions
86
+ sessions_dir: Path
87
+
88
+ # ── Derived properties ────────────────────────────────────────────────────
89
+
90
+ @property
91
+ def anthropic_api_key(self) -> str | None:
92
+ return os.getenv("ANTHROPIC_API_KEY")
93
+
94
+ @property
95
+ def openai_api_key(self) -> str | None:
96
+ return os.getenv("OPENAI_API_KEY")
97
+
98
+ @property
99
+ def active_api_key(self) -> str | None:
100
+ """Return the API key for the currently selected provider."""
101
+ if self.provider == "anthropic":
102
+ return self.anthropic_api_key
103
+ return self.openai_api_key
104
+
105
+ # ── Validation ────────────────────────────────────────────────────────────
106
+
107
+ def validate_api_key(self) -> None:
108
+ """
109
+ Raises ConfigError if the active provider's API key is missing.
110
+ Call this right before making the first API request — not at startup —
111
+ so users can explore/test without a key.
112
+ """
113
+ key_name = REQUIRED_ENV_KEYS[self.provider]
114
+ if not self.active_api_key:
115
+ raise ConfigError(
116
+ f"\n[DevPilot] Missing API key for provider '{self.provider}'.\n"
117
+ f" → Set the environment variable: {key_name}\n"
118
+ f" → Or add it to your .env file (copy .env.example to .env).\n"
119
+ f" → Get an Anthropic key at: https://console.anthropic.com/\n"
120
+ )
121
+
122
+ if self.extended_thinking:
123
+ if self.provider != "anthropic":
124
+ raise ConfigError("Extended thinking is only supported with the Anthropic provider.")
125
+ if self.thinking_budget < 1000:
126
+ raise ConfigError("thinking_budget must be >= 1000 tokens.")
127
+ # Note: We don't strictly enforce model names since API evolves, but it's good to check
128
+ if not any(self.model.startswith(m) for m in ["claude-3", "claude-opus"]):
129
+ pass # We'll let the API reject it if it's really unsupported
130
+
131
+ # ── Factory ───────────────────────────────────────────────────────────────
132
+
133
+ @classmethod
134
+ def load(cls) -> "Config":
135
+ """
136
+ Load configuration from environment variables.
137
+ Raises ConfigError for invalid (not missing) values.
138
+ """
139
+ # ── Provider ────────────────────────────────────────────────────
140
+ provider_raw = os.getenv("DEVPILOT_PROVIDER", "anthropic").lower()
141
+ if provider_raw not in ("anthropic", "openai"):
142
+ raise ConfigError(
143
+ f"Invalid DEVPILOT_PROVIDER='{provider_raw}'. "
144
+ "Must be 'anthropic' or 'openai'."
145
+ )
146
+ provider: Provider = provider_raw # type: ignore[assignment]
147
+
148
+ # ── Model ───────────────────────────────────────────────────────
149
+ model = os.getenv("DEVPILOT_MODEL") or PROVIDER_DEFAULTS[provider]
150
+
151
+ # ── Base URL ────────────────────────────────────────────────────
152
+ base_url = os.getenv("DEVPILOT_BASE_URL") or None
153
+
154
+ # ── Max iterations ───────────────────────────────────────────────
155
+ try:
156
+ max_iterations = int(os.getenv("DEVPILOT_MAX_ITERATIONS", "50"))
157
+ if max_iterations < 1:
158
+ raise ValueError
159
+ except ValueError:
160
+ raise ConfigError(
161
+ "DEVPILOT_MAX_ITERATIONS must be a positive integer."
162
+ )
163
+
164
+ # ── No-confirm flag ─────────────────────────────────────────────
165
+ no_confirm_raw = os.getenv("DEVPILOT_NO_CONFIRM", "false").lower()
166
+ no_confirm = no_confirm_raw in ("true", "1", "yes")
167
+
168
+ # ── A2A port ────────────────────────────────────────────────────
169
+ try:
170
+ a2a_port = int(os.getenv("DEVPILOT_A2A_PORT", "8000"))
171
+ except ValueError:
172
+ raise ConfigError("DEVPILOT_A2A_PORT must be an integer.")
173
+
174
+ # ── A2A token ───────────────────────────────────────────────────
175
+ a2a_token = os.getenv("DEVPILOT_A2A_TOKEN") or None
176
+
177
+ # ── Workspace ───────────────────────────────────────────────────
178
+ workdir = os.getenv("DEVPILOT_WORKDIR", os.getcwd())
179
+
180
+ # ── Extended thinking ───────────────────────────────────────────
181
+ extended_thinking = os.getenv("DEVPILOT_THINKING", "false").lower() in ("true", "1", "yes")
182
+ try:
183
+ thinking_budget = int(os.getenv("DEVPILOT_THINKING_BUDGET", "10000"))
184
+ except ValueError:
185
+ raise ConfigError("DEVPILOT_THINKING_BUDGET must be an integer.")
186
+
187
+ # ── Feature flags ───────────────────────────────────────────────
188
+ web_search_enabled = os.getenv("DEVPILOT_NO_WEB_SEARCH", "false").lower() not in ("true", "1", "yes")
189
+ memory_enabled = os.getenv("DEVPILOT_NO_MEMORY", "false").lower() not in ("true", "1", "yes")
190
+ a2a_enabled = os.getenv("DEVPILOT_NO_A2A", "false").lower() not in ("true", "1", "yes")
191
+
192
+ # ── Sessions dir ────────────────────────────────────────────────
193
+ sessions_dir = Path(
194
+ os.getenv("DEVPILOT_SESSIONS_DIR", ".devpilot_sessions")
195
+ )
196
+
197
+ return cls(
198
+ provider=provider,
199
+ model=model,
200
+ base_url=base_url,
201
+ max_iterations=max_iterations,
202
+ no_confirm=no_confirm,
203
+ a2a_port=a2a_port,
204
+ a2a_token=a2a_token,
205
+ workdir=workdir,
206
+ extended_thinking=extended_thinking,
207
+ thinking_budget=thinking_budget,
208
+ web_search_enabled=web_search_enabled,
209
+ memory_enabled=memory_enabled,
210
+ a2a_enabled=a2a_enabled,
211
+ sessions_dir=sessions_dir,
212
+ )
213
+
214
+ # ── Display ───────────────────────────────────────────────────────────────
215
+
216
+ def __str__(self) -> str:
217
+ key_status = "✓ present" if self.active_api_key else "✗ MISSING"
218
+ return (
219
+ f"DevPilot Config\n"
220
+ f" provider : {self.provider}\n"
221
+ f" model : {self.model}\n"
222
+ f" base_url : {self.base_url or '(default)'}\n"
223
+ f" max_iterations: {self.max_iterations}\n"
224
+ f" no_confirm : {self.no_confirm}\n"
225
+ f" a2a_port : {self.a2a_port}\n"
226
+ f" thinking : {self.extended_thinking} (budget: {self.thinking_budget})\n"
227
+ f" web_search : {self.web_search_enabled}\n"
228
+ f" memory : {self.memory_enabled}\n"
229
+ f" a2a : {self.a2a_enabled}\n"
230
+ f" api_key : {key_status}\n"
231
+ f" sessions_dir : {self.sessions_dir}\n"
232
+ )
agent/context.py ADDED
@@ -0,0 +1,182 @@
1
+ """
2
+ agent/context.py
3
+ ────────────────
4
+ RepoContext — tracks which files the model has read this session,
5
+ their content hashes, and the repo structure snapshot.
6
+
7
+ Injected into every system prompt so the model always knows:
8
+ - What it has already read (no redundant re-reads)
9
+ - Whether a file has changed since it last read it
10
+ - The top-level directory structure of the project
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import ast
17
+ from pathlib import Path
18
+
19
+
20
+
21
+
22
+ class RepoContext:
23
+ """
24
+ Lightweight repo awareness tracker.
25
+
26
+ The loop calls record_read() after every successful read_file call.
27
+ WriteFileTool calls record_write() after every successful write.
28
+ build_context_block() returns a compact text block injected into
29
+ the system prompt at the start of every chat() call.
30
+ """
31
+
32
+ def __init__(self, workdir: str) -> None:
33
+ self._workdir = Path(workdir).resolve()
34
+ # path (relative str) -> content hash at last read
35
+ self._read_files: dict[str, str] = {}
36
+ # path (relative str) -> content hash at write time
37
+ self._written_files: dict[str, str] = {}
38
+
39
+
40
+ # ── Recording ─────────────────────────────────────────────────────────────
41
+
42
+ def record_read(self, path: str, content: str) -> None:
43
+ """Called by ReadFileTool after a successful read."""
44
+ rel = self._rel(path)
45
+ self._read_files[rel] = self._hash(content)
46
+
47
+ def record_write(self, path: str, content: str) -> None:
48
+ """Called by WriteFileTool after a successful write."""
49
+ rel = self._rel(path)
50
+ self._written_files[rel] = self._hash(content)
51
+ # Also update read cache so model knows current on-disk state
52
+ self._read_files[rel] = self._hash(content)
53
+
54
+
55
+ # ── Stale detection ───────────────────────────────────────────────────────
56
+
57
+ def is_stale(self, path: str) -> bool:
58
+ """
59
+ Returns True if the file on disk has changed since last read.
60
+ Used to hint the model it should re-read before writing.
61
+ """
62
+ rel = self._rel(path)
63
+ if rel not in self._read_files:
64
+ return False
65
+ try:
66
+ full = self._workdir / rel
67
+ current = self._hash(full.read_text(encoding="utf-8", errors="replace"))
68
+ return current != self._read_files[rel]
69
+ except OSError:
70
+ return False
71
+
72
+ # ── Context block for system prompt ──────────────────────────────────────
73
+
74
+ def build_context_block(self) -> str:
75
+ """
76
+ Returns a compact text block describing current repo awareness.
77
+ Injected into the system prompt on every chat() call.
78
+ """
79
+ lines: list[str] = []
80
+
81
+ if self._read_files:
82
+ lines.append("Files you have already read this session (do not re-read unless stale):")
83
+ for rel in sorted(self._read_files):
84
+ stale = self.is_stale(rel)
85
+ tag = " ⚠ MODIFIED ON DISK — re-read before editing" if stale else ""
86
+ lines.append(f" • {rel}{tag}")
87
+ else:
88
+ lines.append("You have not read any files yet this session.")
89
+
90
+ if self._written_files:
91
+ lines.append("\nFiles you have written this session:")
92
+ for rel in sorted(self._written_files):
93
+ lines.append(f" • {rel}")
94
+
95
+ tree = self._build_project_tree()
96
+ if tree:
97
+ lines.append(f"\nProject root ({self._workdir.name}/):")
98
+ lines.extend(f" {entry}" for entry in tree)
99
+
100
+ return "\n".join(lines)
101
+
102
+
103
+
104
+ # ── Helpers ───────────────────────────────────────────────────────────────
105
+
106
+ def _rel(self, path: str) -> str:
107
+ p = Path(path)
108
+ if p.is_absolute():
109
+ try:
110
+ return str(p.relative_to(self._workdir))
111
+ except ValueError:
112
+ return path
113
+ return str(p)
114
+
115
+ @staticmethod
116
+ def _hash(content: str) -> str:
117
+ return hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()
118
+
119
+ def _extract_signatures(self, path: Path) -> list[str]:
120
+ rel = self._rel(str(path))
121
+ if rel in self._read_files:
122
+ return []
123
+
124
+ sigs = []
125
+ try:
126
+ content = path.read_text(encoding="utf-8", errors="ignore")
127
+ if path.suffix == ".py":
128
+ tree = ast.parse(content)
129
+ for node in tree.body:
130
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
131
+ sigs.append(f" def {node.name}(...)")
132
+ elif isinstance(node, ast.ClassDef):
133
+ sigs.append(f" class {node.name}:")
134
+ for sub_node in node.body:
135
+ if isinstance(sub_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
136
+ sigs.append(f" def {sub_node.name}(...)")
137
+ elif path.suffix in (".js", ".ts"):
138
+ for line in content.splitlines():
139
+ line = line.strip()
140
+ if line.startswith("class ") or line.startswith("export class ") or \
141
+ line.startswith("function ") or line.startswith("export function "):
142
+ sigs.append(f" {line}")
143
+ except Exception:
144
+ pass
145
+ return sigs
146
+
147
+ def _build_project_tree(self, max_entries: int = 200) -> list[str]:
148
+ entries: list[str] = []
149
+ ignores = {
150
+ ".git", "node_modules", ".venv", "__pycache__", "dist",
151
+ "build", ".next", ".tox", "coverage_html_report", ".devpilot_sessions"
152
+ }
153
+
154
+ def _traverse(directory: Path, prefix: str = "") -> None:
155
+ if len(entries) >= max_entries:
156
+ return
157
+
158
+ try:
159
+ for item in sorted(directory.iterdir()):
160
+ if item.name.startswith(".") and item.name not in (".env", ".gitignore", ".github"):
161
+ continue
162
+ if item.name in ignores or item.name.endswith(".egg-info"):
163
+ continue
164
+
165
+ if item.is_dir():
166
+ entries.append(f"{prefix}📁 {item.name}/")
167
+ _traverse(item, prefix + " ")
168
+ else:
169
+ entries.append(f"{prefix}📄 {item.name}")
170
+ if item.suffix in (".py", ".js", ".ts"):
171
+ sigs = self._extract_signatures(item)
172
+ for sig in sigs:
173
+ entries.append(f"{prefix}{sig}")
174
+
175
+ if len(entries) >= max_entries:
176
+ entries.append("… (truncated to ~200 items for context size)")
177
+ break
178
+ except OSError:
179
+ pass
180
+
181
+ _traverse(self._workdir)
182
+ return entries
agent/history.py ADDED
@@ -0,0 +1,172 @@
1
+ """
2
+ agent/history.py
3
+ ────────────────
4
+ Manages the conversation history and context window.
5
+
6
+ Improvements over original blunt character-count truncation:
7
+ - Smart pruning: large bash/tool outputs are SUMMARISED in place rather
8
+ than the whole message being dropped. The model retains awareness of
9
+ what ran and what happened, just with a shorter representation.
10
+ - Tool results over TOOL_RESULT_TRIM_CHARS are replaced with a one-line
11
+ summary: "[output truncated — N lines, exit code X]"
12
+ - When overall history still exceeds the limit after trimming, oldest
13
+ user↔assistant pairs are dropped (never orphaning tool_use blocks).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ # Overall hard limit in characters (~400k chars ≈ 100k tokens)
23
+ _MAX_CHARS = 100_000 * 4
24
+
25
+ # Individual tool result outputs larger than this get summarised in-place
26
+ # before they're ever stored (≈4k tokens — enough for most command output)
27
+ _TOOL_RESULT_TRIM_CHARS = 16_000
28
+
29
+
30
+ def _summarise_tool_result_message(msg: dict[str, Any]) -> dict[str, Any]:
31
+ """
32
+ Replace oversized tool_result content blocks with a compact summary.
33
+ Returns a new message dict; the original is not mutated.
34
+ """
35
+ if msg.get("role") != "user":
36
+ return msg
37
+ content = msg.get("content")
38
+ if not isinstance(content, list):
39
+ return msg
40
+
41
+ new_blocks: list[dict[str, Any]] = []
42
+ changed = False
43
+ for block in content:
44
+ if (
45
+ isinstance(block, dict)
46
+ and block.get("type") == "tool_result"
47
+ and isinstance(block.get("content"), str)
48
+ and len(block["content"]) > _TOOL_RESULT_TRIM_CHARS
49
+ ):
50
+ raw: str = block["content"]
51
+ lines = raw.splitlines()
52
+ # Extract exit code if present (run_bash format)
53
+ exit_code_line = next(
54
+ (ln for ln in reversed(lines) if ln.startswith("exit code:")), None
55
+ )
56
+ exit_info = f", {exit_code_line}" if exit_code_line else ""
57
+ summary = (
58
+ f"[output trimmed — {len(lines)} lines, "
59
+ f"{len(raw):,} chars{exit_info}. "
60
+ f"First 20 lines:\n"
61
+ + "\n".join(lines[:20])
62
+ + ("\n…" if len(lines) > 20 else "")
63
+ + "]"
64
+ )
65
+ new_blocks.append({**block, "content": summary})
66
+ changed = True
67
+ else:
68
+ new_blocks.append(block)
69
+
70
+ return {**msg, "content": new_blocks} if changed else msg
71
+
72
+
73
+ class HistoryManager:
74
+ """Stores the conversation history and manages session persistence."""
75
+
76
+ def __init__(self) -> None:
77
+ self._messages: list[dict[str, Any]] = []
78
+
79
+ def append(self, message: dict[str, Any]) -> None:
80
+ """Add a message to history, summarising large tool outputs first."""
81
+ self._messages.append(_summarise_tool_result_message(message))
82
+ self._truncate_if_needed()
83
+
84
+ def extend(self, messages: list[dict[str, Any]]) -> None:
85
+ for msg in messages:
86
+ self._messages.append(_summarise_tool_result_message(msg))
87
+ self._truncate_if_needed()
88
+
89
+ def get_messages(self) -> list[dict[str, Any]]:
90
+ return list(self._messages)
91
+
92
+ # ── Internal helpers ──────────────────────────────────────────────────────
93
+
94
+ def _is_tool_result(self, msg: dict[str, Any]) -> bool:
95
+ if msg.get("role") != "user":
96
+ return False
97
+ content = msg.get("content")
98
+ if isinstance(content, list):
99
+ return any(
100
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
101
+ )
102
+ return False
103
+
104
+ def _is_tool_use(self, msg: dict[str, Any]) -> bool:
105
+ if msg.get("role") != "assistant":
106
+ return False
107
+ content = msg.get("content")
108
+ if isinstance(content, list):
109
+ return any(
110
+ isinstance(b, dict) and b.get("type") == "tool_use" for b in content
111
+ )
112
+ return False
113
+
114
+ def _truncate_if_needed(self) -> None:
115
+ """
116
+ Drop oldest complete exchange units until under _MAX_CHARS.
117
+
118
+ An exchange unit is one of:
119
+ • A plain user message
120
+ • An assistant message + its following tool_result user messages
121
+
122
+ We never drop a tool_use assistant message without also dropping
123
+ its paired tool_result messages, avoiding Anthropic 400 errors.
124
+ """
125
+ while True:
126
+ total = sum(len(json.dumps(m)) for m in self._messages)
127
+ if total <= _MAX_CHARS or len(self._messages) <= 2:
128
+ break
129
+
130
+ # Find the first droppable unit: a user message that is NOT a
131
+ # tool_result (those must go with their preceding assistant msg).
132
+ drop_end = 0
133
+ for i, msg in enumerate(self._messages):
134
+ if msg.get("role") == "user" and not self._is_tool_result(msg):
135
+ drop_end = i + 1
136
+ # Also drop the following assistant message + its tool results
137
+ j = drop_end
138
+ while j < len(self._messages):
139
+ next_msg = self._messages[j]
140
+ if next_msg.get("role") == "assistant":
141
+ drop_end = j + 1
142
+ j += 1
143
+ # consume paired tool_results
144
+ while j < len(self._messages) and self._is_tool_result(
145
+ self._messages[j]
146
+ ):
147
+ drop_end = j + 1
148
+ j += 1
149
+ else:
150
+ break
151
+ break
152
+
153
+ if drop_end == 0:
154
+ # Fallback: drop the very first message
155
+ self._messages.pop(0)
156
+ else:
157
+ del self._messages[:drop_end]
158
+
159
+ # ── Persistence ───────────────────────────────────────────────────────────
160
+
161
+ def save(self, path: Path | str) -> None:
162
+ p = Path(path)
163
+ p.parent.mkdir(parents=True, exist_ok=True)
164
+ with open(p, "w", encoding="utf-8") as f:
165
+ json.dump(self._messages, f, indent=2)
166
+
167
+ def load(self, path: Path | str) -> None:
168
+ p = Path(path)
169
+ if not p.exists():
170
+ return
171
+ with open(p, "r", encoding="utf-8") as f:
172
+ self._messages = json.load(f)
agent/loop.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ agent/loop.py
3
+ ─────────────
4
+ The core agentic loop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from agent.config import Config
10
+ from agent.history import HistoryManager
11
+ from agent.providers.base import BaseProvider
12
+ from agent.tools import ToolRegistry
13
+ from agent.ui import UI
14
+
15
+
16
+ async def run_agent_loop(
17
+ provider: BaseProvider,
18
+ registry: ToolRegistry,
19
+ history: HistoryManager,
20
+ config: Config,
21
+ max_iterations: int = 50,
22
+ context=None,
23
+ ) -> None:
24
+ """
25
+ Executes the agentic loop until the model stops returning tool_uses or
26
+ max_iterations is reached.
27
+
28
+ Uses chat_stream() by default so tokens are printed in real time.
29
+ Falls back transparently for providers/modes that don't support streaming
30
+ (e.g. extended thinking).
31
+ """
32
+ heal_attempts = 0
33
+
34
+ for iteration in range(max_iterations):
35
+ messages = history.get_messages()
36
+ tools = registry.schemas
37
+
38
+ from agent.providers.system_prompt import build_system_prompt
39
+
40
+ system = build_system_prompt(
41
+ repo_context_block=context.build_context_block() if context else ""
42
+ )
43
+
44
+ try:
45
+ # chat_stream() prints text tokens live and returns the full response.
46
+ # For extended thinking or providers without streaming it falls back to chat().
47
+ response = await provider.chat_stream(messages, tools, system=system)
48
+ except Exception as e:
49
+ UI.print_error(f"Provider error: {e}")
50
+ break
51
+
52
+ # Append the assistant's message to history
53
+ history.append(response.assistant_message)
54
+
55
+ # UI: render extended thinking if present (Anthropic only; non-streaming path)
56
+ if response.thinking:
57
+ UI.print_thinking_block(response.thinking)
58
+
59
+ # UI: print assistant text only when NOT already streamed live.
60
+ if response.text and not response.streamed_text:
61
+ UI.print_assistant_message(response.text)
62
+
63
+ if not response.has_tool_uses:
64
+ # The model is done
65
+ break
66
+
67
+ should_break_outer = False
68
+
69
+ for tool_use in response.tool_uses:
70
+ UI.print_tool_call(tool_use.name, tool_use.input)
71
+
72
+ tool_result = await registry.execute(tool_use.name, tool_use.input)
73
+
74
+ UI.print_tool_result(tool_use.name, tool_result.output, tool_result.is_error)
75
+
76
+ # Format and append tool result message
77
+ tool_msg = provider.make_tool_result_message(
78
+ tool_use_id=tool_use.id,
79
+ content=tool_result.output,
80
+ is_error=tool_result.is_error,
81
+ )
82
+ history.append(tool_msg)
83
+
84
+ if tool_result.is_error:
85
+ heal_attempts += 1
86
+ if heal_attempts >= 3:
87
+ UI.print_error("Too many consecutive tool errors (>= 3). Aborting loop to prevent infinite retries.")
88
+ should_break_outer = True
89
+ break
90
+ # Inject explicit user prod to fix for this specific tool
91
+ history.append(provider.make_user_message(
92
+ f"Tool '{tool_use.name}' failed with: {tool_result.output}\n"
93
+ "Please analyze the error, fix the underlying issue, and retry."
94
+ ))
95
+ else:
96
+ heal_attempts = 0
97
+
98
+ if should_break_outer:
99
+ break
100
+
101
+ else:
102
+ UI.print_error(f"Max iterations ({max_iterations}) reached. Terminating loop.")