alpiecode 8.0.7__tar.gz → 8.0.9__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-8.0.7 → alpiecode-8.0.9}/PKG-INFO +1 -1
- {alpiecode-8.0.7 → alpiecode-8.0.9}/pyproject.toml +1 -1
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/SOURCES.txt +2 -1
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/__init__.py +1 -2
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/agent.py +74 -5
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/cli.py +0 -49
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/compaction.py +9 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/context.py +8 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/executor.py +0 -59
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/orchestrator.py +23 -17
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/tools.py +1 -1
- alpiecode-8.0.9/test/test_phase1.py +205 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/README.md +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/setup.cfg +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/client.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/discovery.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/guardrails.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/memory.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/progress.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/prompt.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/rephraser.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/session.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.7 → alpiecode-8.0.9}/src/codeagent/vscode_installer.py +0 -0
|
@@ -16,8 +16,7 @@ Supports:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional, Any, Dict
|
|
18
18
|
|
|
19
|
-
__version__ = "8.0.
|
|
20
|
-
|
|
19
|
+
__version__ = "8.0.9"
|
|
21
20
|
|
|
22
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
23
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -11,6 +11,7 @@ Delegates agent orchestration to AgentOrchestrator and handles Rich terminal UI
|
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
13
|
import subprocess
|
|
14
|
+
import sys
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import Any, Dict, List, Optional
|
|
16
17
|
|
|
@@ -249,9 +250,43 @@ def run_agent(
|
|
|
249
250
|
|
|
250
251
|
current_turn = 0
|
|
251
252
|
last_discovery = {}
|
|
253
|
+
has_streamed_tokens = False
|
|
254
|
+
has_printed_anything = False
|
|
252
255
|
|
|
253
256
|
for event in event_stream:
|
|
254
|
-
if event.type == "
|
|
257
|
+
if event.type == "token" and verbose:
|
|
258
|
+
delta = event.data.get("delta", "")
|
|
259
|
+
if delta:
|
|
260
|
+
if not has_streamed_tokens:
|
|
261
|
+
sys.stdout.write("\n")
|
|
262
|
+
has_streamed_tokens = True
|
|
263
|
+
sys.stdout.write(delta)
|
|
264
|
+
sys.stdout.flush()
|
|
265
|
+
has_printed_anything = True
|
|
266
|
+
|
|
267
|
+
elif event.type == "thinking_start" and verbose:
|
|
268
|
+
if debug:
|
|
269
|
+
if HAS_RICH:
|
|
270
|
+
console.print("\n[dim italic]Thinking...[/dim italic]", style="dim")
|
|
271
|
+
else:
|
|
272
|
+
sys.stdout.write("\nThinking...\n")
|
|
273
|
+
sys.stdout.flush()
|
|
274
|
+
|
|
275
|
+
elif event.type == "thinking_delta" and verbose and debug:
|
|
276
|
+
delta = event.data.get("delta", "")
|
|
277
|
+
if delta:
|
|
278
|
+
sys.stdout.write(delta)
|
|
279
|
+
sys.stdout.flush()
|
|
280
|
+
|
|
281
|
+
elif event.type == "thinking_end" and verbose and debug:
|
|
282
|
+
duration = event.data.get("duration", 0)
|
|
283
|
+
if HAS_RICH:
|
|
284
|
+
console.print(f" [dim]({duration}s)[/dim]\n")
|
|
285
|
+
else:
|
|
286
|
+
sys.stdout.write(f" ({duration}s)\n")
|
|
287
|
+
sys.stdout.flush()
|
|
288
|
+
|
|
289
|
+
elif event.type == "discovery" and verbose:
|
|
255
290
|
last_discovery = event.data
|
|
256
291
|
if debug and HAS_RICH:
|
|
257
292
|
console.print(Panel(
|
|
@@ -335,7 +370,12 @@ def run_agent(
|
|
|
335
370
|
_print_reasoning(event.data["content"])
|
|
336
371
|
|
|
337
372
|
elif event.type == "tool_call" and verbose:
|
|
373
|
+
if has_streamed_tokens:
|
|
374
|
+
sys.stdout.write("\n")
|
|
375
|
+
sys.stdout.flush()
|
|
376
|
+
has_streamed_tokens = False
|
|
338
377
|
_print_tool_call(event.data["turn"], event.data["name"], event.data["arguments"])
|
|
378
|
+
has_printed_anything = True
|
|
339
379
|
|
|
340
380
|
elif event.type == "tool_result":
|
|
341
381
|
if verbose:
|
|
@@ -343,7 +383,13 @@ def run_agent(
|
|
|
343
383
|
_checkpoint(workdir, f"checkpoint: turn {event.data['turn']}")
|
|
344
384
|
|
|
345
385
|
elif event.type == "message" and verbose:
|
|
346
|
-
|
|
386
|
+
if has_streamed_tokens:
|
|
387
|
+
sys.stdout.write("\n")
|
|
388
|
+
sys.stdout.flush()
|
|
389
|
+
has_streamed_tokens = False
|
|
390
|
+
else:
|
|
391
|
+
_print_assistant_message(event.data["content"])
|
|
392
|
+
has_printed_anything = True
|
|
347
393
|
_checkpoint(workdir, "checkpoint: response")
|
|
348
394
|
|
|
349
395
|
elif event.type == "fallback" and verbose:
|
|
@@ -393,7 +439,13 @@ def run_agent(
|
|
|
393
439
|
print(f"\n🛑 Safety ceiling ({event.data['ceiling']}) reached.")
|
|
394
440
|
|
|
395
441
|
elif event.type == "done":
|
|
396
|
-
|
|
442
|
+
if has_streamed_tokens:
|
|
443
|
+
sys.stdout.write("\n\n")
|
|
444
|
+
sys.stdout.flush()
|
|
445
|
+
has_streamed_tokens = False
|
|
446
|
+
elif not has_printed_anything and event.data.get("summary"):
|
|
447
|
+
_print_assistant_message(event.data["summary"])
|
|
448
|
+
has_printed_anything = True
|
|
397
449
|
_checkpoint(workdir, "checkpoint: done")
|
|
398
450
|
if debug and HAS_RICH:
|
|
399
451
|
console.rule("[bold green]✅ Complete[/bold green]")
|
|
@@ -461,8 +513,16 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
461
513
|
console.print("Goodbye! 👋")
|
|
462
514
|
break
|
|
463
515
|
|
|
516
|
+
has_streamed_chat = False
|
|
464
517
|
for event in orchestrator.run_task(session, user_input, cfg):
|
|
465
|
-
if event.type == "
|
|
518
|
+
if event.type == "token":
|
|
519
|
+
delta = event.data.get("delta", "")
|
|
520
|
+
if delta:
|
|
521
|
+
sys.stdout.write(delta)
|
|
522
|
+
sys.stdout.flush()
|
|
523
|
+
has_streamed_chat = True
|
|
524
|
+
|
|
525
|
+
elif event.type == "turn_start" and debug:
|
|
466
526
|
if HAS_RICH:
|
|
467
527
|
console.print(f"[dim]── Step {event.data['turn']} ──[/dim]")
|
|
468
528
|
else:
|
|
@@ -480,7 +540,11 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
480
540
|
_checkpoint(workdir, f"checkpoint: chat turn {event.data['turn']}")
|
|
481
541
|
|
|
482
542
|
elif event.type == "message":
|
|
483
|
-
|
|
543
|
+
if has_streamed_chat:
|
|
544
|
+
sys.stdout.write("\n")
|
|
545
|
+
has_streamed_chat = False
|
|
546
|
+
else:
|
|
547
|
+
_print_assistant_message(event.data["content"])
|
|
484
548
|
_checkpoint(workdir, "checkpoint: done")
|
|
485
549
|
|
|
486
550
|
elif event.type == "error":
|
|
@@ -490,4 +554,9 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
|
|
|
490
554
|
print(f"❌ Model error: {event.data['error']}")
|
|
491
555
|
|
|
492
556
|
elif event.type == "done":
|
|
557
|
+
if has_streamed_chat:
|
|
558
|
+
sys.stdout.write("\n")
|
|
559
|
+
has_streamed_chat = False
|
|
560
|
+
elif event.data.get("summary"):
|
|
561
|
+
_print_assistant_message(event.data["summary"])
|
|
493
562
|
break
|
|
@@ -219,10 +219,6 @@ def main():
|
|
|
219
219
|
undo_last_session(Path(args.workdir).resolve())
|
|
220
220
|
return
|
|
221
221
|
|
|
222
|
-
elif args.command == "doctor":
|
|
223
|
-
from .doctor import run_doctor
|
|
224
|
-
sys.exit(run_doctor())
|
|
225
|
-
|
|
226
222
|
elif args.command == "explain":
|
|
227
223
|
target = args.target
|
|
228
224
|
target_path = Path(args.workdir) / target if not Path(target).is_absolute() else Path(target)
|
|
@@ -254,51 +250,6 @@ def main():
|
|
|
254
250
|
debug=getattr(args, "debug", False),
|
|
255
251
|
)
|
|
256
252
|
|
|
257
|
-
elif args.command == "diff":
|
|
258
|
-
workdir = Path(args.workdir).resolve()
|
|
259
|
-
# Show git diff since the first checkpoint
|
|
260
|
-
result = subprocess.run(
|
|
261
|
-
["git", "log", "--oneline", "--all"],
|
|
262
|
-
cwd=workdir, capture_output=True, text=True
|
|
263
|
-
)
|
|
264
|
-
if result.returncode != 0:
|
|
265
|
-
print("Not a git repository or no commits found.")
|
|
266
|
-
return
|
|
267
|
-
|
|
268
|
-
# Find the start checkpoint
|
|
269
|
-
log_lines = result.stdout.strip().splitlines()
|
|
270
|
-
start_sha = None
|
|
271
|
-
for line in reversed(log_lines):
|
|
272
|
-
if "checkpoint: start" in line:
|
|
273
|
-
start_sha = line.split()[0]
|
|
274
|
-
break
|
|
275
|
-
|
|
276
|
-
if not start_sha:
|
|
277
|
-
print("No AlpieCode checkpoint found. Run a task first.")
|
|
278
|
-
return
|
|
279
|
-
|
|
280
|
-
diff_result = subprocess.run(
|
|
281
|
-
["git", "diff", start_sha, "HEAD", "--stat"],
|
|
282
|
-
cwd=workdir, capture_output=True, text=True
|
|
283
|
-
)
|
|
284
|
-
print(f"Changes since AlpieCode started (from {start_sha}):\n")
|
|
285
|
-
print(diff_result.stdout)
|
|
286
|
-
|
|
287
|
-
# Also show the full diff
|
|
288
|
-
full_diff = subprocess.run(
|
|
289
|
-
["git", "diff", start_sha, "HEAD"],
|
|
290
|
-
cwd=workdir, capture_output=True, text=True
|
|
291
|
-
)
|
|
292
|
-
if full_diff.stdout:
|
|
293
|
-
try:
|
|
294
|
-
from rich.console import Console
|
|
295
|
-
from rich.syntax import Syntax
|
|
296
|
-
console = Console()
|
|
297
|
-
syntax = Syntax(full_diff.stdout, "diff", theme="monokai")
|
|
298
|
-
console.print(syntax)
|
|
299
|
-
except ImportError:
|
|
300
|
-
print(full_diff.stdout)
|
|
301
|
-
|
|
302
253
|
|
|
303
254
|
if __name__ == "__main__":
|
|
304
255
|
main()
|
|
@@ -240,6 +240,15 @@ def compact_messages(messages: List[dict]) -> List[dict]:
|
|
|
240
240
|
|
|
241
241
|
# Split into old and recent
|
|
242
242
|
cutoff = len(messages) - KEEP_RECENT_TURNS
|
|
243
|
+
|
|
244
|
+
# ── Tool-Call Pairing Guard ──
|
|
245
|
+
# If cutoff lands on a 'tool' message, move it backwards to include
|
|
246
|
+
# the preceding 'assistant' message that owns the tool_call_id.
|
|
247
|
+
# This prevents OpenAI API 400 errors ("missing tool result for tool_call_id").
|
|
248
|
+
min_cutoff = 1 if system else 0
|
|
249
|
+
while cutoff > min_cutoff and messages[cutoff].get("role") == "tool":
|
|
250
|
+
cutoff -= 1
|
|
251
|
+
|
|
243
252
|
old_messages = messages[1:cutoff] if system else messages[:cutoff]
|
|
244
253
|
recent_messages = messages[cutoff:]
|
|
245
254
|
|
|
@@ -297,6 +297,14 @@ class ContextManager:
|
|
|
297
297
|
"content": "\n\n".join(supplemental_parts),
|
|
298
298
|
})
|
|
299
299
|
|
|
300
|
+
# Ensure user query anchor: endpoint requires at least one 'user' message
|
|
301
|
+
has_user_in_recent = any(m.get("role") == "user" for m in recent_messages)
|
|
302
|
+
if not has_user_in_recent:
|
|
303
|
+
for m in distant_messages:
|
|
304
|
+
if m.get("role") == "user":
|
|
305
|
+
context_blocks.append(dict(m))
|
|
306
|
+
break
|
|
307
|
+
|
|
300
308
|
# ── Layer 4 & 5: Recent Messages and Current Request ──
|
|
301
309
|
if len(recent_messages) > 6:
|
|
302
310
|
for i in range(len(recent_messages) - 4):
|
|
@@ -346,62 +346,3 @@ class ToolExecutor:
|
|
|
346
346
|
)
|
|
347
347
|
|
|
348
348
|
return [results_map[tc.id] for tc in tool_calls if tc.id in results_map]
|
|
349
|
-
|
|
350
|
-
# Sequential execution
|
|
351
|
-
for tc in tool_calls:
|
|
352
|
-
if on_tool_start:
|
|
353
|
-
on_tool_start(tc.name, tc.arguments)
|
|
354
|
-
t0 = time.monotonic()
|
|
355
|
-
# Defensive: ensure arguments is always a dict
|
|
356
|
-
safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
|
|
357
|
-
fn = self.dispatch.get(tc.name)
|
|
358
|
-
if fn:
|
|
359
|
-
try:
|
|
360
|
-
res_str = str(fn(safe_args))
|
|
361
|
-
except Exception as e:
|
|
362
|
-
res_str = f"error: {e}"
|
|
363
|
-
else:
|
|
364
|
-
res_str = f"error: Unknown tool '{tc.name}'"
|
|
365
|
-
elapsed = (time.monotonic() - t0) * 1000
|
|
366
|
-
|
|
367
|
-
# Tool loop detection guard
|
|
368
|
-
call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
|
|
369
|
-
self.tool_call_history.append(call_sig)
|
|
370
|
-
repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
|
|
371
|
-
|
|
372
|
-
if repeat_count >= 3:
|
|
373
|
-
res_str += (
|
|
374
|
-
f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
|
|
375
|
-
f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
|
|
376
|
-
"All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
# Compilation failure recovery hint
|
|
380
|
-
if tc.name == "bash":
|
|
381
|
-
cmd = tc.arguments.get("command", "")
|
|
382
|
-
is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
|
|
383
|
-
if is_compile and "exit_code" in str(res_str):
|
|
384
|
-
try:
|
|
385
|
-
result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
|
|
386
|
-
if result_data.get("exit_code", 0) != 0:
|
|
387
|
-
compile_key = cmd.strip()
|
|
388
|
-
self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
|
|
389
|
-
if self.compile_fail_counts[compile_key] >= 3:
|
|
390
|
-
res_str += (
|
|
391
|
-
"\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
|
|
392
|
-
f"#{self.compile_fail_counts[compile_key]}). "
|
|
393
|
-
"STOP making blind edits. Re-read the ENTIRE source file with "
|
|
394
|
-
"read_file to understand its full structure, then fix ALL errors "
|
|
395
|
-
"comprehensively in one edit."
|
|
396
|
-
)
|
|
397
|
-
else:
|
|
398
|
-
self.compile_fail_counts.pop(cmd.strip(), None)
|
|
399
|
-
except (json.JSONDecodeError, ValueError):
|
|
400
|
-
pass
|
|
401
|
-
|
|
402
|
-
if on_tool_end:
|
|
403
|
-
on_tool_end(tc.name, res_str)
|
|
404
|
-
|
|
405
|
-
results.append(ToolResult(tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed))
|
|
406
|
-
|
|
407
|
-
return results
|
|
Binary file
|
|
@@ -118,6 +118,7 @@ class AgentOrchestrator:
|
|
|
118
118
|
# Track touched files and executed commands for Antigravity Walkthrough
|
|
119
119
|
session_touched_files = set()
|
|
120
120
|
session_executed_commands = []
|
|
121
|
+
verification_nudged = False # Per-task local flag (prevents cross-session state leak)
|
|
121
122
|
|
|
122
123
|
# ── Response cache check ──
|
|
123
124
|
is_cacheable = not any([image_path, video_path, url, github_repo])
|
|
@@ -142,11 +143,12 @@ class AgentOrchestrator:
|
|
|
142
143
|
yield AgentEvent("done", {"summary": cached["response"]})
|
|
143
144
|
return
|
|
144
145
|
|
|
145
|
-
# ── Dynamic backend re-check ──
|
|
146
|
-
|
|
147
|
-
|
|
146
|
+
# ── Dynamic backend re-check (thread-safe: use task-local backend) ──
|
|
147
|
+
task_backend = self.backend
|
|
148
|
+
if isinstance(task_backend, LocalBackend) and is_server_reachable(cfg.base_url, timeout=1.5):
|
|
149
|
+
task_backend = OpenAIBackend(cfg)
|
|
148
150
|
|
|
149
|
-
is_offline = not
|
|
151
|
+
is_offline = not task_backend.is_available or isinstance(task_backend, LocalBackend)
|
|
150
152
|
session.is_offline = is_offline
|
|
151
153
|
|
|
152
154
|
# ── Configure tools & system prompt based on complexity ──
|
|
@@ -164,7 +166,7 @@ class AgentOrchestrator:
|
|
|
164
166
|
})
|
|
165
167
|
rephrased_task = task
|
|
166
168
|
try:
|
|
167
|
-
rephrased_task = self.rephraser.rephrase(task,
|
|
169
|
+
rephrased_task = self.rephraser.rephrase(task, task_backend, task_context)
|
|
168
170
|
except Exception:
|
|
169
171
|
rephrased_task = task
|
|
170
172
|
|
|
@@ -199,7 +201,7 @@ class AgentOrchestrator:
|
|
|
199
201
|
yield AgentEvent("start", {
|
|
200
202
|
"task": task,
|
|
201
203
|
"workdir": str(session.workdir),
|
|
202
|
-
"backend":
|
|
204
|
+
"backend": task_backend.name,
|
|
203
205
|
"is_offline": is_offline,
|
|
204
206
|
"tool_count": len(active_tools),
|
|
205
207
|
"complexity": complexity,
|
|
@@ -261,9 +263,9 @@ class AgentOrchestrator:
|
|
|
261
263
|
else:
|
|
262
264
|
max_tokens = 2048 if is_offline else effective_max_tokens
|
|
263
265
|
|
|
264
|
-
if hasattr(
|
|
266
|
+
if hasattr(task_backend, "chat_completion_stream") and not is_offline:
|
|
265
267
|
resp = None
|
|
266
|
-
for event_type, data in
|
|
268
|
+
for event_type, data in task_backend.chat_completion_stream(
|
|
267
269
|
messages=session.context.messages,
|
|
268
270
|
tools=active_tools if active_tools else None,
|
|
269
271
|
temperature=cfg.temperature,
|
|
@@ -275,7 +277,7 @@ class AgentOrchestrator:
|
|
|
275
277
|
else:
|
|
276
278
|
yield AgentEvent(event_type, data)
|
|
277
279
|
else:
|
|
278
|
-
resp =
|
|
280
|
+
resp = task_backend.chat_completion(
|
|
279
281
|
messages=session.context.messages,
|
|
280
282
|
tools=active_tools if active_tools else None,
|
|
281
283
|
temperature=cfg.temperature,
|
|
@@ -284,14 +286,14 @@ class AgentOrchestrator:
|
|
|
284
286
|
)
|
|
285
287
|
except Exception as e:
|
|
286
288
|
# Online error -> fallback to local
|
|
287
|
-
if not is_offline and isinstance(
|
|
289
|
+
if not is_offline and isinstance(task_backend, OpenAIBackend):
|
|
288
290
|
yield AgentEvent("fallback", {"error": str(e), "message": "Falling back to local engine"})
|
|
289
|
-
|
|
291
|
+
task_backend = LocalBackend(cfg)
|
|
290
292
|
session.is_offline = True
|
|
291
293
|
is_offline = True
|
|
292
294
|
active_tools = self.prompt_builder.get_tools(is_offline=True, complexity=complexity)
|
|
293
295
|
try:
|
|
294
|
-
resp =
|
|
296
|
+
resp = task_backend.chat_completion(
|
|
295
297
|
messages=session.context.messages,
|
|
296
298
|
tools=active_tools if active_tools else None,
|
|
297
299
|
temperature=cfg.temperature,
|
|
@@ -305,7 +307,11 @@ class AgentOrchestrator:
|
|
|
305
307
|
yield AgentEvent("error", {"error": str(e)})
|
|
306
308
|
return
|
|
307
309
|
|
|
308
|
-
if resp
|
|
310
|
+
if resp is None:
|
|
311
|
+
yield AgentEvent("error", {"error": "Backend returned no response"})
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
if resp.reasoning and not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
|
|
309
315
|
yield AgentEvent("thinking", {"content": resp.reasoning})
|
|
310
316
|
|
|
311
317
|
session.context.add_assistant_response(resp)
|
|
@@ -349,7 +355,7 @@ class AgentOrchestrator:
|
|
|
349
355
|
resp.content = parts[0] + "DONE: " + parts[1].strip()
|
|
350
356
|
|
|
351
357
|
# Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
|
|
352
|
-
if not (hasattr(
|
|
358
|
+
if not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
|
|
353
359
|
yield AgentEvent("message", {"content": resp.content})
|
|
354
360
|
|
|
355
361
|
extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
|
|
@@ -385,8 +391,8 @@ class AgentOrchestrator:
|
|
|
385
391
|
has_successful_verify = any(
|
|
386
392
|
c.get("exit_code", -1) == 0 for c in session_executed_commands
|
|
387
393
|
)
|
|
388
|
-
if has_code_files and not has_successful_verify and not
|
|
389
|
-
|
|
394
|
+
if has_code_files and not has_successful_verify and not verification_nudged:
|
|
395
|
+
verification_nudged = True
|
|
390
396
|
session.context.add_user_message(
|
|
391
397
|
"[SYSTEM - VERIFICATION] You created/modified code files but did not run "
|
|
392
398
|
"any verification command. Please run a quick syntax check or test before finishing. "
|
|
@@ -475,7 +481,7 @@ class AgentOrchestrator:
|
|
|
475
481
|
resp.content = parts[0] + "DONE: " + parts[1].strip()
|
|
476
482
|
|
|
477
483
|
# Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
|
|
478
|
-
if not (hasattr(
|
|
484
|
+
if not (hasattr(task_backend, "chat_completion_stream") and not is_offline):
|
|
479
485
|
yield AgentEvent("message", {"content": resp.content})
|
|
480
486
|
|
|
481
487
|
extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
|
|
@@ -496,7 +496,7 @@ def _bash(workdir: Path, command: str) -> str:
|
|
|
496
496
|
|
|
497
497
|
return output
|
|
498
498
|
except subprocess.TimeoutExpired:
|
|
499
|
-
return json.dumps({"stdout": "", "stderr": "Command timed out after
|
|
499
|
+
return json.dumps({"stdout": "", "stderr": "Command timed out after 120s", "exit_code": -1})
|
|
500
500
|
|
|
501
501
|
|
|
502
502
|
def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int = None) -> str:
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests verifying Phase 1 stability fixes:
|
|
3
|
+
- BUG-01: executor.py dead code removal
|
|
4
|
+
- BUG-02: cli.py duplicate command handler removal
|
|
5
|
+
- BUG-04: orchestrator.py verification nudge state leak prevention
|
|
6
|
+
- BUG-06: orchestrator.py backend immutability / thread safety
|
|
7
|
+
- BUG-09: tools.py bash timeout error message alignment
|
|
8
|
+
- BUG-11: compaction.py tool-call pairing integrity
|
|
9
|
+
"""
|
|
10
|
+
import ast
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
import json
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from codeagent.executor import ToolExecutor, ToolCall, ToolResult
|
|
17
|
+
from codeagent.compaction import compact_messages, KEEP_RECENT_TURNS
|
|
18
|
+
from codeagent.orchestrator import AgentOrchestrator
|
|
19
|
+
from codeagent.session import SessionManager
|
|
20
|
+
from codeagent.config import Config
|
|
21
|
+
from codeagent.backends.base import ChatResponse
|
|
22
|
+
from codeagent.backends.openai_backend import OpenAIBackend
|
|
23
|
+
from codeagent.backends.local_backend import LocalBackend
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_bug01_executor_dead_code_removed():
|
|
27
|
+
"""Verify executor.py no longer contains the unreachable sequential execution block."""
|
|
28
|
+
executor_path = Path(__file__).resolve().parent.parent / "src" / "codeagent" / "executor.py"
|
|
29
|
+
content = executor_path.read_text(encoding="utf-8")
|
|
30
|
+
assert "# Sequential execution" not in content
|
|
31
|
+
assert content.count("return results") == 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_bug01_executor_execution():
|
|
35
|
+
"""Verify ToolExecutor correctly executes tool calls via the DAG pipeline."""
|
|
36
|
+
workdir = Path("/tmp")
|
|
37
|
+
executor = ToolExecutor(workdir)
|
|
38
|
+
|
|
39
|
+
def dummy_tool(args):
|
|
40
|
+
return f"result: {args.get('x', 0)}"
|
|
41
|
+
|
|
42
|
+
executor.dispatch["dummy_tool"] = dummy_tool
|
|
43
|
+
|
|
44
|
+
calls = [
|
|
45
|
+
ToolCall(id="c1", name="dummy_tool", arguments={"x": 42}),
|
|
46
|
+
ToolCall(id="c2", name="dummy_tool", arguments={"x": 99}),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
results = executor.execute_tool_calls(calls)
|
|
50
|
+
assert len(results) == 2
|
|
51
|
+
assert results[0].tool_call_id == "c1"
|
|
52
|
+
assert "result: 42" in results[0].content
|
|
53
|
+
assert results[1].tool_call_id == "c2"
|
|
54
|
+
assert "result: 99" in results[1].content
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_bug02_cli_no_duplicate_handlers():
|
|
58
|
+
"""Verify cli.py has exactly one diff and one doctor handler."""
|
|
59
|
+
cli_path = Path(__file__).resolve().parent.parent / "src" / "codeagent" / "cli.py"
|
|
60
|
+
content = cli_path.read_text(encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
assert content.count('args.command == "diff"') == 1
|
|
63
|
+
assert content.count('args.command == "doctor"') == 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_bug04_and_06_orchestrator_state_isolation(tmp_path):
|
|
67
|
+
"""Verify orchestrator does not leak verification_nudge on self and does not mutate self.backend."""
|
|
68
|
+
mock_backend = MagicMock(spec=["name", "is_available", "chat_completion"])
|
|
69
|
+
mock_backend.name = "MockOpenAI"
|
|
70
|
+
mock_backend.is_available = True
|
|
71
|
+
mock_backend.chat_completion.return_value = ChatResponse(
|
|
72
|
+
content="DONE: Task is finished",
|
|
73
|
+
reasoning=None,
|
|
74
|
+
tool_calls=[],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
orchestrator = AgentOrchestrator(backend=mock_backend)
|
|
78
|
+
session_mgr = SessionManager()
|
|
79
|
+
session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
|
|
80
|
+
cfg = Config()
|
|
81
|
+
|
|
82
|
+
# Before run
|
|
83
|
+
assert not hasattr(orchestrator, "_verification_nudge")
|
|
84
|
+
original_backend = orchestrator.backend
|
|
85
|
+
|
|
86
|
+
events = list(orchestrator.run_task(session=session, task="Simple test task", cfg=cfg))
|
|
87
|
+
|
|
88
|
+
# After run: verify _verification_nudge is NOT set on orchestrator instance
|
|
89
|
+
assert not hasattr(orchestrator, "_verification_nudge")
|
|
90
|
+
# Verify backend on orchestrator was NOT mutated
|
|
91
|
+
assert orchestrator.backend is original_backend
|
|
92
|
+
|
|
93
|
+
done_events = [e for e in events if e.type == "done"]
|
|
94
|
+
assert len(done_events) == 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_bug06_fallback_preserves_orchestrator_backend(tmp_path):
|
|
98
|
+
"""Verify that when online backend fails and falls back to local, self.backend is NOT mutated."""
|
|
99
|
+
mock_openai = MagicMock(spec=OpenAIBackend)
|
|
100
|
+
mock_openai.name = "OpenAI"
|
|
101
|
+
mock_openai.is_available = True
|
|
102
|
+
mock_openai.chat_completion.side_effect = ConnectionError("Online service unreachable")
|
|
103
|
+
# Disallow stream so it hits chat_completion directly
|
|
104
|
+
del mock_openai.chat_completion_stream
|
|
105
|
+
|
|
106
|
+
orchestrator = AgentOrchestrator(backend=mock_openai)
|
|
107
|
+
session_mgr = SessionManager()
|
|
108
|
+
session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
|
|
109
|
+
cfg = Config()
|
|
110
|
+
|
|
111
|
+
fallback_resp = ChatResponse(
|
|
112
|
+
content="DONE: Handled by local fallback",
|
|
113
|
+
reasoning=None,
|
|
114
|
+
tool_calls=[],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with patch.object(LocalBackend, "__init__", return_value=None), \
|
|
118
|
+
patch.object(LocalBackend, "chat_completion", return_value=fallback_resp):
|
|
119
|
+
events = list(orchestrator.run_task(session=session, task="Fallback test", cfg=cfg))
|
|
120
|
+
|
|
121
|
+
event_types = [e.type for e in events]
|
|
122
|
+
assert "fallback" in event_types
|
|
123
|
+
assert "done" in event_types
|
|
124
|
+
|
|
125
|
+
# Critical check: orchestrator.backend must still be the original OpenAI backend!
|
|
126
|
+
assert orchestrator.backend is mock_openai
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_orchestrator_streaming(tmp_path):
|
|
130
|
+
"""Verify orchestrator handles streaming backends properly."""
|
|
131
|
+
mock_backend = MagicMock(spec=["name", "is_available", "chat_completion", "chat_completion_stream"])
|
|
132
|
+
mock_backend.name = "MockStreamBackend"
|
|
133
|
+
mock_backend.is_available = True
|
|
134
|
+
|
|
135
|
+
chat_resp = ChatResponse(
|
|
136
|
+
content="DONE: Stream completed",
|
|
137
|
+
reasoning="thinking about stream",
|
|
138
|
+
tool_calls=[],
|
|
139
|
+
)
|
|
140
|
+
mock_backend.chat_completion_stream.return_value = iter([
|
|
141
|
+
("token", {"delta": "DONE: "}),
|
|
142
|
+
("token", {"delta": "Stream completed"}),
|
|
143
|
+
("done", chat_resp),
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
orchestrator = AgentOrchestrator(backend=mock_backend)
|
|
147
|
+
session_mgr = SessionManager()
|
|
148
|
+
session = session_mgr.create_session(workdir=tmp_path, max_tokens=8192)
|
|
149
|
+
cfg = Config()
|
|
150
|
+
|
|
151
|
+
events = list(orchestrator.run_task(session=session, task="Streaming test", cfg=cfg))
|
|
152
|
+
event_types = [e.type for e in events]
|
|
153
|
+
assert "token" in event_types
|
|
154
|
+
assert "done" in event_types
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_bug09_bash_timeout_message(tmp_path):
|
|
158
|
+
"""Verify tools.py _bash returns 120s timeout in error message."""
|
|
159
|
+
import subprocess
|
|
160
|
+
from codeagent.tools import _bash
|
|
161
|
+
|
|
162
|
+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="sleep 200", timeout=120)):
|
|
163
|
+
res = _bash(tmp_path, "sleep 200")
|
|
164
|
+
data = json.loads(res)
|
|
165
|
+
assert data["exit_code"] == -1
|
|
166
|
+
assert "120s" in data["stderr"]
|
|
167
|
+
assert "300s" not in data["stderr"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_bug11_compaction_tool_pairing_guard():
|
|
171
|
+
"""Verify compact_messages does not split an assistant tool-call from its tool results."""
|
|
172
|
+
# Build a message list where KEEP_RECENT_TURNS would land right on a tool message
|
|
173
|
+
messages = [
|
|
174
|
+
{"role": "system", "content": "You are AlpieCode."},
|
|
175
|
+
{"role": "user", "content": "Initial prompt"},
|
|
176
|
+
{"role": "assistant", "content": "Thinking about step 1..."},
|
|
177
|
+
{"role": "user", "content": "Continue"},
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# Add several turns of assistant + tool calls
|
|
181
|
+
for i in range(10):
|
|
182
|
+
call_id = f"call_{i}"
|
|
183
|
+
messages.append({
|
|
184
|
+
"role": "assistant",
|
|
185
|
+
"content": f"Running tool {i}",
|
|
186
|
+
"tool_calls": [{"id": call_id, "function": {"name": "read_file", "arguments": "{}"}}]
|
|
187
|
+
})
|
|
188
|
+
messages.append({
|
|
189
|
+
"role": "tool",
|
|
190
|
+
"tool_call_id": call_id,
|
|
191
|
+
"content": f"file content {i}"
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
compacted = compact_messages(messages)
|
|
195
|
+
assert len(compacted) > 0
|
|
196
|
+
assert compacted[0]["role"] == "system"
|
|
197
|
+
|
|
198
|
+
# In compacted messages, every 'tool' message must be immediately preceded by
|
|
199
|
+
# an 'assistant' message that contains its tool_call_id or another 'tool' from the same assistant
|
|
200
|
+
for idx, msg in enumerate(compacted):
|
|
201
|
+
if msg.get("role") == "tool":
|
|
202
|
+
prev_msg = compacted[idx - 1]
|
|
203
|
+
assert prev_msg.get("role") in ("assistant", "tool"), (
|
|
204
|
+
f"Tool message at index {idx} was preceded by role '{prev_msg.get('role')}'"
|
|
205
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|