localcode 0.3.0__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- localcode/__init__.py +1 -1
- localcode/agent/context.py +11 -4
- localcode/agent/loop.py +17 -2
- localcode/agent/prompts.py +1 -1
- localcode/agent/tool_orchestration.py +2 -1
- localcode/app.py +0 -393
- localcode/bootstrap.py +2 -572
- localcode/cache.py +5 -3
- localcode/checkpoint.py +18 -2
- localcode/composer.py +12 -29
- localcode/config.py +28 -18
- localcode/entrypoint.py +9 -54
- localcode/errors.py +3 -3
- localcode/headless_json.py +3 -0
- localcode/history.py +15 -0
- localcode/models.py +0 -13
- localcode/models_catalog.py +13 -0
- localcode/performance.py +14 -21
- localcode/runtime.py +70 -407
- localcode/runtime_diffusion.py +31 -2
- localcode/server_manager.py +3 -3
- localcode/skills.py +5 -0
- localcode/tui/app.py +12 -56
- localcode/tui/screens/chat.py +215 -49
- localcode/tui/screens/model_picker.py +3 -2
- localcode/tui/screens/setup.py +14 -42
- localcode/tui/styles/app.tcss +9 -23
- localcode/tui/widgets/chat_log.py +35 -8
- localcode/voice.py +4 -2
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/METADATA +1 -1
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/RECORD +35 -36
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/WHEEL +1 -1
- localcode/tool_parsing.py +0 -367
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/entry_points.txt +0 -0
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {localcode-0.3.0.dist-info → localcode-0.3.1.dist-info}/top_level.txt +0 -0
localcode/__init__.py
CHANGED
localcode/agent/context.py
CHANGED
|
@@ -412,7 +412,8 @@ def _redact_duplicate_reads(messages: list[dict]) -> list[dict]:
|
|
|
412
412
|
if not tc_id:
|
|
413
413
|
continue
|
|
414
414
|
try:
|
|
415
|
-
|
|
415
|
+
_raw = (tc.get("function") or {}).get("arguments") or "{}"
|
|
416
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
416
417
|
except Exception:
|
|
417
418
|
continue
|
|
418
419
|
path = args.get("path")
|
|
@@ -629,7 +630,8 @@ def _compact_history_summary(messages: list[dict]) -> str:
|
|
|
629
630
|
fn = tc.get("function") or {}
|
|
630
631
|
name = str(fn.get("name") or "").strip()
|
|
631
632
|
try:
|
|
632
|
-
|
|
633
|
+
_raw = fn.get("arguments") or "{}"
|
|
634
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
633
635
|
except Exception:
|
|
634
636
|
args = {}
|
|
635
637
|
path = args.get("path") or args.get("file_path")
|
|
@@ -769,8 +771,13 @@ def _compact_messages(messages: list[dict], out: "OutputManager") -> list[dict]:
|
|
|
769
771
|
fn = tc.get("function", {})
|
|
770
772
|
name = fn.get("name", "")
|
|
771
773
|
try:
|
|
772
|
-
|
|
773
|
-
|
|
774
|
+
# `arguments` may be a JSON string OR an already-decoded
|
|
775
|
+
# dict (some providers/parsers do that) — json.loads on a
|
|
776
|
+
# dict raises TypeError, which used to escape and kill the
|
|
777
|
+
# turn with E3102 during compaction.
|
|
778
|
+
_raw = fn.get("arguments", "{}")
|
|
779
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
780
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
774
781
|
args = {}
|
|
775
782
|
if name in ("write_file", "append_file", "edit_file"):
|
|
776
783
|
files_modified.add(args.get("path", "?"))
|
localcode/agent/loop.py
CHANGED
|
@@ -24,6 +24,7 @@ you hit them.
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
26
|
import json
|
|
27
|
+
import os
|
|
27
28
|
import re
|
|
28
29
|
import sys
|
|
29
30
|
import time
|
|
@@ -1328,8 +1329,12 @@ def run_agent_loop(
|
|
|
1328
1329
|
fn = tc.get("function", {})
|
|
1329
1330
|
tool_name = fn.get("name", "unknown")
|
|
1330
1331
|
try:
|
|
1331
|
-
|
|
1332
|
-
|
|
1332
|
+
_raw_args = fn.get("arguments", "{}")
|
|
1333
|
+
# `arguments` is normally a JSON string, but some providers/
|
|
1334
|
+
# parsers hand back an already-decoded dict — json.loads on a
|
|
1335
|
+
# dict raises TypeError, which used to escape and crash the loop.
|
|
1336
|
+
args = _raw_args if isinstance(_raw_args, dict) else json.loads(_raw_args)
|
|
1337
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
1333
1338
|
args = {}
|
|
1334
1339
|
out.print_info(f"Warning: malformed args for {tool_name}")
|
|
1335
1340
|
|
|
@@ -1337,6 +1342,16 @@ def run_agent_loop(
|
|
|
1337
1342
|
stage = _tool_stage_label(tool_name, args)
|
|
1338
1343
|
out.set_stage(stage)
|
|
1339
1344
|
idx = out.log_tool(tool_name, _summarize_args(args))
|
|
1345
|
+
# RL trace fidelity: the normal `tool_start` event carries only a
|
|
1346
|
+
# summarized + 200-char-truncated arg preview. When capturing
|
|
1347
|
+
# trajectories for fine-tuning we need the FULL arguments (e.g. the
|
|
1348
|
+
# whole write_file body). Env-gated so normal TUI/headless runs are
|
|
1349
|
+
# unaffected; dev/rl/collect sets LOCALCODE_TRACE_FULL_ARGS=1.
|
|
1350
|
+
if os.environ.get("LOCALCODE_TRACE_FULL_ARGS"):
|
|
1351
|
+
out._emit_event(
|
|
1352
|
+
"tool_call_full", index=str(idx),
|
|
1353
|
+
name=tool_name, arguments=args,
|
|
1354
|
+
)
|
|
1340
1355
|
|
|
1341
1356
|
# Safety: confirm destructive commands (honors current autonomy
|
|
1342
1357
|
# level so /permissions toggles take effect immediately).
|
localcode/agent/prompts.py
CHANGED
|
@@ -57,7 +57,7 @@ How to work:
|
|
|
57
57
|
- MATCH THE PROJECT'S LANGUAGE AND CONVENTIONS. Code MUST be valid for the file's actual language. Never write Python syntax (triple-quoted docstrings, snake_case, `import x`/`from x import y`) in a .ts/.tsx/.js/.jsx/.go/.rs or other non-Python file. Use that language's real comment, naming, and import/module syntax, and mirror the patterns already present in the project (imports, formatting, framework idioms).
|
|
58
58
|
- For new projects, create a small multi-file structure by default. Keep entrypoints thin; move reusable logic, styles, data/config, templates, and assets into focused files. Use one large file only if requested.
|
|
59
59
|
- Prefer edit_file for existing files. Use write_file when creating a new file or doing a deliberate full rewrite.
|
|
60
|
-
- When a tool returns an error, read it, fix the specific problem, and retry. Don't give up after one failed call.
|
|
60
|
+
- When a tool returns an error, read it, fix the specific problem, and retry with DIFFERENT input. Never repeat an identical failing call: if the same call fails or returns the same result twice, change the arguments or the approach, or move on — do not loop. Don't give up after one failed call, but don't spin on it either.
|
|
61
61
|
- DON'T CONFABULATE. If the user names a person, song, place, term, library, command, or concept you do not recognize, your FIRST move is to say "I don't recognize that" — NOT to invent a plausible-sounding meaning by phonetic association. Phonetic fits ("Alombasi sounds Bantu so it must be a Zambian chant", "Pyfoo sounds like a Python library so it must do X") are exactly the failure mode. If a web search would help, run it BEFORE asserting facts; if results are empty, say so plainly. Never write paragraph-length cultural/etymological/technical descriptions of something you can't actually source — "I'd love to know more, what's it from?" is the correct answer. Doubling down when the user repeats the unknown term is also forbidden; repetition is not evidence.
|
|
62
62
|
|
|
63
63
|
Runtime facts (true today; rely on these instead of guessing):
|
|
@@ -31,7 +31,8 @@ def prefetch_parallel_tool_calls(
|
|
|
31
31
|
fn = tc.get("function", {}) or {}
|
|
32
32
|
name = (fn.get("name", "") or "").strip()
|
|
33
33
|
try:
|
|
34
|
-
|
|
34
|
+
_raw = fn.get("arguments", "{}")
|
|
35
|
+
args = _raw if isinstance(_raw, dict) else json.loads(_raw)
|
|
35
36
|
except Exception:
|
|
36
37
|
continue
|
|
37
38
|
if not is_concurrency_safe(name, args):
|
localcode/app.py
CHANGED
|
@@ -744,399 +744,6 @@ class LocalCodeApp:
|
|
|
744
744
|
self._thinking_buffer = ""
|
|
745
745
|
self._thinking_revealed = 0
|
|
746
746
|
|
|
747
|
-
def _run_tool_loop_streaming(self, composed_messages: list[dict], stream: bool = True, _indicator: ThinkingIndicator | None = None) -> str:
|
|
748
|
-
"""Tool-calling loop with clean display."""
|
|
749
|
-
working_messages = composed_messages
|
|
750
|
-
|
|
751
|
-
# Route tools based on user message
|
|
752
|
-
user_text = ""
|
|
753
|
-
for msg in reversed(composed_messages):
|
|
754
|
-
if msg.get("role") == "user":
|
|
755
|
-
user_text = str(msg.get("content", ""))
|
|
756
|
-
break
|
|
757
|
-
routing = route_tools(
|
|
758
|
-
user_text,
|
|
759
|
-
self.toolkit.list_tool_names(),
|
|
760
|
-
conversation_history=self.session.messages[-4:],
|
|
761
|
-
online=is_online(),
|
|
762
|
-
)
|
|
763
|
-
if self.logger:
|
|
764
|
-
self.logger.log_user_input(user_text, intent=",".join(routing.intents))
|
|
765
|
-
if not routing.tool_names:
|
|
766
|
-
return self._run_stream_simple(composed_messages, stream)
|
|
767
|
-
if stream:
|
|
768
|
-
route_bits = ", ".join(routing.intents[:3]) or "chat"
|
|
769
|
-
tool_bits = ", ".join(routing.tool_names[:3])
|
|
770
|
-
self.out.print_info(f"route: {route_bits}")
|
|
771
|
-
if tool_bits:
|
|
772
|
-
self.out.print_info(f"tool lane: {tool_bits}")
|
|
773
|
-
|
|
774
|
-
ctx_size = self._select_task_context(user_text, routing.intents)
|
|
775
|
-
output_budget = self._select_task_output_budget(user_text, routing.intents)
|
|
776
|
-
|
|
777
|
-
# ── Speed optimization: speculative pre-execution ──
|
|
778
|
-
# Start running predicted tools NOW while model thinks
|
|
779
|
-
if hasattr(self, '_spec_executor'):
|
|
780
|
-
self._spec_executor.predict_and_prefetch(user_text, routing.tool_names)
|
|
781
|
-
|
|
782
|
-
# DIRECT FILE EDIT: if intent is edit and we can identify the file,
|
|
783
|
-
# skip the model tool-call loop entirely. Read file, ask model for
|
|
784
|
-
# updated content, write it. One shot. No multi-round failures.
|
|
785
|
-
# Also trigger if recent conversation was about editing a file
|
|
786
|
-
recent_edit_file = self._get_recent_edit_file()
|
|
787
|
-
is_question = "?" in user_text
|
|
788
|
-
is_edit_intent = ("file_edit" in routing.intents or "file_write" in routing.intents) and not is_question
|
|
789
|
-
is_followup_edit = (
|
|
790
|
-
bool(recent_edit_file)
|
|
791
|
-
and not is_edit_intent
|
|
792
|
-
and not is_question
|
|
793
|
-
and any(w in user_text.lower() for w in [
|
|
794
|
-
"add", "change", "remove", "update", "make",
|
|
795
|
-
"modify", "replace", "delete", "insert",
|
|
796
|
-
"improve", "fix", "refactor", "optimize", "rewrite",
|
|
797
|
-
"just", "dunno", "better", "clean",
|
|
798
|
-
])
|
|
799
|
-
)
|
|
800
|
-
if (is_edit_intent or is_followup_edit) and self.profile.feature_variant in ("compact", "balanced"):
|
|
801
|
-
if stream and (recent_edit_file if is_followup_edit else True):
|
|
802
|
-
target = recent_edit_file if is_followup_edit else "resolved from request"
|
|
803
|
-
self.out.print_info(f"edit path: direct file edit ({target})")
|
|
804
|
-
result = self._direct_file_edit(
|
|
805
|
-
user_text, stream,
|
|
806
|
-
force_file=recent_edit_file if is_followup_edit else None,
|
|
807
|
-
)
|
|
808
|
-
if result is not None:
|
|
809
|
-
return result
|
|
810
|
-
|
|
811
|
-
use_minimal = self.profile.feature_variant == "compact"
|
|
812
|
-
all_schemas = self.toolkit.schemas(minimal=use_minimal)
|
|
813
|
-
tools = [t for t in all_schemas if t["function"]["name"] in routing.tool_names]
|
|
814
|
-
# Use centralized output manager
|
|
815
|
-
out = self.out
|
|
816
|
-
|
|
817
|
-
try:
|
|
818
|
-
consecutive_errors = 0
|
|
819
|
-
last_tool_call = ""
|
|
820
|
-
max_rounds = 20 # safety cap — agent has none but we need one for local models
|
|
821
|
-
for _round in range(max_rounds):
|
|
822
|
-
thinking_parts: list[str] = []
|
|
823
|
-
content_parts: list[str] = []
|
|
824
|
-
tool_calls_found: list[dict] = []
|
|
825
|
-
content_started = False
|
|
826
|
-
round_started = time.time()
|
|
827
|
-
first_token_s: float | None = None
|
|
828
|
-
|
|
829
|
-
# ── Thinking mode ──
|
|
830
|
-
# Reasoning mode (/switch): think on every round
|
|
831
|
-
# Fast mode: never think
|
|
832
|
-
from .thinking import should_use_thinking
|
|
833
|
-
goal_type = infer_goal_state(user_text).goal_type
|
|
834
|
-
use_think = should_use_thinking(
|
|
835
|
-
self.config.runtime.laptop_26b_runtime_mode,
|
|
836
|
-
self.config.runtime.internal_thinking_mode,
|
|
837
|
-
goal_type=goal_type,
|
|
838
|
-
task_stage=getattr(getattr(self.session, "current_task", None), "current_stage", ""),
|
|
839
|
-
user_text=user_text,
|
|
840
|
-
)
|
|
841
|
-
round_budget = output_budget if _round == 0 else min(800, output_budget)
|
|
842
|
-
for event in self.engine.stream_chat_events(
|
|
843
|
-
working_messages,
|
|
844
|
-
tools=tools,
|
|
845
|
-
think=use_think,
|
|
846
|
-
num_ctx=ctx_size,
|
|
847
|
-
num_predict=round_budget,
|
|
848
|
-
):
|
|
849
|
-
if event["type"] == "thinking":
|
|
850
|
-
chunk = str(event["content"])
|
|
851
|
-
thinking_parts.append(chunk)
|
|
852
|
-
if stream:
|
|
853
|
-
out.feed_thinking(chunk)
|
|
854
|
-
peek = " ".join("".join(thinking_parts).split())[-120:]
|
|
855
|
-
if peek:
|
|
856
|
-
out.set_thinking_peek(peek)
|
|
857
|
-
elif event["type"] == "content":
|
|
858
|
-
chunk = str(event["content"])
|
|
859
|
-
# Filter special tokens
|
|
860
|
-
if "<|" in chunk or "|>" in chunk:
|
|
861
|
-
from .tool_parsing import parse_tool_calls as _ptc
|
|
862
|
-
parsed = _ptc(chunk)
|
|
863
|
-
if parsed.has_tools:
|
|
864
|
-
tool_calls_found = parsed.to_ollama_format()
|
|
865
|
-
break
|
|
866
|
-
import re as _re
|
|
867
|
-
chunk = _re.sub(r'<\|[^>]*\|>', '', chunk)
|
|
868
|
-
if not chunk.strip():
|
|
869
|
-
continue
|
|
870
|
-
if first_token_s is None:
|
|
871
|
-
first_token_s = time.time() - round_started
|
|
872
|
-
content_parts.append(chunk)
|
|
873
|
-
if stream:
|
|
874
|
-
out.stream(chunk)
|
|
875
|
-
content_started = True
|
|
876
|
-
elif event["type"] == "tool_calls":
|
|
877
|
-
tool_calls_found = event["tool_calls"]
|
|
878
|
-
break
|
|
879
|
-
|
|
880
|
-
thinking = "".join(thinking_parts)
|
|
881
|
-
content = "".join(content_parts).strip()
|
|
882
|
-
self._record_runtime_sample(first_token_s=first_token_s, total_s=time.time() - round_started)
|
|
883
|
-
self.stats.record(self.engine.last_response_meta)
|
|
884
|
-
|
|
885
|
-
# Check if content contains a text-format tool call (model output it as text)
|
|
886
|
-
if not tool_calls_found and content:
|
|
887
|
-
import re as _re
|
|
888
|
-
# Match: write_file(path='x', content='...') or similar
|
|
889
|
-
tc_match = _re.search(r'(write_file|read_file|edit_file|bash)\s*\(', content)
|
|
890
|
-
if tc_match:
|
|
891
|
-
from .tool_parsing import parse_tool_calls as _ptc
|
|
892
|
-
parsed = _ptc(content)
|
|
893
|
-
if parsed.has_tools:
|
|
894
|
-
tool_calls_found = parsed.to_ollama_format()
|
|
895
|
-
content = parsed.content # strip tool call from display
|
|
896
|
-
|
|
897
|
-
if not content_started and stream:
|
|
898
|
-
out.done()
|
|
899
|
-
|
|
900
|
-
if tool_calls_found:
|
|
901
|
-
# Dedup: don't repeat the same tool call
|
|
902
|
-
call_sig = str([(t.get("function",{}).get("name",""), t.get("function",{}).get("arguments","")) for t in tool_calls_found])
|
|
903
|
-
if call_sig == last_tool_call:
|
|
904
|
-
break
|
|
905
|
-
last_tool_call = call_sig
|
|
906
|
-
|
|
907
|
-
# Permission check + execute each tool
|
|
908
|
-
tool_messages = []
|
|
909
|
-
for tc in tool_calls_found:
|
|
910
|
-
f = tc.get("function", {})
|
|
911
|
-
name = f.get("name", "")
|
|
912
|
-
args = f.get("arguments", {})
|
|
913
|
-
if isinstance(args, str):
|
|
914
|
-
import json as _json
|
|
915
|
-
try: args = _json.loads(args)
|
|
916
|
-
except: args = {}
|
|
917
|
-
|
|
918
|
-
# Lifecycle hook: pre-tool
|
|
919
|
-
hook = self.hooks.on_pre_tool_use(name, args)
|
|
920
|
-
if hook.blocked:
|
|
921
|
-
if stream:
|
|
922
|
-
out.log_tool(name, f"BLOCKED by hook: {hook.output or hook.error}")
|
|
923
|
-
tool_messages.append({"role": "tool", "content": f"Blocked by hook: {hook.output or hook.error}"})
|
|
924
|
-
continue
|
|
925
|
-
|
|
926
|
-
allowed, reason = self.perms.check(name, args)
|
|
927
|
-
if not allowed:
|
|
928
|
-
if stream:
|
|
929
|
-
out.log_tool(name, f"SKIPPED: {reason}")
|
|
930
|
-
tool_messages.append({"role": "tool", "content": f"Denied: {reason}"})
|
|
931
|
-
continue
|
|
932
|
-
|
|
933
|
-
if stream:
|
|
934
|
-
out.log_tool(name, str(args)[:60])
|
|
935
|
-
# Check speculative cache first — instant if pre-fetched
|
|
936
|
-
spec_result = self._spec_executor.get_if_ready(name, args)
|
|
937
|
-
if spec_result is not None:
|
|
938
|
-
result = {"role": "tool", "content": spec_result}
|
|
939
|
-
else:
|
|
940
|
-
result = self.toolkit._execute_one(tc)
|
|
941
|
-
is_err = result["content"].startswith("Error") or result["content"].startswith("Tool error")
|
|
942
|
-
if stream:
|
|
943
|
-
out.tool_result(result["content"][:120], error=is_err)
|
|
944
|
-
if self.logger:
|
|
945
|
-
self.logger.log_tool_call(name, args, result["content"], duration_ms=0)
|
|
946
|
-
self.hooks.on_post_tool_use(name, args, result["content"], is_err)
|
|
947
|
-
# Track file changes for turn diff
|
|
948
|
-
if name in ("write_file", "edit_file") and "path" in args:
|
|
949
|
-
self.turn_tracker.track_file(str(args["path"]))
|
|
950
|
-
tool_messages.append(result)
|
|
951
|
-
|
|
952
|
-
# Check for errors — if too many consecutive, break and force-edit
|
|
953
|
-
all_errors = all(
|
|
954
|
-
m["content"].startswith("Error") or m["content"].startswith("Tool error")
|
|
955
|
-
for m in tool_messages
|
|
956
|
-
)
|
|
957
|
-
if all_errors:
|
|
958
|
-
consecutive_errors += 1
|
|
959
|
-
else:
|
|
960
|
-
consecutive_errors = 0
|
|
961
|
-
|
|
962
|
-
if stream:
|
|
963
|
-
for item in tool_messages:
|
|
964
|
-
is_err = item["content"].startswith("Error") or item["content"].startswith("Tool error")
|
|
965
|
-
out.tool_result(item["content"], error=is_err)
|
|
966
|
-
|
|
967
|
-
# If ANY file tool error, do the edit ourselves immediately
|
|
968
|
-
if consecutive_errors >= 1 and bool({"file_edit", "file_write"} & set(routing.intents)):
|
|
969
|
-
if stream:
|
|
970
|
-
ResponseDisplay.print_info("applying edit directly...")
|
|
971
|
-
# Find the file being edited
|
|
972
|
-
import re as _re
|
|
973
|
-
fpath = None
|
|
974
|
-
for tc in tool_calls_found:
|
|
975
|
-
args = tc.get("function", {}).get("arguments", {})
|
|
976
|
-
if "path" in args:
|
|
977
|
-
fpath = args["path"]
|
|
978
|
-
break
|
|
979
|
-
if not fpath:
|
|
980
|
-
fpath_match = _re.search(r'(\w+\.(?:py|js|ts|json|md|txt|html|css))', user_text + " " + thinking)
|
|
981
|
-
fpath = fpath_match.group(1) if fpath_match else None
|
|
982
|
-
if fpath and (self.repo_root / fpath).is_file():
|
|
983
|
-
old = (self.repo_root / fpath).read_text(errors="replace")
|
|
984
|
-
code_r = self.engine.chat_once([
|
|
985
|
-
{"role": "user", "content": f"Current {fpath}:\n```\n{old}\n```\n\nApply this change: {user_text}\n\nIMPORTANT: Make MINIMAL changes. Keep ALL existing code. Only modify what was requested.\nReturn the COMPLETE file with the small change applied. No explanation."}
|
|
986
|
-
])
|
|
987
|
-
new = code_r.get("message", {}).get("content", "").strip()
|
|
988
|
-
new = _re.sub(r'^```\w*\n', '', new)
|
|
989
|
-
new = _re.sub(r'\n```\s*$', '', new)
|
|
990
|
-
if new and new != old:
|
|
991
|
-
self.toolkit.changes.snapshot_before(fpath, "direct_edit")
|
|
992
|
-
(self.repo_root / fpath).write_text(new)
|
|
993
|
-
if stream:
|
|
994
|
-
out.log_tool("write_file", f"path={fpath}")
|
|
995
|
-
out.tool_result(f"Edited {fpath}")
|
|
996
|
-
return f"I've updated {fpath} with the requested changes."
|
|
997
|
-
break
|
|
998
|
-
|
|
999
|
-
if stream:
|
|
1000
|
-
out.start_thinking() # restart indicator for next round
|
|
1001
|
-
|
|
1002
|
-
working_messages = [
|
|
1003
|
-
*working_messages,
|
|
1004
|
-
{"role": "assistant", "content": content, "tool_calls": tool_calls_found},
|
|
1005
|
-
*tool_messages,
|
|
1006
|
-
{"role": "user", "content": f"Good. Now do the NEXT step. Original task: {user_text}"},
|
|
1007
|
-
]
|
|
1008
|
-
continue
|
|
1009
|
-
|
|
1010
|
-
# Force tool if model didn't call one but should have
|
|
1011
|
-
if _round <= 1 and not tool_calls_found:
|
|
1012
|
-
content_lower = content.lower()
|
|
1013
|
-
# Check if file write/edit was expected but not done
|
|
1014
|
-
file_intents = {"file_write", "file_edit"}
|
|
1015
|
-
file_tools_called = any(
|
|
1016
|
-
t.get("function", {}).get("name", "") in ("write_file", "edit_file")
|
|
1017
|
-
for t in tool_calls_found
|
|
1018
|
-
)
|
|
1019
|
-
should_force = (
|
|
1020
|
-
not content.strip() # empty response
|
|
1021
|
-
or any(p in content_lower for p in [
|
|
1022
|
-
"i do not have access", "i cannot", "i don't have",
|
|
1023
|
-
"i recommend using", "beyond my", "not able to",
|
|
1024
|
-
"i am unable", "i'm unable", "cannot directly",
|
|
1025
|
-
"i will now", "i will edit", "i will update", # says it will but didn't
|
|
1026
|
-
])
|
|
1027
|
-
or (bool(file_intents & set(routing.intents)) and not file_tools_called) # edit/write intent but no tool
|
|
1028
|
-
)
|
|
1029
|
-
if should_force and thinking:
|
|
1030
|
-
thinking_lower = thinking.lower()
|
|
1031
|
-
# Try each tool — match name or name with underscores replaced
|
|
1032
|
-
forced = False
|
|
1033
|
-
for ts in tools:
|
|
1034
|
-
tname = ts["function"]["name"]
|
|
1035
|
-
readable = tname.replace("_", " ")
|
|
1036
|
-
if tname in thinking_lower or readable in thinking_lower or f"`{tname}`" in thinking_lower:
|
|
1037
|
-
forced_args = self._extract_args_from_thinking(tname, thinking, user_text)
|
|
1038
|
-
if forced_args is None:
|
|
1039
|
-
import re as _re
|
|
1040
|
-
if tname in ("write_file", "edit_file"):
|
|
1041
|
-
# e2b can't call file tools — generate code, write ourselves
|
|
1042
|
-
path_match = _re.search(r'(\w+\.(?:py|js|ts|json|md|txt|html|css))', user_text + " " + thinking)
|
|
1043
|
-
fpath = path_match.group(1) if path_match else "output.py"
|
|
1044
|
-
existing = (self.repo_root / fpath)
|
|
1045
|
-
if existing.is_file() and tname == "edit_file":
|
|
1046
|
-
# Edit: ask model for the UPDATED version of the file
|
|
1047
|
-
old_content = existing.read_text(errors="replace")
|
|
1048
|
-
if stream:
|
|
1049
|
-
ResponseDisplay.print_info(f"generating edit for {fpath}...")
|
|
1050
|
-
code_r = self.engine.chat_once([
|
|
1051
|
-
{"role": "user", "content": f"Here is the current content of {fpath}:\n```\n{old_content}\n```\n\nModify it to: {user_text}\n\nReturn ONLY the complete updated file. No explanation."}
|
|
1052
|
-
])
|
|
1053
|
-
else:
|
|
1054
|
-
if stream:
|
|
1055
|
-
ResponseDisplay.print_info(f"generating code for {fpath}...")
|
|
1056
|
-
code_r = self.engine.chat_once([
|
|
1057
|
-
{"role": "user", "content": f"Write ONLY the code for {fpath}. No explanation, just the code. Task: {user_text}"}
|
|
1058
|
-
])
|
|
1059
|
-
code_content = code_r.get("message", {}).get("content", "").strip()
|
|
1060
|
-
code_content = _re.sub(r'^```\w*\n', '', code_content)
|
|
1061
|
-
code_content = _re.sub(r'\n```\s*$', '', code_content)
|
|
1062
|
-
if code_content:
|
|
1063
|
-
forced_args = {"path": fpath, "content": code_content}
|
|
1064
|
-
forced_calls = [{"function": {"name": "write_file", "arguments": forced_args}}]
|
|
1065
|
-
else:
|
|
1066
|
-
continue
|
|
1067
|
-
else:
|
|
1068
|
-
continue
|
|
1069
|
-
else:
|
|
1070
|
-
forced_calls = [{"function": {"name": tname, "arguments": forced_args}}]
|
|
1071
|
-
tool_msgs = self.toolkit.execute_tool_calls(forced_calls)
|
|
1072
|
-
if stream:
|
|
1073
|
-
out.log_tool(tname, str(forced_args)[:60])
|
|
1074
|
-
for item in tool_msgs:
|
|
1075
|
-
out.tool_result(item["content"])
|
|
1076
|
-
out.start_thinking() # restart indicator for next round
|
|
1077
|
-
working_messages = [
|
|
1078
|
-
*working_messages,
|
|
1079
|
-
{"role": "assistant", "content": "", "tool_calls": forced_calls},
|
|
1080
|
-
*tool_msgs,
|
|
1081
|
-
{"role": "user", "content": f"Good. Now do the NEXT step. Original task: {user_text}"},
|
|
1082
|
-
]
|
|
1083
|
-
forced = True
|
|
1084
|
-
break
|
|
1085
|
-
if forced and len(working_messages) > len(composed_messages):
|
|
1086
|
-
continue
|
|
1087
|
-
# Last resort: retry with ultra-short prompt (no system context)
|
|
1088
|
-
if not forced and not content.strip():
|
|
1089
|
-
retry_msgs = [
|
|
1090
|
-
{"role": "system", "content": "Use tools. Always call tools."},
|
|
1091
|
-
{"role": "user", "content": user_text},
|
|
1092
|
-
]
|
|
1093
|
-
r = self.engine.chat_once(retry_msgs, tools=tools)
|
|
1094
|
-
tc = r.get("message", {}).get("tool_calls", [])
|
|
1095
|
-
if tc:
|
|
1096
|
-
tool_msgs = self.toolkit.execute_tool_calls(tc)
|
|
1097
|
-
if stream:
|
|
1098
|
-
for t in tc:
|
|
1099
|
-
f = t.get("function", {})
|
|
1100
|
-
out.log_tool(f.get("name", ""), str(f.get("arguments", ""))[:60])
|
|
1101
|
-
for item in tool_msgs:
|
|
1102
|
-
out.tool_result(item["content"])
|
|
1103
|
-
out.start_thinking() # restart indicator for next round
|
|
1104
|
-
working_messages = [
|
|
1105
|
-
*working_messages,
|
|
1106
|
-
r["message"],
|
|
1107
|
-
*tool_msgs,
|
|
1108
|
-
{"role": "user", "content": f"Answer: {user_text}"},
|
|
1109
|
-
]
|
|
1110
|
-
continue
|
|
1111
|
-
content = r.get("message", {}).get("content", "")
|
|
1112
|
-
|
|
1113
|
-
# Auto-save: if response contains a code block with a filename, write it
|
|
1114
|
-
if content and "```" in content:
|
|
1115
|
-
import re as _re
|
|
1116
|
-
# Look for patterns like "save to pong.py" or "file named pong.py" in the text
|
|
1117
|
-
file_hint = _re.search(r'(?:save|file|named|called|create)\s+(?:it\s+(?:as|to)\s+)?[`*]*(\w+\.(?:py|js|ts|html|css|json|md|sh))[`*]*', content, _re.IGNORECASE)
|
|
1118
|
-
if file_hint:
|
|
1119
|
-
fname = file_hint.group(1)
|
|
1120
|
-
# Extract the code block
|
|
1121
|
-
code_match = _re.search(r'```(?:\w+)?\n(.*?)```', content, _re.DOTALL)
|
|
1122
|
-
if code_match:
|
|
1123
|
-
code = code_match.group(1).strip()
|
|
1124
|
-
if len(code) > 20:
|
|
1125
|
-
fpath = self.repo_root / fname
|
|
1126
|
-
self.toolkit.changes.snapshot_before(fname, "auto_write")
|
|
1127
|
-
fpath.parent.mkdir(parents=True, exist_ok=True)
|
|
1128
|
-
fpath.write_text(code)
|
|
1129
|
-
if stream:
|
|
1130
|
-
self.console.print(f"\n [green]✓[/] Auto-saved to {fname} ({len(code.splitlines())} lines)")
|
|
1131
|
-
|
|
1132
|
-
if stream:
|
|
1133
|
-
pass # output handled by out.done()
|
|
1134
|
-
return content
|
|
1135
|
-
|
|
1136
|
-
return ""
|
|
1137
|
-
finally:
|
|
1138
|
-
out.done()
|
|
1139
|
-
|
|
1140
747
|
def _get_recent_edit_file(self) -> str | None:
|
|
1141
748
|
"""Check if we recently edited/read a file (for follow-up edits)."""
|
|
1142
749
|
for event in reversed(self.session.events[-10:]):
|