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.
- opalacoder/__init__.py +2 -0
- opalacoder/agents.py +233 -0
- opalacoder/agents.yaml +78 -0
- opalacoder/api_keys.py +75 -0
- opalacoder/cli.py +339 -0
- opalacoder/cli_commands.py +277 -0
- opalacoder/config.py +215 -0
- opalacoder/embeddings.py +85 -0
- opalacoder/i18n.py +249 -0
- opalacoder/orchestrator.py +381 -0
- opalacoder/planner.py +206 -0
- opalacoder/project.py +196 -0
- opalacoder/session.py +4 -0
- opalacoder/skills/generaldeveloper.md +52 -0
- opalacoder/skills/html_css_js.md +51 -0
- opalacoder/skills/opalacoder.md +37 -0
- opalacoder/skills/python_subprocess.md +11 -0
- opalacoder/skills/react_vite.md +6 -0
- opalacoder/skills.py +184 -0
- opalacoder/structured.py +113 -0
- opalacoder/terminal.py +186 -0
- opalacoder/tools.py +351 -0
- opalacoder/vcs.py +254 -0
- opalacoder-0.1.0.dist-info/METADATA +230 -0
- opalacoder-0.1.0.dist-info/RECORD +27 -0
- opalacoder-0.1.0.dist-info/WHEEL +4 -0
- opalacoder-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|
+
|