alpiecode 10.6.4__tar.gz → 10.6.7__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.
Files changed (58) hide show
  1. {alpiecode-10.6.4 → alpiecode-10.6.7}/PKG-INFO +1 -1
  2. {alpiecode-10.6.4 → alpiecode-10.6.7}/pyproject.toml +1 -1
  3. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/SOURCES.txt +1 -0
  5. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/agent.py +12 -6
  7. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/backends/local_backend.py +243 -9
  8. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/backends/openai_backend.py +53 -6
  9. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/cli.py +3 -0
  10. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/config.py +1 -0
  11. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/orchestrator.py +8 -3
  12. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_local_model_download.py +97 -0
  13. alpiecode-10.6.7/test/test_non_thinking_and_local_stream.py +70 -0
  14. {alpiecode-10.6.4 → alpiecode-10.6.7}/README.md +0 -0
  15. {alpiecode-10.6.4 → alpiecode-10.6.7}/setup.cfg +0 -0
  16. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode/__init__.py +0 -0
  17. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  18. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/entry_points.txt +0 -0
  19. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/requires.txt +0 -0
  20. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/alpiecode.egg-info/top_level.txt +0 -0
  21. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/assessment.py +0 -0
  22. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/backends/__init__.py +0 -0
  23. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/backends/base.py +0 -0
  24. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/cache.py +0 -0
  25. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/client.py +0 -0
  26. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/compaction.py +0 -0
  27. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/context.py +0 -0
  28. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/discovery.py +0 -0
  29. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/doctor.py +0 -0
  30. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/dream_rsi.py +0 -0
  31. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/executor.py +0 -0
  32. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/extension/alpiecode.vsix +0 -0
  33. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/git_ops.py +0 -0
  34. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/github.py +0 -0
  35. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/guardian.py +0 -0
  36. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/guardrails.py +0 -0
  37. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/ipython_ext.py +0 -0
  38. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/local_model.py +0 -0
  39. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/media.py +0 -0
  40. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/memory.py +0 -0
  41. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/metrics.py +0 -0
  42. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/paths.py +0 -0
  43. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/progress.py +0 -0
  44. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/prompt.py +0 -0
  45. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/rephraser.py +0 -0
  46. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/server.py +0 -0
  47. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/session.py +0 -0
  48. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/system1.py +0 -0
  49. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/tools.py +0 -0
  50. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/updater.py +0 -0
  51. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/verifier.py +0 -0
  52. {alpiecode-10.6.4 → alpiecode-10.6.7}/src/codeagent/vscode_installer.py +0 -0
  53. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_adaptive_resilience.py +0 -0
  54. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_cli_ui.py +0 -0
  55. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_context_and_trajectory.py +0 -0
  56. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_dream_rsi.py +0 -0
  57. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_task_assessment.py +0 -0
  58. {alpiecode-10.6.4 → alpiecode-10.6.7}/test/test_verifier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 10.6.4
3
+ Version: 10.6.7
4
4
  Summary: AlpieCode
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "10.6.4"
7
+ version = "10.6.7"
8
8
  description = "AlpieCode"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 10.6.4
3
+ Version: 10.6.7
4
4
  Summary: AlpieCode
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -51,5 +51,6 @@ test/test_cli_ui.py
51
51
  test/test_context_and_trajectory.py
52
52
  test/test_dream_rsi.py
53
53
  test/test_local_model_download.py
54
+ test/test_non_thinking_and_local_stream.py
54
55
  test/test_task_assessment.py
55
56
  test/test_verifier.py
@@ -17,7 +17,7 @@ Supports:
17
17
  from pathlib import Path
18
18
  from typing import Optional, Any, Dict
19
19
 
20
- __version__ = "10.6.4"
20
+ __version__ = "10.6.7"
21
21
 
22
22
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
23
23
  """Run an autonomous coding task programmatically."""
@@ -376,12 +376,16 @@ def run_agent(
376
376
  else:
377
377
  status_spinner.update("Reasoning through implementation...", style="bold magenta")
378
378
 
379
- elif event.type == "thinking_delta" and verbose and debug:
380
- status_spinner.pause()
379
+ elif event.type == "thinking_delta" and verbose:
381
380
  delta = event.data.get("delta", "")
382
- if delta:
383
- sys.stdout.write(delta)
384
- sys.stdout.flush()
381
+ count = event.data.get("count", 0)
382
+ if debug:
383
+ if delta:
384
+ status_spinner.pause()
385
+ sys.stdout.write(delta)
386
+ sys.stdout.flush()
387
+ elif count > 0 and count % 15 == 0:
388
+ status_spinner.update(f"Reasoning through implementation ({count} tokens)...", style="bold magenta")
385
389
 
386
390
  elif event.type == "thinking_end" and verbose:
387
391
  duration = event.data.get("duration", 0)
@@ -504,7 +508,9 @@ def run_agent(
504
508
  else:
505
509
  print(f"── Step {current_turn} ──")
506
510
  if current_turn > 0:
507
- status_spinner.resume(f"Step {current_turn}: Thinking...", style="bold cyan")
511
+ is_turn_thinking = event.data.get("thinking", True)
512
+ turn_label = "Thinking..." if is_turn_thinking else "Working..."
513
+ status_spinner.resume(f"Step {current_turn}: {turn_label}", style="bold cyan")
508
514
 
509
515
  elif event.type == "compaction" and verbose:
510
516
  status_spinner.pause()
@@ -1,11 +1,14 @@
1
1
  import os
2
2
  import shutil
3
+ import subprocess
3
4
  import sys
4
- from typing import Any, List, Optional
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional, Tuple
5
8
 
6
9
  from ..config import Config
7
10
  from ..local_model import LocalModel
8
- from ..paths import is_android, is_ios
11
+ from ..paths import ensure_dir, get_alpiecode_dir, is_android, is_ios
9
12
  from .base import ChatResponse, ToolCall
10
13
 
11
14
 
@@ -425,7 +428,7 @@ class iOSBackend(BasePlatformBackend):
425
428
  import llama_cpp
426
429
  return True
427
430
  except ImportError:
428
- return self._is_bridge_online()
431
+ return self._is_bridge_online() or self._is_wasm_available()
429
432
 
430
433
  def _get_bridge_url(self) -> str:
431
434
  return os.environ.get("IOS_LLAMA_BRIDGE", "http://127.0.0.1:8080").rstrip("/")
@@ -438,6 +441,224 @@ class iOSBackend(BasePlatformBackend):
438
441
  except Exception:
439
442
  return False
440
443
 
444
+ def _get_wasm_binary_path(self) -> Optional[Path]:
445
+ """Locate the llama.wasm binary for a-Shell / WASI execution."""
446
+ custom = os.environ.get("ALPIECODE_WASM_PATH") or os.environ.get("LLAMA_WASM_PATH")
447
+ if custom and Path(custom).exists():
448
+ return Path(custom)
449
+ candidates = [
450
+ get_alpiecode_dir() / "bin" / "llama.wasm",
451
+ get_alpiecode_dir() / "bin" / "llama-cli.wasm",
452
+ Path.home() / "Documents" / ".alpiecode" / "bin" / "llama.wasm",
453
+ Path.home() / ".alpiecode" / "bin" / "llama.wasm",
454
+ Path(sys.prefix) / "bin" / "llama.wasm",
455
+ Path.cwd() / "llama.wasm",
456
+ Path.cwd() / ".alpiecode" / "bin" / "llama.wasm",
457
+ ]
458
+ for c in candidates:
459
+ if c.is_file():
460
+ return c
461
+ w_bin = shutil.which("llama.wasm") or shutil.which("llama-cli.wasm")
462
+ if w_bin:
463
+ return Path(w_bin)
464
+ return None
465
+
466
+ def _get_model_path_safe(self) -> Path:
467
+ """Resolve model path whether _ensure_model returns LocalModel or Path."""
468
+ model_obj = self._ensure_model()
469
+ if hasattr(model_obj, "ensure_model"):
470
+ return model_obj.ensure_model()
471
+ if hasattr(model_obj, "model_path") and model_obj.model_path:
472
+ return Path(model_obj.model_path)
473
+ return Path(model_obj)
474
+
475
+ def _is_wasm_available(self) -> bool:
476
+ """Check if WASM runner (wasm command or executable .wasm) is available in a-Shell."""
477
+ has_wasm_cmd = shutil.which("wasm") is not None
478
+ wasm_bin = self._get_wasm_binary_path()
479
+ return bool(has_wasm_cmd or wasm_bin)
480
+
481
+ def _ensure_wasm_runner(self) -> Tuple[str, Path]:
482
+ """Verify WASM binary and return (wasm_command, wasm_binary_path)."""
483
+ wasm_bin = self._get_wasm_binary_path()
484
+ bin_dir = ensure_dir(get_alpiecode_dir() / "bin")
485
+ default_wasm = bin_dir / "llama.wasm"
486
+
487
+ if not wasm_bin:
488
+ # Attempt automatic download from HuggingFace so user doesn't have to manually transfer files
489
+ try:
490
+ from huggingface_hub import hf_hub_download
491
+ repo = getattr(self._cfg, "model_repo", "169Pi/alpie_learn_gguf")
492
+ downloaded = hf_hub_download(
493
+ repo_id=repo,
494
+ filename="llama.wasm",
495
+ local_dir=str(bin_dir),
496
+ )
497
+ if downloaded and Path(downloaded).exists():
498
+ wasm_bin = Path(downloaded)
499
+ except Exception:
500
+ pass
501
+
502
+ if not wasm_bin:
503
+ raise RuntimeError(
504
+ f"\n╭────────────────────────────────────────────────────────────╮\n"
505
+ f"│ a-Shell WebAssembly Inference: │\n"
506
+ f"│ llama.wasm binary not found. │\n"
507
+ f"│ │\n"
508
+ f"│ To enable offline inference in a-Shell: │\n"
509
+ f"│ 1. Place 'llama.wasm' into: │\n"
510
+ f"│ {str(default_wasm)[:55]:<55}│\n"
511
+ f"│ 2. Or set: export ALPIECODE_WASM_PATH=/path/to/llama.wasm │\n"
512
+ f"╰────────────────────────────────────────────────────────────╯"
513
+ )
514
+
515
+ runner = shutil.which("wasm") or "wasm"
516
+ return runner, wasm_bin
517
+
518
+ def _format_messages_to_prompt(self, messages: List[dict]) -> str:
519
+ """Format OpenAI-style message dictionary list into ChatML prompt string."""
520
+ sanitized = self._sanitize_messages_for_gguf(messages)
521
+ parts = []
522
+ for msg in sanitized:
523
+ role = msg.get("role", "user")
524
+ content = msg.get("content", "")
525
+ parts.append(f"<|im_start|>{role}\n{content}<|im_end|>")
526
+ parts.append("<|im_start|>assistant\n")
527
+ return "\n".join(parts)
528
+
529
+ def _wasm_chat_completion(
530
+ self,
531
+ messages: List[dict],
532
+ tools: Optional[List[dict]] = None,
533
+ temperature: float = 0.2,
534
+ max_tokens: int = 2048,
535
+ enable_thinking: bool = True,
536
+ ) -> ChatResponse:
537
+ runner, wasm_path = self._ensure_wasm_runner()
538
+ model_path = self._get_model_path_safe()
539
+ prompt = self._format_messages_to_prompt(messages)
540
+
541
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".txt") as tf:
542
+ tf.write(prompt)
543
+ prompt_file = tf.name
544
+
545
+ try:
546
+ n_ctx = str(getattr(self._cfg, "n_ctx", 4096))
547
+ base_args = [
548
+ "-m", str(model_path),
549
+ "-f", prompt_file,
550
+ "-n", str(max_tokens),
551
+ "--temp", str(temperature),
552
+ "-c", n_ctx,
553
+ "--no-mmap",
554
+ "--simple-io",
555
+ "--no-display-prompt",
556
+ "-t", "4",
557
+ ]
558
+ if shutil.which(runner):
559
+ cmd = [runner, str(wasm_path)] + base_args
560
+ else:
561
+ cmd = [str(wasm_path)] + base_args
562
+
563
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
564
+ if res.returncode != 0:
565
+ err = res.stderr.strip() or res.stdout.strip()
566
+ if "memory" in err.lower() or "bounds" in err.lower() or "alloc" in err.lower():
567
+ raise RuntimeError(
568
+ f"\n❌ WASM Memory Allocation Failed for '{model_path.name}'.\n"
569
+ f" The model size ({model_path.stat().st_size / (1024**3):.2f} GB) exceeded the 32-bit WASM limit (~2-4 GB).\n"
570
+ f" Details: {err[:200]}"
571
+ )
572
+ raise RuntimeError(f"WASM llama execution failed (code {res.returncode}): {err[:250]}")
573
+
574
+ raw_output = res.stdout.strip()
575
+ content = raw_output
576
+ reasoning = None
577
+ if "<think>" in raw_output and "</think>" in raw_output:
578
+ p1, p2 = raw_output.split("</think>", 1)
579
+ reasoning = p1.replace("<think>", "").strip()
580
+ content = p2.strip()
581
+ elif "<think>" in raw_output:
582
+ content = raw_output.replace("<think>", "").strip()
583
+
584
+ return ChatResponse(content=content, reasoning=reasoning, tool_calls=None, raw={"output": raw_output})
585
+ finally:
586
+ try:
587
+ os.remove(prompt_file)
588
+ except Exception:
589
+ pass
590
+
591
+ def _wasm_chat_completion_stream(
592
+ self,
593
+ messages: List[dict],
594
+ tools: Optional[List[dict]] = None,
595
+ temperature: Optional[float] = None,
596
+ max_tokens: Optional[int] = None,
597
+ enable_thinking: bool = True,
598
+ ):
599
+ runner, wasm_path = self._ensure_wasm_runner()
600
+ model_path = self._get_model_path_safe()
601
+ prompt = self._format_messages_to_prompt(messages)
602
+
603
+ with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".txt") as tf:
604
+ tf.write(prompt)
605
+ prompt_file = tf.name
606
+
607
+ proc = None
608
+ try:
609
+ n_ctx = str(getattr(self._cfg, "n_ctx", 4096))
610
+ t_val = str(temperature if temperature is not None else 0.2)
611
+ m_val = str(max_tokens if max_tokens is not None else 2048)
612
+
613
+ base_args = [
614
+ "-m", str(model_path),
615
+ "-f", prompt_file,
616
+ "-n", m_val,
617
+ "--temp", t_val,
618
+ "-c", n_ctx,
619
+ "--no-mmap",
620
+ "--simple-io",
621
+ "--no-display-prompt",
622
+ "-t", "4",
623
+ ]
624
+ if shutil.which(runner):
625
+ cmd = [runner, str(wasm_path)] + base_args
626
+ else:
627
+ cmd = [str(wasm_path)] + base_args
628
+
629
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
630
+ full_content = []
631
+ for line in proc.stdout:
632
+ if line:
633
+ full_content.append(line)
634
+ yield ("token", {"delta": line})
635
+ proc.wait()
636
+ if proc.returncode != 0:
637
+ err = proc.stderr.read().strip() if proc.stderr else ""
638
+ if "memory" in err.lower() or "bounds" in err.lower():
639
+ raise RuntimeError(
640
+ f"\n❌ WASM Memory Allocation Failed for '{model_path.name}'.\n"
641
+ f" Details: {err[:200]}"
642
+ )
643
+ if not full_content:
644
+ raise RuntimeError(f"WASM execution exited with code {proc.returncode}: {err[:200]}")
645
+
646
+ yield ("done", ChatResponse(
647
+ content="".join(full_content).strip(),
648
+ reasoning=None,
649
+ tool_calls=None,
650
+ ))
651
+ finally:
652
+ if proc and proc.poll() is None:
653
+ try:
654
+ proc.kill()
655
+ except Exception:
656
+ pass
657
+ try:
658
+ os.remove(prompt_file)
659
+ except Exception:
660
+ pass
661
+
441
662
  def chat_completion(
442
663
  self,
443
664
  messages: List[dict],
@@ -455,12 +676,18 @@ class iOSBackend(BasePlatformBackend):
455
676
  if self._is_bridge_online():
456
677
  return self._bridge_chat_completion(messages, tools, temperature, max_tokens, enable_thinking)
457
678
 
679
+ if self._is_wasm_available():
680
+ return self._wasm_chat_completion(messages, tools, temperature, max_tokens, enable_thinking)
681
+
458
682
  raise RuntimeError(
459
683
  "\n╭────────────────────────────────────────────────────────────╮\n"
460
- "│ iOS Native Inference: │\n"
684
+ "│ iOS Native Inference (a-Shell / Container): │\n"
461
685
  "│ Dynamic compilation via pip is disabled on iOS. │\n"
462
- "│ llama.cpp must be pre-bundled with the iOS app container │\n"
463
- "│ or served via a local bridge (e.g. http://127.0.0.1:8080). │\n"
686
+ "│ │\n"
687
+ "│ Choose one of the following to run offline on iOS: │\n"
688
+ "│ 1. a-Shell WASM: Place llama.wasm into ~/.alpiecode/bin/ │\n"
689
+ "│ 2. Local Bridge: Run local server on 127.0.0.1:8080 │\n"
690
+ "│ 3. Pre-bundled: llama.cpp inside iOS app container │\n"
464
691
  "╰────────────────────────────────────────────────────────────╯"
465
692
  )
466
693
 
@@ -527,12 +754,19 @@ class iOSBackend(BasePlatformBackend):
527
754
  yield from self._bridge_chat_completion_stream(messages, tools, temperature, max_tokens, enable_thinking)
528
755
  return
529
756
 
757
+ if self._is_wasm_available():
758
+ yield from self._wasm_chat_completion_stream(messages, tools, temperature, max_tokens, enable_thinking)
759
+ return
760
+
530
761
  raise RuntimeError(
531
762
  "\n╭────────────────────────────────────────────────────────────╮\n"
532
- "│ iOS Native Inference: │\n"
763
+ "│ iOS Native Inference (a-Shell / Container): │\n"
533
764
  "│ Dynamic compilation via pip is disabled on iOS. │\n"
534
- "│ llama.cpp must be pre-bundled with the iOS app container │\n"
535
- "│ or served via a local bridge (e.g. http://127.0.0.1:8080). │\n"
765
+ "│ │\n"
766
+ "│ Choose one of the following to run offline on iOS: │\n"
767
+ "│ 1. a-Shell WASM: Place llama.wasm into ~/.alpiecode/bin/ │\n"
768
+ "│ 2. Local Bridge: Run local server on 127.0.0.1:8080 │\n"
769
+ "│ 3. Pre-bundled: llama.cpp inside iOS app container │\n"
536
770
  "╰────────────────────────────────────────────────────────────╯"
537
771
  )
538
772
 
@@ -175,7 +175,16 @@ class _HTTPXClient:
175
175
  def __init__(self, base_url: str, api_key: str, http_client: Any = None):
176
176
  self.base_url = base_url
177
177
  self.api_key = api_key or "not-needed"
178
- self.http_client = http_client if http_client is not None else get_shared_http_client()
178
+ if http_client is not None:
179
+ self.http_client = http_client
180
+ elif "127.0.0.1" in base_url or "localhost" in base_url:
181
+ self.http_client = httpx.Client(
182
+ http2=False,
183
+ timeout=httpx.Timeout(180.0, connect=5.0, read=180.0, write=30.0),
184
+ limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
185
+ )
186
+ else:
187
+ self.http_client = get_shared_http_client()
179
188
  self.chat = _Namespace(completions=_HTTPXChatCompletions(self))
180
189
  self.models = _HTTPXModels(self)
181
190
 
@@ -397,6 +406,17 @@ class OpenAIBackend:
397
406
  except ValueError:
398
407
  pass
399
408
 
409
+ # For local endpoints (e.g. llama-server on Android), instruct model to skip <think> in fast mode
410
+ if not enable_thinking and ("127.0.0.1" in getattr(self._cfg, "base_url", "") or "localhost" in getattr(self._cfg, "base_url", "")):
411
+ clean_messages = []
412
+ for m in messages:
413
+ clean_messages.append(dict(m))
414
+ if clean_messages and clean_messages[-1].get("role") == "user":
415
+ u_text = str(clean_messages[-1].get("content", ""))
416
+ if "[Direct Mode:" not in u_text:
417
+ clean_messages[-1]["content"] = u_text + "\n\n[Direct Mode: Output code and tool calls directly without <think> tags. Do not deliberate.]"
418
+ messages = clean_messages
419
+
400
420
  params = {
401
421
  "model": model_name,
402
422
  "messages": messages,
@@ -419,6 +439,8 @@ class OpenAIBackend:
419
439
  response = client.chat.completions.create(**params)
420
440
 
421
441
  in_think = enable_thinking
442
+ suppress_think = not enable_thinking
443
+ inside_suppressed_think = False
422
444
  think_started = False
423
445
  full_content = []
424
446
  full_reasoning = []
@@ -472,16 +494,38 @@ class OpenAIBackend:
472
494
 
473
495
  # Explicit reasoning field from some models
474
496
  explicit_reasoning = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)
475
- if explicit_reasoning:
497
+ if explicit_reasoning and enable_thinking:
476
498
  if not think_started:
477
499
  think_started = True
478
500
  yield ("thinking_start", {"time": start_time})
479
501
  full_reasoning.append(explicit_reasoning)
480
- yield ("thinking_delta", {"delta": explicit_reasoning})
502
+ yield ("thinking_delta", {"delta": explicit_reasoning, "count": len(full_reasoning)})
481
503
 
482
504
  # Content deltas (contains <think>...</think> on vLLM/Qwen models)
483
505
  if delta.content:
484
506
  text = delta.content
507
+ if suppress_think:
508
+ # In non-thinking mode, swallow any think tags/blocks emitted by local models
509
+ if "<think>" in text:
510
+ inside_suppressed_think = True
511
+ text = text.replace("<think>", "")
512
+ if inside_suppressed_think:
513
+ if "<tool_call>" in text or "DONE:" in text or "<function=" in text:
514
+ inside_suppressed_think = False
515
+ elif "</think>" in text:
516
+ _, after = text.split("</think>", 1)
517
+ inside_suppressed_think = False
518
+ text = after
519
+ else:
520
+ text = ""
521
+ else:
522
+ text = text.replace("</think>", "")
523
+ if not text:
524
+ continue
525
+ full_content.append(text)
526
+ yield ("token", {"delta": text})
527
+ continue
528
+
485
529
  if in_think:
486
530
  if not think_started:
487
531
  think_started = True
@@ -491,7 +535,7 @@ class OpenAIBackend:
491
535
  before, after = text.split("</think>", 1)
492
536
  if before:
493
537
  full_reasoning.append(before)
494
- yield ("thinking_delta", {"delta": before})
538
+ yield ("thinking_delta", {"delta": before, "count": len(full_reasoning)})
495
539
  in_think = False
496
540
  elapsed = time.time() - start_time
497
541
  yield ("thinking_end", {"duration": round(elapsed, 1), "content": "".join(full_reasoning).strip()})
@@ -503,7 +547,7 @@ class OpenAIBackend:
503
547
  before, after = text.split("DONE:", 1)
504
548
  if before:
505
549
  full_reasoning.append(before)
506
- yield ("thinking_delta", {"delta": before})
550
+ yield ("thinking_delta", {"delta": before, "count": len(full_reasoning)})
507
551
  in_think = False
508
552
  elapsed = time.time() - start_time
509
553
  yield ("thinking_end", {"duration": round(elapsed, 1), "content": "".join(full_reasoning).strip()})
@@ -513,7 +557,7 @@ class OpenAIBackend:
513
557
  else:
514
558
  clean_text = text.replace("<think>", "")
515
559
  full_reasoning.append(clean_text)
516
- yield ("thinking_delta", {"delta": clean_text})
560
+ yield ("thinking_delta", {"delta": clean_text, "count": len(full_reasoning)})
517
561
  else:
518
562
  full_content.append(text)
519
563
  yield ("token", {"delta": text})
@@ -664,6 +708,9 @@ class OpenAIBackend:
664
708
  reasoning = re.sub(r"(?i)^thinking process:\s*", "", content).strip()
665
709
  content = ""
666
710
 
711
+ if not enable_thinking:
712
+ reasoning = None
713
+
667
714
  return ChatResponse(
668
715
  content=content,
669
716
  reasoning=reasoning,
@@ -274,10 +274,13 @@ def main():
274
274
  if getattr(args, "force_thinking", False):
275
275
  cfg.force_thinking = True
276
276
  cfg.enable_thinking = True
277
+ cfg.force_no_thinking = False
277
278
  elif getattr(args, "thinking", False):
278
279
  cfg.enable_thinking = True
280
+ cfg.force_no_thinking = False
279
281
  elif getattr(args, "no_thinking", False):
280
282
  cfg.enable_thinking = False
283
+ cfg.force_no_thinking = True
281
284
 
282
285
  if args.command == "run":
283
286
  if args.max_turns:
@@ -152,6 +152,7 @@ class Config:
152
152
  enable_thinking: bool = False
153
153
  adaptive_thinking: bool = True
154
154
  force_thinking: bool = False
155
+ force_no_thinking: bool = False
155
156
  n_ctx: int = 32768
156
157
  n_gpu_layers: Optional[int] = None
157
158
 
@@ -327,7 +327,10 @@ class AgentOrchestrator:
327
327
 
328
328
  # ── Adaptive thinking (base setting) ──
329
329
  strict_format = getattr(getattr(task_context, "assessment", None), "requires_strict_format", False) or getattr(task_context, "requires_strict_format", False)
330
- if getattr(cfg, "force_thinking", False):
330
+ if getattr(cfg, "force_no_thinking", False):
331
+ base_enable_thinking = False
332
+ yield AgentEvent("adaptive_mode", {"message": "Non-thinking mode explicitly requested via --no-thinking; fast execution enabled.", "thinking": False})
333
+ elif getattr(cfg, "force_thinking", False):
331
334
  base_enable_thinking = True
332
335
  elif strict_format:
333
336
  base_enable_thinking = False
@@ -400,7 +403,9 @@ class AgentOrchestrator:
400
403
  )
401
404
 
402
405
  # ── Turn-Level Adaptive Reasoning Dynamics ──
403
- if getattr(cfg, "force_thinking", False) or not getattr(cfg, "adaptive_thinking", True):
406
+ if getattr(cfg, "force_no_thinking", False):
407
+ turn_enable_thinking = False
408
+ elif getattr(cfg, "force_thinking", False) or not getattr(cfg, "adaptive_thinking", True):
404
409
  turn_enable_thinking = base_enable_thinking
405
410
  elif strict_format:
406
411
  if prior_turn_had_error:
@@ -440,7 +445,7 @@ class AgentOrchestrator:
440
445
  yield AgentEvent("safety_ceiling", {"turn": turn, "ceiling": safety_ceiling})
441
446
  break
442
447
 
443
- yield AgentEvent("turn_start", {"turn": turn})
448
+ yield AgentEvent("turn_start", {"turn": turn, "thinking": turn_enable_thinking})
444
449
 
445
450
  # In offline mode, guide the model with current workspace progress
446
451
  if is_offline and turn >= 1:
@@ -286,3 +286,100 @@ def test_embedded_hf_token_used_when_no_env(monkeypatch):
286
286
  from codeagent.local_model import EMBEDDED_HF_TOKEN
287
287
  assert EMBEDDED_HF_TOKEN in captured_tokens
288
288
 
289
+
290
+ def test_ios_backend_wasm_dispatch(tmp_path, monkeypatch):
291
+ """When llama_cpp is absent and bridge is offline, iOSBackend dispatches to WASM runner."""
292
+ from codeagent.backends.local_backend import iOSBackend
293
+ cfg = load_config()
294
+ b = iOSBackend(cfg)
295
+
296
+ wasm_bin = tmp_path / "llama.wasm"
297
+ wasm_bin.touch()
298
+ fake_model = tmp_path / "alpie-vl-text-q3_k_m.gguf"
299
+ fake_model.touch()
300
+
301
+ monkeypatch.setenv("ALPIECODE_WASM_PATH", str(wasm_bin))
302
+
303
+ mock_run = MagicMock()
304
+ mock_run.returncode = 0
305
+ mock_run.stdout = "<think>Analyzing code...</think>\nHello from WASM offline!"
306
+ mock_run.stderr = ""
307
+
308
+ with patch.dict("sys.modules", {"llama_cpp": None}), \
309
+ patch.object(b, "_is_bridge_online", return_value=False), \
310
+ patch.object(b, "_ensure_model", return_value=fake_model), \
311
+ patch("shutil.which", return_value="/usr/bin/wasm"), \
312
+ patch("subprocess.run", return_value=mock_run) as run_spy:
313
+ res = b.chat_completion([{"role": "user", "content": "hello"}])
314
+ assert res.content == "Hello from WASM offline!"
315
+ assert res.reasoning == "Analyzing code..."
316
+
317
+ # Verify command arguments
318
+ assert run_spy.called
319
+ cmd = run_spy.call_args[0][0]
320
+ assert cmd[0] == "/usr/bin/wasm"
321
+ assert cmd[1] == str(wasm_bin)
322
+ assert "-m" in cmd
323
+ assert str(fake_model) in cmd
324
+ assert "-f" in cmd
325
+
326
+
327
+ def test_ios_backend_wasm_streaming(tmp_path, monkeypatch):
328
+ """iOSBackend streams tokens line-by-line via WASM runner."""
329
+ from codeagent.backends.local_backend import iOSBackend
330
+ cfg = load_config()
331
+ b = iOSBackend(cfg)
332
+
333
+ wasm_bin = tmp_path / "llama.wasm"
334
+ wasm_bin.touch()
335
+ fake_model = tmp_path / "alpie-vl-text-q3_k_m.gguf"
336
+ fake_model.touch()
337
+
338
+ monkeypatch.setenv("ALPIECODE_WASM_PATH", str(wasm_bin))
339
+
340
+ mock_proc = MagicMock()
341
+ mock_proc.stdout = ["Hello ", "from ", "stream!"]
342
+ mock_proc.returncode = 0
343
+ mock_proc.poll.return_value = 0
344
+
345
+ with patch.dict("sys.modules", {"llama_cpp": None}), \
346
+ patch.object(b, "_is_bridge_online", return_value=False), \
347
+ patch.object(b, "_ensure_model", return_value=fake_model), \
348
+ patch("shutil.which", return_value="/usr/bin/wasm"), \
349
+ patch("subprocess.Popen", return_value=mock_proc):
350
+ stream = list(b.chat_completion_stream([{"role": "user", "content": "hi"}]))
351
+ tokens = [payload["delta"] for tag, payload in stream if tag == "token"]
352
+ assert "".join(tokens) == "Hello from stream!"
353
+ assert stream[-1][0] == "done"
354
+ assert stream[-1][1].content == "Hello from stream!"
355
+
356
+
357
+ def test_ios_backend_wasm_memory_error(tmp_path, monkeypatch):
358
+ """WASM runtime memory allocation failure throws actionable diagnostic."""
359
+ from codeagent.backends.local_backend import iOSBackend
360
+ cfg = load_config()
361
+ b = iOSBackend(cfg)
362
+
363
+ wasm_bin = tmp_path / "llama.wasm"
364
+ wasm_bin.touch()
365
+ fake_model = tmp_path / "alpie-vl-text-q3_k_m.gguf"
366
+ fake_model.touch()
367
+
368
+ monkeypatch.setenv("ALPIECODE_WASM_PATH", str(wasm_bin))
369
+
370
+ mock_run = MagicMock()
371
+ mock_run.returncode = 1
372
+ mock_run.stdout = ""
373
+ mock_run.stderr = "RuntimeError: out of bounds memory access / memory allocation failed"
374
+
375
+ with patch.dict("sys.modules", {"llama_cpp": None}), \
376
+ patch.object(b, "_is_bridge_online", return_value=False), \
377
+ patch.object(b, "_ensure_model", return_value=fake_model), \
378
+ patch("shutil.which", return_value="/usr/bin/wasm"), \
379
+ patch("subprocess.run", return_value=mock_run):
380
+ with pytest.raises(RuntimeError) as exc_info:
381
+ b.chat_completion([{"role": "user", "content": "hello"}])
382
+ assert "WASM Memory Allocation Failed" in str(exc_info.value)
383
+ assert "32-bit WASM limit" in str(exc_info.value)
384
+
385
+
@@ -0,0 +1,70 @@
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch
3
+ from codeagent.config import Config
4
+ from codeagent.backends.openai_backend import _HTTPXClient, OpenAIBackend
5
+
6
+
7
+ def test_httpx_client_uses_http1_for_local_bridge():
8
+ """Verify that localhost/127.0.0.1 uses an unbuffered HTTP/1.1 client for llama-server."""
9
+ client_local = _HTTPXClient("http://127.0.0.1:8080/v1", "not-needed")
10
+ # http2 is disabled for local server
11
+ assert client_local.http_client._transport._pool._http2 is False or not getattr(client_local.http_client, "_http2", True)
12
+
13
+ client_remote = _HTTPXClient("https://test.169pi.ai/v1", "not-needed")
14
+ assert client_remote.http_client is not None
15
+
16
+
17
+ def test_force_no_thinking_flag_on_config():
18
+ """Verify that force_no_thinking flag defaults to False and can be set to True."""
19
+ cfg = Config()
20
+ assert cfg.force_no_thinking is False
21
+
22
+ cfg_no_think = Config(force_no_thinking=True)
23
+ assert cfg_no_think.force_no_thinking is True
24
+
25
+
26
+ def test_openai_backend_suppresses_think_in_non_thinking_mode():
27
+ """Verify that OpenAIBackend suppresses think tokens when enable_thinking=False."""
28
+ cfg = Config(base_url="http://127.0.0.1:8080/v1")
29
+ backend = OpenAIBackend(cfg)
30
+
31
+ # Mock response chunks containing a <think> block
32
+ class MockDelta:
33
+ def __init__(self, content=None, tool_calls=None):
34
+ self.content = content
35
+ self.tool_calls = tool_calls
36
+
37
+ class MockChoice:
38
+ def __init__(self, delta):
39
+ self.delta = delta
40
+
41
+ class MockChunk:
42
+ def __init__(self, content):
43
+ self.choices = [MockChoice(MockDelta(content=content))]
44
+
45
+ mock_stream = [
46
+ MockChunk("<think>"),
47
+ MockChunk("Reasoning about prime numbers..."),
48
+ MockChunk("</think>"),
49
+ MockChunk("def is_prime(n):"),
50
+ MockChunk(" return n > 1"),
51
+ ]
52
+
53
+ mock_client = MagicMock()
54
+ mock_client.chat.completions.create.return_value = mock_stream
55
+ backend._client = mock_client
56
+ backend._resolved_model = "alpie_9b"
57
+
58
+ events = list(backend.chat_completion_stream(
59
+ messages=[{"role": "user", "content": "Write a prime checker"}],
60
+ enable_thinking=False,
61
+ ))
62
+
63
+ # In non-thinking mode, thinking_start and thinking_delta must NOT be emitted
64
+ event_types = [e[0] for e in events]
65
+ assert "thinking_start" not in event_types
66
+ assert "thinking_delta" not in event_types
67
+
68
+ # Only real content tokens must be emitted
69
+ token_events = [e[1]["delta"] for e in events if e[0] == "token"]
70
+ assert "".join(token_events).strip() == "def is_prime(n): return n > 1"
File without changes
File without changes