opalacoder 0.1.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.
@@ -0,0 +1,381 @@
1
+ """MemGPT-style autonomous orchestrator with real-time live progress panel."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+ from rich.live import Live
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from rich.panel import Panel
10
+ from rich import box
11
+
12
+ import abc
13
+
14
+
15
+ class _SuppressMockToolCallErrors(logging.Filter):
16
+ """Suppress the non-fatal LiteLLM serialization error caused by MockToolCall objects."""
17
+
18
+ def filter(self, record: logging.LogRecord) -> bool:
19
+ return "__pydantic_fields_set__" not in record.getMessage()
20
+
21
+
22
+ logging.getLogger("LiteLLM").addFilter(_SuppressMockToolCallErrors())
23
+
24
+ from agenticblocks.blocks.llm.memgpt_agent import MemGPTAgentBlock
25
+ from agenticblocks.blocks.llm.agent import AgentInput
26
+ from .tools import get_available_tools, AGENT_PROGRESS
27
+ from .config import get_agent_llm_kwargs, get_agent_model, get_agent_debug, DEFAULT_MODEL, get_agent_max_heartbeats
28
+ from . import terminal as T
29
+
30
+
31
+ class BaseOrchestratorStrategy(abc.ABC):
32
+ def __init__(self, model: str):
33
+ self.model = model
34
+
35
+ @abc.abstractmethod
36
+ async def run(self, user_request: str, history: str, **kwargs) -> str:
37
+ """Execute the orchestration logic for the given user request."""
38
+ pass
39
+
40
+
41
+ def _build_progress_panel(progress: object, max_hb: int) -> Panel:
42
+ """Build a Rich Panel showing current agent activity."""
43
+ hb = progress.heartbeat
44
+ bar_filled = min(hb, max_hb)
45
+ bar_empty = max(0, max_hb - bar_filled)
46
+ bar = f"[cyan]{'â–ˆ' * bar_filled}[/cyan][dim]{'â–‘' * bar_empty}[/dim]"
47
+
48
+ tool_color = {
49
+ "write_file": "green",
50
+ "write_content_pos": "green",
51
+ "read_file": "blue",
52
+ "read_content_pos": "blue",
53
+ "get_file_overview": "blue",
54
+ "get_project_overview": "magenta",
55
+ "run_command": "yellow",
56
+ "search_code": "magenta",
57
+ "ask_human": "red",
58
+ "send_message": "cyan",
59
+ }.get(progress.last_tool, "white")
60
+
61
+ tool_line = f"[bold {tool_color}]🔧 {progress.last_tool}[/bold {tool_color}]"
62
+ if progress.last_args:
63
+ tool_line += f"\n [dim]↳ {progress.last_args}[/dim]"
64
+
65
+ content = (
66
+ f"[bold]Heartbeat:[/bold] {hb}/{max_hb} {bar}\n"
67
+ f"[bold]Elapsed:[/bold] {progress.elapsed()}\n\n"
68
+ f"{tool_line}"
69
+ )
70
+ return Panel(
71
+ content,
72
+ title="[bold cyan]🤖 Orchestrator Working[/bold cyan]",
73
+ border_style="cyan",
74
+ expand=False,
75
+ )
76
+
77
+
78
+ class AutonomousOrchestratorStrategy(BaseOrchestratorStrategy):
79
+ def __init__(self, model: str | None = None):
80
+ super().__init__(get_agent_model("orchestrator", model or DEFAULT_MODEL))
81
+ self.tools = get_available_tools()
82
+
83
+ def _build_system_prompt(self, approved_plan: str = "", project_context: str = "") -> str:
84
+ import re as _re
85
+
86
+ proj_path = "."
87
+ project_header = ""
88
+ if project_context:
89
+ _m = _re.search(r"PATH:\s*(.+?)\]", project_context)
90
+ proj_path = _m.group(1).strip() if _m else "."
91
+ _n = _re.search(r"PROJECT:\s*(.+?)\s*\|", project_context)
92
+ proj_name = _n.group(1).strip() if _n else proj_path
93
+ project_header = f"""
94
+ ## PROJECT CONTEXT
95
+ Name : {proj_name}
96
+ Path : {proj_path}
97
+
98
+ You are working EXCLUSIVELY inside this project. Every file you read, write, or execute
99
+ must live under `{proj_path}`. Never touch files outside this directory.
100
+ """
101
+
102
+ plan_section = ""
103
+ if approved_plan:
104
+ plan_section = f"""
105
+ ## APPROVED PLAN
106
+ The user reviewed and approved this plan. **Execute every step now — do not re-describe the plan.**
107
+ Use your tools to implement it fully before calling `send_message`.
108
+
109
+ {approved_plan}
110
+ """
111
+
112
+ return f"""You are OpalaCoder — an autonomous software-engineering agent embedded inside a project workspace.
113
+ {project_header}
114
+ ## FIRST ACTION — always
115
+ Before doing anything else, call `get_project_overview` to understand the current state of the project:
116
+ its files, structure, and technology stack. Use that snapshot to make every decision that follows.
117
+ {plan_section}
118
+ ## YOUR MISSION
119
+ Implement coding tasks autonomously within the project. This includes creating new features, fixing bugs,
120
+ refactoring, and updating any existing code inside the project directory.
121
+
122
+ ## RULES
123
+ 1. **Project-first**: Every action must reference the project. Read existing files before rewriting them. Use `get_file_overview` to understand a file's structure before making targeted edits.
124
+ Extend what is already there unless a full rewrite is explicitly requested.
125
+ 2. **ACT, don't ask**: Create directories, write files, run `npm install`, `pip install`, or any standard
126
+ dev command without asking for permission.
127
+ 3. **Fix bugs autonomously**: read the relevant file → identify the problem → fix in place.
128
+ Do NOT recreate from scratch unless the file is irreparably broken.
129
+ 4. **Never start servers**: Do NOT run `npm start`, `npm run dev`, `flask run`, or any long-running process.
130
+ 5. **Verify your work**: After writing code, run a quick syntax/lint check (e.g. `node --check file.js`,
131
+ `python -m py_compile file.py`).
132
+ 6. **Use ask_human ONLY** for genuinely dangerous or irreversible operations (`rm -rf`, `sudo`,
133
+ credentials, files outside the project).
134
+ 7. **Internal Version Control**: You may have access to git tools (if allowed by the user strategy) that operate on an internal "Shadow Git".
135
+ - If available, use `git_diff` to review your code changes.
136
+ - If available, use `git_commit` to save milestones of your work logically.
137
+ - You can use `run_command` with `git --git-dir=.opalacoder/.git --work-tree=. checkout <file>` to revert mistakes.
138
+ 8. **Report ONCE — but only after finishing**: Call `send_message` **exactly once**, and only after
139
+ you have fully completed every step of the plan (all files written, commands run, verifications done).
140
+ NEVER call `send_message` to describe what you are *about to do* — that terminates execution immediately.
141
+ The message must be a past-tense summary of what you *actually did*: files created/modified (relative paths),
142
+ commands run, and any caveats. If you have not finished all steps, keep using tools — do not send yet.
143
+ """
144
+
145
+ async def _plan_and_refine(self, user_request: str, history: str, session, store) -> str:
146
+ """Generate a plan and run the interactive refinement loop with the user."""
147
+ from .planner import generate_panorama, refine_plan
148
+ from . import terminal as T
149
+
150
+ T.section("Phase 1 — Implementation Overview")
151
+ panorama_text = await generate_panorama(user_request, self.model, history=history)
152
+
153
+ T.section("Phase 2 — Plan Refinement")
154
+ approved_plan = await refine_plan(user_request, panorama_text, self.model, session, store)
155
+
156
+ # Save the plan to the project directory
157
+ import os
158
+ project_path = getattr(session, "project_path", ".") or "."
159
+ plan_file_path = os.path.join(project_path, "plan.md")
160
+ try:
161
+ os.makedirs(project_path, exist_ok=True)
162
+ with open(plan_file_path, "w", encoding="utf-8") as f:
163
+ f.write(approved_plan)
164
+ except Exception as e:
165
+ T.warning(f"Não foi possível salvar plan.md no diretório do projeto: {e}")
166
+
167
+ return approved_plan
168
+
169
+ async def run(self, user_request: str, history: str, **kwargs) -> str:
170
+ """Generate and refine a plan with the user, then run autonomously."""
171
+ session = kwargs.get("session")
172
+ store = kwargs.get("store")
173
+
174
+ project_context = session.context_header() if session and hasattr(session, "context_header") else ""
175
+
176
+ approved_plan = ""
177
+ if session and store:
178
+ approved_plan = await self._plan_and_refine(user_request, history, session, store)
179
+
180
+ llm_kwargs = get_agent_llm_kwargs("orchestrator")
181
+ max_hb_config = get_agent_max_heartbeats("orchestrator", 20)
182
+
183
+ from .config import get_complexity_inference_mode, ALTERNATIVE_MODEL
184
+ from .api_keys import ensure_api_key
185
+
186
+ if approved_plan and get_complexity_inference_mode() == "double":
187
+ from .agents import make_post_plan_evaluator
188
+ evaluator = make_post_plan_evaluator(self.model)
189
+ with T.spinner("Avaliando esforço do plano (Inferência Dupla)..."):
190
+ try:
191
+ res = await evaluator.run(AgentInput(prompt=approved_plan))
192
+ import json
193
+ data = json.loads(res.response)
194
+
195
+ if data.get("model") == "alternative" and self.model != ALTERNATIVE_MODEL:
196
+ if ensure_api_key(ALTERNATIVE_MODEL):
197
+ T.info(f"Plano complexo detectado. Promovendo orquestrador para {ALTERNATIVE_MODEL}...")
198
+ self.model = ALTERNATIVE_MODEL
199
+
200
+ if max_hb_config == "auto":
201
+ steps = int(data.get("estimated_steps", 10))
202
+ max_hb_config = min(steps * 3 + 5, 200)
203
+ except Exception as e:
204
+ T.warning(f"Falha na inferência dupla de complexidade: {e}")
205
+
206
+ if max_hb_config == "auto":
207
+ max_hb_config = 50 # Fallback for simple mode or error
208
+
209
+ max_hb = int(max_hb_config)
210
+
211
+ # Reset progress state for this new run
212
+ AGENT_PROGRESS.heartbeat = 0
213
+ AGENT_PROGRESS.max_heartbeats = max_hb
214
+ AGENT_PROGRESS.last_tool = "Initializing…"
215
+ AGENT_PROGRESS.last_args = ""
216
+ AGENT_PROGRESS.start_time = time.monotonic()
217
+
218
+ project_path = getattr(session, "project_path", ".") or "."
219
+
220
+ from .vcs import get_vcs_strategy
221
+ from .config import get_git_strategy
222
+ vcs_strategy = get_vcs_strategy(get_git_strategy(), project_path)
223
+ vcs_strategy.setup()
224
+
225
+ agent_tools = self.tools + vcs_strategy.get_tools()
226
+
227
+ agent = MemGPTAgentBlock(
228
+ name="orchestrator",
229
+ system_prompt=self._build_system_prompt(approved_plan, project_context),
230
+ model=self.model,
231
+ tools=agent_tools,
232
+ litellm_kwargs=llm_kwargs,
233
+ max_heartbeats=max_hb,
234
+ debug=get_agent_debug("orchestrator", False)
235
+ )
236
+
237
+ project_path = getattr(session, "project_path", ".") or "."
238
+ checkpoint_dir = os.path.join(project_path, ".opalacoder")
239
+ checkpoint_path = os.path.join(checkpoint_dir, "session_state.json")
240
+ is_resume = False
241
+
242
+ if os.path.exists(checkpoint_path):
243
+ from rich.prompt import Confirm
244
+ if Confirm.ask("[yellow]Sessão não finalizada detectada. Deseja retomar a execução anterior?[/yellow]", default=True):
245
+ import json
246
+ try:
247
+ with open(checkpoint_path, "r") as f:
248
+ state = json.load(f)
249
+ agent.internal_history = state.get("internal_history", [])
250
+ agent.recursive_summary = state.get("recursive_summary", "")
251
+ AGENT_PROGRESS.heartbeat = state.get("heartbeat", 0)
252
+ is_resume = True
253
+ except Exception as e:
254
+ T.warning(f"Falha ao carregar sessão anterior: {e}")
255
+ else:
256
+ os.remove(checkpoint_path)
257
+
258
+ prompt = (
259
+ f"[CONVERSATION HISTORY]\n{history}\n[END HISTORY]\n\n"
260
+ f"[USER TASK]:\n{user_request}\n[END USER TASK]"
261
+ )
262
+ if is_resume:
263
+ prompt = "SYSTEM EVENT: O sistema foi reiniciado devido a uma interrupção. Verifique seu histórico recente e retome a execução do plano usando as ferramentas."
264
+
265
+ result_holder: list[str] = []
266
+ agent_holder: list = []
267
+ error_holder: list[Exception] = []
268
+
269
+ async def _run_agent():
270
+ try:
271
+ vcs_strategy.pre_run(prompt)
272
+ out = await agent.run(AgentInput(prompt=prompt))
273
+ result_holder.append(out.response)
274
+ agent_holder.append(agent)
275
+ except Exception as e:
276
+ error_holder.append(e)
277
+
278
+ # Start the agent as a background task so we can update the live panel
279
+ agent_task = asyncio.create_task(_run_agent())
280
+
281
+ last_history_len = 0
282
+ with Live(
283
+ _build_progress_panel(AGENT_PROGRESS, max_hb),
284
+ console=T.console,
285
+ refresh_per_second=4,
286
+ transient=False,
287
+ ) as live:
288
+ AGENT_PROGRESS.live_context = live
289
+ while not agent_task.done():
290
+ if live.is_started:
291
+ live.update(_build_progress_panel(AGENT_PROGRESS, max_hb))
292
+
293
+ # Checkpointing logic
294
+ current_history_len = len(agent.internal_history)
295
+ if current_history_len != last_history_len:
296
+ last_history_len = current_history_len
297
+ try:
298
+ os.makedirs(checkpoint_dir, exist_ok=True)
299
+ state = {
300
+ "internal_history": agent.internal_history,
301
+ "recursive_summary": getattr(agent, "recursive_summary", ""),
302
+ "heartbeat": AGENT_PROGRESS.heartbeat
303
+ }
304
+ import json
305
+ with open(checkpoint_path, "w") as f:
306
+ json.dump(state, f)
307
+ except Exception:
308
+ pass
309
+
310
+ await asyncio.sleep(0.25)
311
+ AGENT_PROGRESS.live_context = None
312
+
313
+ # Final render with terminal state
314
+ if not live.is_started:
315
+ live.start()
316
+ live.update(_build_progress_panel(AGENT_PROGRESS, max_hb))
317
+
318
+ # Limpa o checkpoint se finalizou com sucesso
319
+ if os.path.exists(checkpoint_path) and not error_holder:
320
+ try:
321
+ os.remove(checkpoint_path)
322
+ except Exception:
323
+ pass
324
+
325
+ if error_holder:
326
+ vcs_strategy.post_run(success=False, msg=str(error_holder[0]))
327
+ return f"Agent execution encountered an error: {error_holder[0]}"
328
+
329
+ vcs_strategy.post_run(success=True, msg="Agent completed execution.")
330
+
331
+ raw = result_holder[0] if result_holder else ""
332
+ if not raw.strip() and agent_holder:
333
+ raw = _extract_fallback(agent_holder[0])
334
+ if not raw.strip():
335
+ raw = "(Agent completed without generating a final report.)"
336
+ return _deduplicate_response(raw)
337
+
338
+
339
+ def _extract_fallback(agent) -> str:
340
+ """Extract the last meaningful assistant text from the agent's internal history.
341
+
342
+ Skips messages that are raw JSON tool-call blobs (plain-text tool calls that
343
+ the MemGPT fallback parser intercepted) — those are internal plumbing, not
344
+ user-facing content.
345
+ """
346
+ import json as _json
347
+
348
+ history = getattr(agent, "internal_history", [])
349
+ for msg in reversed(history):
350
+ if msg.get("role") != "assistant":
351
+ continue
352
+ # Skip messages that contain tool_calls (already handled by send_message accumulator)
353
+ if msg.get("tool_calls"):
354
+ continue
355
+ content = (msg.get("content") or "").strip()
356
+ if not content:
357
+ continue
358
+ # Skip raw JSON blobs (plain-text tool-call attempts)
359
+ if content.startswith("{"):
360
+ try:
361
+ _json.loads(content)
362
+ continue # valid JSON → internal plumbing, not a report
363
+ except _json.JSONDecodeError:
364
+ pass
365
+ return content
366
+ return ""
367
+
368
+
369
+ def _deduplicate_response(text: str) -> str:
370
+ """Remove consecutive duplicate paragraphs from the agent response.
371
+
372
+ The MemGPT agent sometimes calls send_message multiple times with the
373
+ same message. This collapses them into a single occurrence while
374
+ preserving legitimately different paragraphs.
375
+ """
376
+ paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
377
+ seen: list[str] = []
378
+ for p in paragraphs:
379
+ if not seen or p != seen[-1]:
380
+ seen.append(p)
381
+ return "\n".join(seen)
opalacoder/planner.py ADDED
@@ -0,0 +1,206 @@
1
+ """Planning pipeline: panorama generation, user refinement loop, and decomposition."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from agenticblocks.blocks.llm.agent import AgentInput
8
+
9
+ from .agents import make_landscape_planner, make_refinement_agent
10
+ from .structured import confirm_plan
11
+ from .project import ProjectData, ProjectStore
12
+ from .config import get_agent_llm_kwargs
13
+ from . import terminal as T
14
+ from .i18n import _
15
+
16
+
17
+ def _estimate_tokens(text: str) -> int:
18
+ try:
19
+ import litellm
20
+ return litellm.token_counter(model="gpt-3.5-turbo", messages=[{"role": "user", "content": text}])
21
+ except Exception:
22
+ return len(text) // 4
23
+
24
+
25
+ def _trim_to_budget(text: str, budget_tokens: int) -> str:
26
+ """Keep the tail of text that fits within budget_tokens, preserving line boundaries."""
27
+ if _estimate_tokens(text) <= budget_tokens:
28
+ return text
29
+ lines = text.splitlines()
30
+ kept: list[str] = []
31
+ used = 0
32
+ for line in reversed(lines):
33
+ cost = _estimate_tokens(line) + 1
34
+ if used + cost > budget_tokens:
35
+ break
36
+ kept.append(line)
37
+ used += cost
38
+ if not kept:
39
+ return text[-(budget_tokens * 4):]
40
+ return "[...earlier content omitted...]\n" + "\n".join(reversed(kept))
41
+
42
+ PLAN_FILE = "plan.md"
43
+
44
+ MAX_REFINEMENT_CYCLES = 20
45
+
46
+ # Fast-path heuristics — avoid an LLM call when user intent is unambiguous
47
+ _APPROVAL_WORDS = {
48
+ # Portuguese
49
+ "sim", "s", "ok", "okay", "aprovado", "pode", "certo", "perfeito",
50
+ "tudo certo", "tudo bem", "pode ir", "vai", "prossiga", "continua",
51
+ "continue", "confirmo", "confirmado", "aceito", "aceitar", "beleza",
52
+ "ótimo", "excelente", "correto", "está bom", "está ótimo", "positivo",
53
+ # English
54
+ "yes", "y", "approved", "sure", "fine", "great", "looks good", "proceed",
55
+ "go ahead", "confirmed", "confirm", "good", "perfect", "done", "alright",
56
+ "all good", "that's good", "sounds good", "let's go", "execute", "run it",
57
+ }
58
+ _CHANGE_SIGNALS = {
59
+ # Portuguese
60
+ "quero que", "adicione", "remova", "mude", "altere", "somente", "apenas",
61
+ "não precisa", "deve mostrar", "deve ter", "deve ser", "coloque", "tire",
62
+ "inclua", "exclua", "troque", "substitua", "corrija", "ajuste", "falta",
63
+ "precisa de", "faltou", "acrescente", "retire", "prefiro", "melhor seria",
64
+ # English
65
+ "want", "add", "remove", "change", "alter", "only", "just",
66
+ "don't need", "must show", "must have", "must be", "put", "take",
67
+ "include", "exclude", "replace", "substitute", "fix", "adjust", "instead",
68
+ "also", "but", "however", "missing", "need", "should", "could you",
69
+ "can you", "please add", "please remove", "please change",
70
+ }
71
+
72
+
73
+ def _fast_approval(user_response: str) -> bool | None:
74
+ """
75
+ Returns True (approved), False (wants changes), or None (ask the LLM).
76
+ Avoids an LLM round-trip when the intent is obvious.
77
+ """
78
+ normalized = user_response.strip().lower().rstrip(".,!?")
79
+ if normalized in _APPROVAL_WORDS:
80
+ return True
81
+ if any(signal in normalized for signal in _CHANGE_SIGNALS):
82
+ return False
83
+ return None
84
+
85
+
86
+ _TOOL_CALL_PATTERN = re.compile(
87
+ r"(`{1,3}[^`]*`{1,3}|\b(?:ask_human|input|print|get_preferences)\s*\([^)]*\))",
88
+ re.DOTALL,
89
+ )
90
+ _PREAMBLE_PATTERN = re.compile(
91
+ r"^.*?(?:before (?:creating|planning)|need to (?:run|conduct|perform)|requirements elicitation)[^\n]*\n+",
92
+ re.IGNORECASE | re.DOTALL,
93
+ )
94
+
95
+
96
+ def _sanitize_panorama(text: str) -> str:
97
+ """Strip tool calls and elicitation preambles from raw LLM panorama output."""
98
+ text = _PREAMBLE_PATTERN.sub("", text)
99
+ text = _TOOL_CALL_PATTERN.sub("", text)
100
+ # Collapse leftover blank lines
101
+ text = re.sub(r"\n{3,}", "\n\n", text)
102
+ return text.strip()
103
+
104
+
105
+ async def generate_panorama(request: str, model: str, history: str = "") -> str:
106
+ """Generate a high-level plan (panorama) for the given request."""
107
+ num_ctx = get_agent_llm_kwargs("landscape_planner").get("num_ctx", 8192)
108
+ threshold = 0.9
109
+ # Reserve budget for system prompt (~500 tokens) and the request itself
110
+ system_reserved = 500
111
+ request_tokens = _estimate_tokens(request)
112
+ history_budget = int(num_ctx * threshold) - system_reserved - request_tokens
113
+
114
+ if history and history_budget > 0:
115
+ history = _trim_to_budget(history, history_budget)
116
+
117
+ prompt = request
118
+ if history:
119
+ prompt = f"[CONVERSATION HISTORY]\n{history}\n[END HISTORY]\n\n[USER TASK]:\n{request}\n[END USER TASK]"
120
+
121
+ with T.spinner(_("generating_panorama")):
122
+ planner = make_landscape_planner(model)
123
+ result = await planner.run(AgentInput(prompt=prompt))
124
+ return _sanitize_panorama(result.response)
125
+
126
+
127
+ async def refine_plan(
128
+ request: str,
129
+ plan_text: str,
130
+ model: str,
131
+ session: ProjectData,
132
+ store: ProjectStore,
133
+ ) -> str:
134
+ """
135
+ Interactive refinement loop: show plan → ask user → refine or approve.
136
+ Confirmation uses instructor structured output — immune to formatting variations.
137
+ Returns the final approved plan text.
138
+ """
139
+ refinement_agent = make_refinement_agent(model)
140
+ cycles = 0
141
+
142
+ while cycles < MAX_REFINEMENT_CYCLES:
143
+ T.section(_("plan_review"))
144
+
145
+ # Show plan in terminal and write to file so user can also edit it externally
146
+ T.show_plan(plan_text)
147
+ plan_path = Path(PLAN_FILE).resolve()
148
+ try:
149
+ plan_path.write_text(plan_text, encoding="utf-8")
150
+ T.success(_("plan_saved_to_file", path=str(plan_path)))
151
+ except OSError as e:
152
+ T.warning(f"Could not write {PLAN_FILE}: {e}")
153
+
154
+ T.info(_("cancel_reminder"))
155
+ user_response = T.ask(_("plan_confirm_after_edit"))
156
+
157
+ # Read back the (possibly edited) file before using plan_text further
158
+ try:
159
+ plan_text = plan_path.read_text(encoding="utf-8")
160
+ except OSError as e:
161
+ T.warning(_("plan_file_read_error", err=e))
162
+
163
+ store.append_message(session, "assistant", plan_text)
164
+ store.append_message(session, "user", user_response)
165
+
166
+ # Empty Enter or explicit approval words → approved as-is
167
+ if not user_response:
168
+ T.success(_("plan_approved"))
169
+ return plan_text
170
+
171
+ # Fast heuristic — no LLM call needed for obvious cases
172
+ fast = _fast_approval(user_response)
173
+ if fast is True:
174
+ T.success(_("plan_approved"))
175
+ return plan_text
176
+ elif fast is False:
177
+ approved = False
178
+ else:
179
+ # Ambiguous — use structured LLM classification
180
+ with T.spinner(_("interpreting_response")):
181
+ result = await confirm_plan(plan_text, user_response, model)
182
+ approved = result.approved
183
+
184
+ if approved:
185
+ T.success(_("plan_approved"))
186
+ return plan_text
187
+
188
+ cycles += 1
189
+ T.thinking(_("refining_plan"))
190
+ num_ctx = get_agent_llm_kwargs("refinement_agent").get("num_ctx", 8192)
191
+ threshold = 0.9
192
+ guarded_request = request
193
+ full_prompt = _("refinement_prompt", request=request, plan_text=plan_text, feedback=user_response)
194
+ if _estimate_tokens(full_prompt) > num_ctx * threshold:
195
+ # Trim the request (skills preamble) to recover budget; plan and feedback are preserved
196
+ request_budget = int(num_ctx * threshold) - _estimate_tokens(plan_text) - _estimate_tokens(user_response) - 100
197
+ guarded_request = _trim_to_budget(request, max(request_budget, 200))
198
+ full_prompt = _("refinement_prompt", request=guarded_request, plan_text=plan_text, feedback=user_response)
199
+ with T.spinner(_("refining")):
200
+ refined = await refinement_agent.run(AgentInput(prompt=full_prompt))
201
+ plan_text = refined.response
202
+
203
+ T.warning(_("max_refinement_cycles"))
204
+ return plan_text
205
+
206
+