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 CHANGED
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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
- args = json.loads((tc.get("function") or {}).get("arguments") or "{}")
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
- args = json.loads(fn.get("arguments") or "{}")
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
- args = json.loads(fn.get("arguments", "{}"))
773
- except json.JSONDecodeError:
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
- args = json.loads(fn.get("arguments", "{}"))
1332
- except json.JSONDecodeError:
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).
@@ -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
- args = json.loads(fn.get("arguments", "{}"))
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:]):