alpiecode 9.0.3__tar.gz → 9.0.6__tar.gz
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.
- {alpiecode-9.0.3 → alpiecode-9.0.6}/PKG-INFO +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/pyproject.toml +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/SOURCES.txt +4 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/__init__.py +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/agent.py +65 -10
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/local_backend.py +18 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/cli.py +16 -2
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/client.py +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/config.py +18 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/discovery.py +69 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/executor.py +75 -5
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/local_model.py +44 -3
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/orchestrator.py +65 -17
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/progress.py +45 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/prompt.py +9 -3
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/rephraser.py +4 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/server.py +7 -1
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/tools.py +25 -9
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/updater.py +8 -0
- alpiecode-9.0.6/test/test_audit_fixes.py +71 -0
- alpiecode-9.0.6/test/test_chat_mode.py +85 -0
- alpiecode-9.0.6/test/test_claude_codex_capabilities.py +111 -0
- alpiecode-9.0.6/test/test_offline_mode.py +78 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/README.md +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/setup.cfg +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode/__init__.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/base.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/cache.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/compaction.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/context.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/doctor.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/git_ops.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/github.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/guardian.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/guardrails.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/media.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/memory.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/session.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/src/codeagent/vscode_installer.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase1.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase2.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.6}/test/test_phase3.py +0 -0
|
@@ -40,6 +40,10 @@ src/codeagent/backends/base.py
|
|
|
40
40
|
src/codeagent/backends/local_backend.py
|
|
41
41
|
src/codeagent/backends/openai_backend.py
|
|
42
42
|
src/codeagent/extension/alpiecode.vsix
|
|
43
|
+
test/test_audit_fixes.py
|
|
44
|
+
test/test_chat_mode.py
|
|
45
|
+
test/test_claude_codex_capabilities.py
|
|
46
|
+
test/test_offline_mode.py
|
|
43
47
|
test/test_phase1.py
|
|
44
48
|
test/test_phase2.py
|
|
45
49
|
test/test_phase3.py
|
|
@@ -16,7 +16,7 @@ Supports:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional, Any, Dict
|
|
18
18
|
|
|
19
|
-
__version__ = "9.0.
|
|
19
|
+
__version__ = "9.0.6"
|
|
20
20
|
|
|
21
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
22
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -213,11 +213,15 @@ def run_agent(
|
|
|
213
213
|
# CLI-as-Client check: auto-detect if local/remote server is running
|
|
214
214
|
from .client import AlpieCodeClient
|
|
215
215
|
target_server = server_url or getattr(cfg, "server_url", None)
|
|
216
|
-
if not target_server:
|
|
217
|
-
# Check default local server
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
if not target_server and cfg.base_url != "offline" and not getattr(cfg, "offline", False):
|
|
217
|
+
# Check default local server (online mode only)
|
|
218
|
+
try:
|
|
219
|
+
local_client = AlpieCodeClient("http://127.0.0.1:7169")
|
|
220
|
+
h = local_client.health()
|
|
221
|
+
if h.get("status") == "online" and h.get("mode") != "offline":
|
|
222
|
+
target_server = "http://127.0.0.1:7169"
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
221
225
|
|
|
222
226
|
if target_server:
|
|
223
227
|
client = AlpieCodeClient(target_server)
|
|
@@ -234,7 +238,7 @@ def run_agent(
|
|
|
234
238
|
backend = resolve_backend(cfg)
|
|
235
239
|
orchestrator = AgentOrchestrator(backend)
|
|
236
240
|
session_mgr = SessionManager()
|
|
237
|
-
session = session_mgr.create_session(workdir, max_tokens=
|
|
241
|
+
session = session_mgr.create_session(workdir, max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144))
|
|
238
242
|
event_stream = orchestrator.run_task(
|
|
239
243
|
session=session,
|
|
240
244
|
task=task,
|
|
@@ -467,7 +471,7 @@ def run_agent(
|
|
|
467
471
|
return session.context.messages if "session" in locals() else []
|
|
468
472
|
|
|
469
473
|
|
|
470
|
-
def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
474
|
+
def run_chat(workdir: Path, cfg: Config, verbose: bool = True, debug: bool = False) -> None:
|
|
471
475
|
"""Run interactive chat session with Rich presentation."""
|
|
472
476
|
workdir = workdir.resolve()
|
|
473
477
|
_ensure_git(workdir)
|
|
@@ -475,7 +479,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
475
479
|
backend = resolve_backend(cfg)
|
|
476
480
|
orchestrator = AgentOrchestrator(backend)
|
|
477
481
|
session_mgr = SessionManager()
|
|
478
|
-
session = session_mgr.create_session(workdir, max_tokens=
|
|
482
|
+
session = session_mgr.create_session(workdir, max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144))
|
|
479
483
|
|
|
480
484
|
is_offline = isinstance(backend, LocalBackend) or not backend.is_available
|
|
481
485
|
active_tools = PromptBuilder().get_tools(is_offline=is_offline)
|
|
@@ -513,7 +517,50 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
513
517
|
console.print("Goodbye! 👋")
|
|
514
518
|
break
|
|
515
519
|
|
|
520
|
+
# In-chat slash commands
|
|
521
|
+
if user_input.lower() in ("/clear", "/reset"):
|
|
522
|
+
session = session_mgr.create_session(
|
|
523
|
+
workdir,
|
|
524
|
+
max_tokens=backend.context_window if hasattr(backend, 'context_window') and backend.context_window else (cfg.n_ctx if cfg.base_url == 'offline' else 262_144)
|
|
525
|
+
)
|
|
526
|
+
if HAS_RICH:
|
|
527
|
+
console.print("[bold green]✨ Conversation context reset.[/bold green]")
|
|
528
|
+
else:
|
|
529
|
+
print("✨ Conversation context reset.")
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
if user_input.lower() in ("/info", "/status"):
|
|
533
|
+
mode_str = "OFFLINE (Local GGUF)" if is_offline else "ONLINE (Cloud API)"
|
|
534
|
+
ctx_tokens = backend.context_window if hasattr(backend, 'context_window') and backend.context_window else cfg.n_ctx
|
|
535
|
+
user_turns = len([m for m in session.context.messages if m.get("role") == "user"])
|
|
536
|
+
info_msg = (
|
|
537
|
+
f"[bold]AlpieCode Session Info[/bold]\n"
|
|
538
|
+
f" • Mode: {mode_str}\n"
|
|
539
|
+
f" • Working Dir: {workdir}\n"
|
|
540
|
+
f" • Context Window: {ctx_tokens} tokens\n"
|
|
541
|
+
f" • Session Turns: {user_turns}"
|
|
542
|
+
)
|
|
543
|
+
if HAS_RICH:
|
|
544
|
+
console.print(Panel(info_msg, border_style="cyan", title="ℹ️ Status"))
|
|
545
|
+
else:
|
|
546
|
+
print(info_msg)
|
|
547
|
+
continue
|
|
548
|
+
|
|
549
|
+
if user_input.lower() in ("/help", "/?"):
|
|
550
|
+
help_msg = (
|
|
551
|
+
"Available commands:\n"
|
|
552
|
+
" • /clear, /reset - Reset conversation memory\n"
|
|
553
|
+
" • /info, /status - View current model and session diagnostics\n"
|
|
554
|
+
" • exit, quit, q - Exit interactive chat"
|
|
555
|
+
)
|
|
556
|
+
if HAS_RICH:
|
|
557
|
+
console.print(Panel(help_msg, border_style="dim", title="Help"))
|
|
558
|
+
else:
|
|
559
|
+
print(help_msg)
|
|
560
|
+
continue
|
|
561
|
+
|
|
516
562
|
has_streamed_chat = False
|
|
563
|
+
has_printed_chat = False
|
|
517
564
|
for event in orchestrator.run_task(session, user_input, cfg):
|
|
518
565
|
if event.type == "token":
|
|
519
566
|
delta = event.data.get("delta", "")
|
|
@@ -521,6 +568,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
521
568
|
sys.stdout.write(delta)
|
|
522
569
|
sys.stdout.flush()
|
|
523
570
|
has_streamed_chat = True
|
|
571
|
+
has_printed_chat = True
|
|
524
572
|
|
|
525
573
|
elif event.type == "turn_start" and debug:
|
|
526
574
|
if HAS_RICH:
|
|
@@ -532,7 +580,12 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
532
580
|
_print_reasoning(event.data["content"])
|
|
533
581
|
|
|
534
582
|
elif event.type == "tool_call" and verbose:
|
|
583
|
+
if has_streamed_chat:
|
|
584
|
+
sys.stdout.write("\n")
|
|
585
|
+
sys.stdout.flush()
|
|
586
|
+
has_streamed_chat = False
|
|
535
587
|
_print_tool_call(event.data["turn"], event.data["name"], event.data["arguments"])
|
|
588
|
+
has_printed_chat = True
|
|
536
589
|
|
|
537
590
|
elif event.type == "tool_result":
|
|
538
591
|
if verbose:
|
|
@@ -543,8 +596,9 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
543
596
|
if has_streamed_chat:
|
|
544
597
|
sys.stdout.write("\n")
|
|
545
598
|
has_streamed_chat = False
|
|
546
|
-
|
|
599
|
+
elif not has_printed_chat:
|
|
547
600
|
_print_assistant_message(event.data["content"])
|
|
601
|
+
has_printed_chat = True
|
|
548
602
|
_checkpoint(workdir, "checkpoint: done")
|
|
549
603
|
|
|
550
604
|
elif event.type == "error":
|
|
@@ -557,6 +611,7 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
557
611
|
if has_streamed_chat:
|
|
558
612
|
sys.stdout.write("\n")
|
|
559
613
|
has_streamed_chat = False
|
|
560
|
-
elif event.data.get("summary"):
|
|
614
|
+
elif not has_printed_chat and event.data.get("summary"):
|
|
561
615
|
_print_assistant_message(event.data["summary"])
|
|
616
|
+
has_printed_chat = True
|
|
562
617
|
break
|
|
@@ -55,8 +55,25 @@ class LocalBackend:
|
|
|
55
55
|
sanitized = []
|
|
56
56
|
for msg in messages:
|
|
57
57
|
role = msg.get("role", "")
|
|
58
|
-
# Skip tool result messages entirely — GGUF models don't understand them
|
|
59
58
|
if role == "tool":
|
|
59
|
+
# Bridge tool execution results into ChatML user turns so local model sees outputs
|
|
60
|
+
name = msg.get("name") or "tool"
|
|
61
|
+
raw_content = msg.get("content") or ""
|
|
62
|
+
if not isinstance(raw_content, str):
|
|
63
|
+
raw_content = str(raw_content)
|
|
64
|
+
# Smart truncate oversized terminal logs to conserve context
|
|
65
|
+
if len(raw_content) > 1500:
|
|
66
|
+
lines = raw_content.splitlines()
|
|
67
|
+
if len(lines) > 12:
|
|
68
|
+
raw_content = f"[Output: {len(lines)} lines]\n" + "\n".join(lines[:6]) + "\n...\n" + "\n".join(lines[-4:])
|
|
69
|
+
else:
|
|
70
|
+
raw_content = raw_content[:1500] + "\n... [truncated]"
|
|
71
|
+
|
|
72
|
+
tool_text = f"[Tool Result for {name}]:\n{raw_content}"
|
|
73
|
+
if sanitized and sanitized[-1].get("role") == "user":
|
|
74
|
+
sanitized[-1]["content"] += "\n\n" + tool_text
|
|
75
|
+
else:
|
|
76
|
+
sanitized.append({"role": "user", "content": tool_text})
|
|
60
77
|
continue
|
|
61
78
|
|
|
62
79
|
clean = {"role": role}
|
|
@@ -41,6 +41,10 @@ def _normalize_args():
|
|
|
41
41
|
if not subcommand and len(sys.argv) > 1:
|
|
42
42
|
subcommand = "run"
|
|
43
43
|
sys.argv.insert(1, "run")
|
|
44
|
+
elif subcommand and subcommand not in ("-h", "--help", "-v", "--version") and sys.argv[1] != subcommand:
|
|
45
|
+
# Move subcommand to index 1 so subparsers parse arguments correctly without default override
|
|
46
|
+
sys.argv.remove(subcommand)
|
|
47
|
+
sys.argv.insert(1, subcommand)
|
|
44
48
|
|
|
45
49
|
if subcommand in ("run", "plan", "explain") and len(sys.argv) > 2:
|
|
46
50
|
cmd_idx = sys.argv.index(subcommand)
|
|
@@ -82,6 +86,7 @@ def main():
|
|
|
82
86
|
common.add_argument("--no-update", action="store_true", help="Skip automatic update check")
|
|
83
87
|
common.add_argument("--quiet", action="store_true", help="Suppress per-turn logging")
|
|
84
88
|
common.add_argument("--debug", action="store_true", help="Show autonomous discovery and debug diagnostics")
|
|
89
|
+
common.add_argument("--offline", "--local", dest="offline", action="store_true", help="Run using local offline GGUF model on GPU without internet")
|
|
85
90
|
|
|
86
91
|
from . import __version__
|
|
87
92
|
parser = argparse.ArgumentParser(
|
|
@@ -145,13 +150,16 @@ def main():
|
|
|
145
150
|
|
|
146
151
|
if args.command == "serve":
|
|
147
152
|
_show_banner()
|
|
153
|
+
if getattr(args, "offline", False):
|
|
154
|
+
import os
|
|
155
|
+
os.environ["ALPIECODE_BASE_URL"] = "offline"
|
|
148
156
|
try:
|
|
149
157
|
from .vscode_installer import ensure_vscode_extension
|
|
150
158
|
ensure_vscode_extension()
|
|
151
159
|
except Exception as e:
|
|
152
160
|
pass
|
|
153
161
|
from .server import run_server
|
|
154
|
-
run_server(host=args.host, port=args.port)
|
|
162
|
+
run_server(host=args.host, port=args.port, offline=getattr(args, "offline", False))
|
|
155
163
|
return
|
|
156
164
|
|
|
157
165
|
# Check for auto-updates from GitHub in background
|
|
@@ -164,6 +172,12 @@ def main():
|
|
|
164
172
|
|
|
165
173
|
cfg = load_config()
|
|
166
174
|
|
|
175
|
+
if getattr(args, "offline", False):
|
|
176
|
+
cfg.base_url = "offline"
|
|
177
|
+
cfg.n_ctx = 16384
|
|
178
|
+
import os
|
|
179
|
+
os.environ["ALPIECODE_BASE_URL"] = "offline"
|
|
180
|
+
|
|
167
181
|
if getattr(args, "thinking", False):
|
|
168
182
|
cfg.enable_thinking = True
|
|
169
183
|
elif getattr(args, "no_thinking", False):
|
|
@@ -188,7 +202,7 @@ def main():
|
|
|
188
202
|
cfg.max_turns = args.max_turns
|
|
189
203
|
_show_banner()
|
|
190
204
|
from .agent import run_chat
|
|
191
|
-
run_chat(Path(args.workdir), cfg, verbose=not args.quiet)
|
|
205
|
+
run_chat(Path(args.workdir), cfg, verbose=not args.quiet, debug=getattr(args, 'debug', False))
|
|
192
206
|
|
|
193
207
|
elif args.command == "plan":
|
|
194
208
|
plan_task = (
|
|
@@ -11,7 +11,7 @@ import urllib.parse
|
|
|
11
11
|
import urllib.request
|
|
12
12
|
from dataclasses import dataclass
|
|
13
13
|
from pathlib import Path
|
|
14
|
-
from typing import Any, Dict, Iterator, Optional
|
|
14
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
15
15
|
|
|
16
16
|
from .orchestrator import AgentEvent
|
|
17
17
|
|
|
@@ -133,6 +133,16 @@ def load_config() -> Config:
|
|
|
133
133
|
data["api_key"] = os.environ[f"{prefix}API_KEY"]
|
|
134
134
|
if os.environ.get("HF_TOKEN"):
|
|
135
135
|
data["hf_token"] = os.environ["HF_TOKEN"]
|
|
136
|
+
|
|
137
|
+
# Context size overrides from environment
|
|
138
|
+
for ctx_var in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE", "ALPIECODE_CONTEXT_SIZE", "CODEAGENT_N_CTX"):
|
|
139
|
+
if os.environ.get(ctx_var):
|
|
140
|
+
try:
|
|
141
|
+
data["n_ctx"] = int(os.environ[ctx_var])
|
|
142
|
+
break
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
136
146
|
if os.environ.get("ALPIECODE_CPU") == "1":
|
|
137
147
|
data["n_gpu_layers"] = 0
|
|
138
148
|
elif os.environ.get("ALPIECODE_GPU_LAYERS"):
|
|
@@ -141,6 +151,14 @@ def load_config() -> Config:
|
|
|
141
151
|
except ValueError:
|
|
142
152
|
pass
|
|
143
153
|
|
|
154
|
+
# For offline mode, set high-performance 16k context (16384 tokens) for big multi-file projects
|
|
155
|
+
# Fits completely in 6GB VRAM on RTX 3050 with zero paging
|
|
156
|
+
if data.get("base_url") == "offline" and not any(os.environ.get(v) for v in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE")):
|
|
157
|
+
if data.get("n_ctx") in (32768, 4096):
|
|
158
|
+
data["n_ctx"] = 16384
|
|
159
|
+
if data.get("max_tokens", 8192) > 4096:
|
|
160
|
+
data["max_tokens"] = 4096
|
|
161
|
+
|
|
144
162
|
# Filter data to only keys present in Config fields
|
|
145
163
|
valid_keys = {f.name for f in fields(Config)}
|
|
146
164
|
filtered_data = {k: v for k, v in data.items() if k in valid_keys}
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
import sys
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import List, Optional, Tuple
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# -- Task Context ----------------------------------------------------------
|
|
@@ -47,6 +47,7 @@ class TaskContext:
|
|
|
47
47
|
has_tests: bool = False
|
|
48
48
|
entry_points: List[str] = field(default_factory=list)
|
|
49
49
|
dependencies: List[str] = field(default_factory=list)
|
|
50
|
+
symbol_index: Dict[str, List[str]] = field(default_factory=dict)
|
|
50
51
|
|
|
51
52
|
# Budget (computed from complexity)
|
|
52
53
|
max_tokens: int = 8192
|
|
@@ -558,6 +559,7 @@ def gather_relevant_context(
|
|
|
558
559
|
if getattr(task_context, "intent", "") in ("qa",):
|
|
559
560
|
return None
|
|
560
561
|
|
|
562
|
+
task_lower = task.lower().strip()
|
|
561
563
|
sections: List[str] = []
|
|
562
564
|
chars_used = 0
|
|
563
565
|
|
|
@@ -599,6 +601,22 @@ def gather_relevant_context(
|
|
|
599
601
|
for ep in task_context.entry_points[:3]:
|
|
600
602
|
_add_file(ep, f"{ep} (entry point)")
|
|
601
603
|
|
|
604
|
+
|
|
605
|
+
# -- Priority 2.5: Matching Codebase Symbols (quick navigation) --
|
|
606
|
+
if getattr(task_context, "symbol_index", None):
|
|
607
|
+
matched_syms = []
|
|
608
|
+
task_terms = [t for t in task_lower.split() if len(t) > 3]
|
|
609
|
+
for fpath, syms in task_context.symbol_index.items():
|
|
610
|
+
rel_lower = fpath.lower()
|
|
611
|
+
syms_str = " ".join(syms).lower()
|
|
612
|
+
if any(term in rel_lower or term in syms_str for term in task_terms) or len(matched_syms) < 3:
|
|
613
|
+
matched_syms.append(f"- `{fpath}`: {', '.join(syms[:4])}")
|
|
614
|
+
if len(matched_syms) >= 6:
|
|
615
|
+
break
|
|
616
|
+
if matched_syms:
|
|
617
|
+
sym_section = "### Codebase Symbol Map (Quick Navigation)\n" + "\n".join(matched_syms)
|
|
618
|
+
sections.append(sym_section)
|
|
619
|
+
|
|
602
620
|
# -- Priority 3: Files mentioned in the task (fuzzy match) --
|
|
603
621
|
task_lower = task.lower()
|
|
604
622
|
try:
|
|
@@ -629,6 +647,48 @@ def gather_relevant_context(
|
|
|
629
647
|
|
|
630
648
|
# -- Main Entry Point -------------------------------------------------------
|
|
631
649
|
|
|
650
|
+
|
|
651
|
+
def build_symbol_index(workdir: Path, max_files: int = 40) -> Dict[str, List[str]]:
|
|
652
|
+
"""Fast AST-based code symbol indexer (<50ms).
|
|
653
|
+
|
|
654
|
+
Extracts classes, methods, and functions across Python files.
|
|
655
|
+
"""
|
|
656
|
+
symbols: Dict[str, List[str]] = {}
|
|
657
|
+
count = 0
|
|
658
|
+
import ast
|
|
659
|
+
|
|
660
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".egg-info"}
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
for path in workdir.rglob("*.py"):
|
|
664
|
+
if any(part in skip_dirs or part.endswith(".egg-info") for part in path.parts):
|
|
665
|
+
continue
|
|
666
|
+
try:
|
|
667
|
+
rel_str = str(path.relative_to(workdir))
|
|
668
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
669
|
+
file_syms = []
|
|
670
|
+
for node in tree.body:
|
|
671
|
+
if isinstance(node, ast.FunctionDef):
|
|
672
|
+
args = [a.arg for a in node.args.args if a.arg != "self"]
|
|
673
|
+
sig = f"def {node.name}({', '.join(args[:3])})"
|
|
674
|
+
file_syms.append(sig)
|
|
675
|
+
elif isinstance(node, ast.ClassDef):
|
|
676
|
+
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef) and not n.name.startswith("__")]
|
|
677
|
+
method_str = f" [{', '.join(methods[:3])}]" if methods else ""
|
|
678
|
+
file_syms.append(f"class {node.name}{method_str}")
|
|
679
|
+
if file_syms:
|
|
680
|
+
symbols[rel_str] = file_syms[:8]
|
|
681
|
+
count += 1
|
|
682
|
+
if count >= max_files:
|
|
683
|
+
break
|
|
684
|
+
except Exception:
|
|
685
|
+
continue
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
|
|
689
|
+
return symbols
|
|
690
|
+
|
|
691
|
+
|
|
632
692
|
def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
633
693
|
"""Build complete TaskContext by running all discovery phases.
|
|
634
694
|
|
|
@@ -650,7 +710,15 @@ def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
|
650
710
|
# Phase 5: Budget from complexity
|
|
651
711
|
budget = COMPLEXITY_CONFIG[complexity]
|
|
652
712
|
|
|
713
|
+
symbol_index = {}
|
|
714
|
+
if repo.get("file_count", 0) > 0 and repo.get("project_type") != "empty":
|
|
715
|
+
try:
|
|
716
|
+
symbol_index = build_symbol_index(workdir)
|
|
717
|
+
except Exception:
|
|
718
|
+
pass
|
|
719
|
+
|
|
653
720
|
return TaskContext(
|
|
721
|
+
symbol_index=symbol_index,
|
|
654
722
|
# Intent
|
|
655
723
|
intent=intent,
|
|
656
724
|
complexity=complexity,
|
|
@@ -39,11 +39,15 @@ def parse_text_tool_calls(text: str) -> List[dict]:
|
|
|
39
39
|
|
|
40
40
|
tool_calls = []
|
|
41
41
|
|
|
42
|
-
# 1. Try parsing
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
# 1. Try parsing blocks inside <tool_call> or ```json using robust JSON repair
|
|
43
|
+
from .backends.openai_backend import _repair_and_parse_json_arguments
|
|
44
|
+
blocks = re.findall(r"(?:<tool_call>|```json)\s*(.*?)\s*(?:</tool_call>|```|$)", text, re.DOTALL)
|
|
45
|
+
for block in blocks:
|
|
46
|
+
raw = block.strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
continue
|
|
45
49
|
try:
|
|
46
|
-
data =
|
|
50
|
+
data = _repair_and_parse_json_arguments(raw)
|
|
47
51
|
if isinstance(data, dict) and "name" in data:
|
|
48
52
|
tool_calls.append({
|
|
49
53
|
"name": data["name"],
|
|
@@ -146,6 +150,42 @@ def _parse_error_hint(stderr: str, stdout: str) -> str:
|
|
|
146
150
|
return "\n".join(hints)
|
|
147
151
|
|
|
148
152
|
|
|
153
|
+
|
|
154
|
+
def suggest_similar_file(workdir: Path, target_path: str) -> Optional[str]:
|
|
155
|
+
"""Find the closest existing file path in the workspace for typos/misspellings."""
|
|
156
|
+
import difflib
|
|
157
|
+
target_name = Path(target_path).name.lower()
|
|
158
|
+
candidates = []
|
|
159
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
160
|
+
try:
|
|
161
|
+
for p in workdir.rglob("*"):
|
|
162
|
+
if p.is_file() and not any(part in skip_dirs for part in p.parts):
|
|
163
|
+
candidates.append(str(p.relative_to(workdir)))
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if not candidates:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# 1. Exact filename match in another directory
|
|
171
|
+
for c in candidates:
|
|
172
|
+
if Path(c).name.lower() == target_name:
|
|
173
|
+
return c
|
|
174
|
+
|
|
175
|
+
# 2. Fuzzy match on relative path
|
|
176
|
+
matches = difflib.get_close_matches(target_path, candidates, n=1, cutoff=0.55)
|
|
177
|
+
if matches:
|
|
178
|
+
return matches[0]
|
|
179
|
+
|
|
180
|
+
# 3. Fuzzy match on filename
|
|
181
|
+
file_matches = difflib.get_close_matches(target_name, [Path(c).name.lower() for c in candidates], n=1, cutoff=0.55)
|
|
182
|
+
if file_matches:
|
|
183
|
+
for c in candidates:
|
|
184
|
+
if Path(c).name.lower() == file_matches[0]:
|
|
185
|
+
return c
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
149
189
|
class ToolExecutor:
|
|
150
190
|
"""Executes tool calls for a workspace."""
|
|
151
191
|
|
|
@@ -154,13 +194,14 @@ class ToolExecutor:
|
|
|
154
194
|
self.dispatch = make_dispatch(workdir)
|
|
155
195
|
self.tool_call_history: List[Tuple[str, str]] = []
|
|
156
196
|
self.compile_fail_counts: Dict[str, int] = {}
|
|
197
|
+
self.successful_bash_commands: set = set()
|
|
157
198
|
|
|
158
199
|
def extract_tool_calls(self, response: ChatResponse) -> List[ToolCall]:
|
|
159
200
|
"""Extract tool calls from response (structured or text format)."""
|
|
160
201
|
if response.tool_calls:
|
|
161
202
|
return response.tool_calls
|
|
162
203
|
|
|
163
|
-
if response.content
|
|
204
|
+
if response.content:
|
|
164
205
|
parsed = parse_text_tool_calls(response.content)
|
|
165
206
|
return [
|
|
166
207
|
ToolCall(
|
|
@@ -215,6 +256,12 @@ class ToolExecutor:
|
|
|
215
256
|
res_str = f"error: {e}"
|
|
216
257
|
if on_tool_end:
|
|
217
258
|
on_tool_end(tc.name, res_str)
|
|
259
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
260
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
261
|
+
if req_path:
|
|
262
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
263
|
+
if sugg and sugg != req_path:
|
|
264
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
218
265
|
results_map[tc.id] = ToolResult(
|
|
219
266
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
220
267
|
)
|
|
@@ -270,6 +317,12 @@ class ToolExecutor:
|
|
|
270
317
|
res_str = f"error: {e}"
|
|
271
318
|
if on_tool_end:
|
|
272
319
|
on_tool_end(tc.name, res_str)
|
|
320
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
321
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
322
|
+
if req_path:
|
|
323
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
324
|
+
if sugg and sugg != req_path:
|
|
325
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
273
326
|
results_map[tc.id] = ToolResult(
|
|
274
327
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
275
328
|
)
|
|
@@ -290,6 +343,10 @@ class ToolExecutor:
|
|
|
290
343
|
res_str = f"error: Unknown tool '{tc.name}'"
|
|
291
344
|
elapsed = (time.monotonic() - t0) * 1000
|
|
292
345
|
|
|
346
|
+
# Clear passed verification set whenever code files are written or edited
|
|
347
|
+
if tc.name in ("write_file", "edit_file", "apply_patch"):
|
|
348
|
+
self.successful_bash_commands.clear()
|
|
349
|
+
|
|
293
350
|
# Tool loop detection guard
|
|
294
351
|
call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
|
|
295
352
|
self.tool_call_history.append(call_sig)
|
|
@@ -302,6 +359,19 @@ class ToolExecutor:
|
|
|
302
359
|
"All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
|
|
303
360
|
)
|
|
304
361
|
|
|
362
|
+
# Repetitive verification sequence guard
|
|
363
|
+
if tc.name == "bash":
|
|
364
|
+
cmd_str = safe_args.get("command", "").strip()
|
|
365
|
+
if cmd_str and cmd_str in self.successful_bash_commands:
|
|
366
|
+
res_str += (
|
|
367
|
+
"\n\n⚠️ REPETITIVE VERIFICATION NOTICE: This exact command has already run and passed with exit code 0, "
|
|
368
|
+
"and no files have been modified since. Re-running identical tests is unnecessary. "
|
|
369
|
+
"All checks have already passed! Finalize the task now with: DONE: <summary>."
|
|
370
|
+
)
|
|
371
|
+
elif '"exit_code": 0' in res_str:
|
|
372
|
+
if cmd_str:
|
|
373
|
+
self.successful_bash_commands.add(cmd_str)
|
|
374
|
+
|
|
305
375
|
# Smart error hint injection for failed bash commands
|
|
306
376
|
if tc.name == "bash" and '"exit_code"' in res_str:
|
|
307
377
|
try:
|
|
Binary file
|
|
@@ -68,7 +68,8 @@ def detect_gpu() -> int:
|
|
|
68
68
|
if res.returncode == 0 and res.stdout.strip():
|
|
69
69
|
vram_mb = int(res.stdout.strip().split("\n")[0].strip())
|
|
70
70
|
if vram_mb <= 6500: # 6GB VRAM (e.g. RTX 3050 Laptop)
|
|
71
|
-
|
|
71
|
+
# Full offload: 4.4GB model + 4096 context fits in 6GB VRAM and runs 5x faster than partial offload
|
|
72
|
+
return 33
|
|
72
73
|
return -1 # 8GB+ VRAM — offload all layers
|
|
73
74
|
except Exception:
|
|
74
75
|
pass
|
|
@@ -303,7 +304,19 @@ class LocalModel:
|
|
|
303
304
|
model_path = self.ensure_model()
|
|
304
305
|
Llama = _ensure_llama_cpp()
|
|
305
306
|
|
|
306
|
-
|
|
307
|
+
has_gpu_offload = False
|
|
308
|
+
try:
|
|
309
|
+
import llama_cpp
|
|
310
|
+
has_gpu_offload = getattr(llama_cpp, "llama_supports_gpu_offload", lambda: False)()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
if self.n_gpu_layers != 0 and not has_gpu_offload:
|
|
315
|
+
print(" ⚠️ Warning: Active Python environment has no CUDA llama-cpp support. Running on CPU.")
|
|
316
|
+
accel = "CPU (No CUDA in Python env)"
|
|
317
|
+
self.n_gpu_layers = 0
|
|
318
|
+
else:
|
|
319
|
+
accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
|
|
307
320
|
|
|
308
321
|
# ── Smart P-Core Topology Auto-Tuning ─────────────────────────
|
|
309
322
|
# On hybrid CPUs (Intel 12th+), over-subscribing E-cores causes
|
|
@@ -401,6 +414,17 @@ class LocalModel:
|
|
|
401
414
|
else:
|
|
402
415
|
msgs = messages
|
|
403
416
|
|
|
417
|
+
# Context headroom safety guard: dynamically clamp max_tokens if prompt is large
|
|
418
|
+
n_ctx_limit = getattr(self, "n_ctx", 16384)
|
|
419
|
+
try:
|
|
420
|
+
total_chars = sum(len(str(m.get("content", ""))) for m in msgs)
|
|
421
|
+
est_prompt_tokens = int(total_chars / 3.0) + (len(tools or []) * 80) + 100
|
|
422
|
+
if est_prompt_tokens + max_tokens > n_ctx_limit:
|
|
423
|
+
available_tokens = max(128, n_ctx_limit - est_prompt_tokens - 32)
|
|
424
|
+
max_tokens = min(max_tokens, available_tokens)
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
427
|
+
|
|
404
428
|
params = {
|
|
405
429
|
"messages": msgs,
|
|
406
430
|
"temperature": 0.1 if not enable_thinking else temperature,
|
|
@@ -410,7 +434,24 @@ class LocalModel:
|
|
|
410
434
|
params["tools"] = tools
|
|
411
435
|
params["tool_choice"] = kwargs.get("tool_choice", "auto")
|
|
412
436
|
|
|
413
|
-
|
|
437
|
+
try:
|
|
438
|
+
result_dict = llm.create_chat_completion(**params)
|
|
439
|
+
except ValueError as ve:
|
|
440
|
+
err_msg = str(ve)
|
|
441
|
+
if "exceed context window" in err_msg.lower():
|
|
442
|
+
# Parse: "Requested tokens (X) exceed context window of Y"
|
|
443
|
+
m = re.search(r"Requested tokens \((\d+)\) exceed context window of (\d+)", err_msg)
|
|
444
|
+
if m:
|
|
445
|
+
requested = int(m.group(1))
|
|
446
|
+
win = int(m.group(2))
|
|
447
|
+
excess = requested - win
|
|
448
|
+
new_max = max(128, params["max_tokens"] - excess - 32)
|
|
449
|
+
params["max_tokens"] = new_max
|
|
450
|
+
result_dict = llm.create_chat_completion(**params)
|
|
451
|
+
else:
|
|
452
|
+
raise
|
|
453
|
+
else:
|
|
454
|
+
raise
|
|
414
455
|
|
|
415
456
|
# Fix prefill response content so chat history remains valid across multi-turn sessions
|
|
416
457
|
if not enable_thinking and result_dict.get("choices"):
|