diff --git a/AutoCua/ios_connector/session.py b/AutoCua/ios_connector/session.py index 4ae4e53..47664b9 100644 --- a/AutoCua/ios_connector/session.py +++ b/AutoCua/ios_connector/session.py @@ -140,20 +140,21 @@ def _recv_exact(sock, n): return data -def plugged_in_udids(): - """UDIDs of the iOS devices plugged into this Mac by USB, asked straight - from usbmuxd: about a millisecond, no subprocess. A phone seen only over - Wi-Fi does not count, the session runs over the cable. - - None when usbmuxd could not be asked (no daemon, an odd reply), so the - caller can fall back to simply trying the phone.""" +def _usbmux_devices(): + """usbmuxd's device list (ListDevices), asked straight from the daemon: + about a millisecond, no subprocess. Its unix socket on macOS and Linux, + its TCP port on Windows. None when usbmuxd could not be asked (no + daemon, an odd reply).""" import plistlib - import socket import struct try: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + if sys.platform == "win32": + s = socket.create_connection(("127.0.0.1", 27015), timeout=2) + else: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(2) s.connect("/var/run/usbmuxd") + with s: body = plistlib.dumps({"MessageType": "ListDevices", "ClientVersionString": "AutoCua", "ProgName": "AutoCua"}) # usbmuxd header: total length, version 1 (plist), message 8 (plist), tag @@ -163,14 +164,39 @@ def plugged_in_udids(): devices = reply.get("DeviceList", []) except Exception: return None + return [d for d in devices if isinstance(d, dict)] + + +def plugged_in_udids(): + """UDIDs of the iOS devices plugged into this Mac by USB. A phone seen + only over Wi-Fi does not count, the session runs over the cable. + + None when usbmuxd could not be asked, so the caller can fall back to + simply trying the phone.""" + devices = _usbmux_devices() + if devices is None: + return None udids = set() for d in devices: - props = d.get("Properties", {}) if isinstance(d, dict) else {} + props = d.get("Properties", {}) if props.get("ConnectionType") == "USB" and props.get("SerialNumber"): udids.add(str(props["SerialNumber"])) return udids +def mux_device_id(udid): + """usbmuxd's id for the phone's CURRENT cable attachment, None when it is + not on the cable (or usbmuxd could not be asked). The id changes every + time the phone re-enumerates on USB (QuickTime's screen mirror picking + the phone, a cable glitch), and every usbmux connection made before that + moment is dead: the session's forward and runner have to start again.""" + for d in _usbmux_devices() or []: + props = d.get("Properties", {}) + if props.get("ConnectionType") == "USB" and str(props.get("SerialNumber")) == str(udid): + return d.get("DeviceID") + return None + + def _free_local_port(preferred): """`preferred` when nothing listens there, else any free port.""" import socket @@ -292,6 +318,16 @@ def __init__(self): self._shared = False self._udid = None self._log_path = None + # The phone's usbmux attachment id at the last (re)start. A re-plug + # (QuickTime picking the phone's screen, a cable glitch) gives it a + # new id and kills the forward and the runner's channel while both + # Mac-side processes stay alive, so a watchdog compares the id every + # few seconds and starts the session again in place. + self._mux_id = None + self._reconnecting = False + self._relaunches = [] # when the watchdog last restarted the session + self._gave_up = False + self._watchdog = None # -- helpers -- def _wda_up(self): @@ -386,12 +422,14 @@ def activate(self, udid=None, owner=None): if self._udid == udid and self._wda_up(): self._owner = owner self._shared = True + self._relaunches = [] return {"ok": True, "state": "connected", "udid": udid, "shared": True} if self._alive(self._xctest) and self._alive(self._forward): return {"ok": False, "state": "error", "code": "in_use", "error": "the phone is in use by Mobile use in the app"} if self._udid == udid and self._wda_up(): self._owner = owner + self._relaunches = [] return {"ok": True, "state": "connected", "udid": udid} # How long each step took, for the phone link's log: a connect # that misses its budget must say where the time went. @@ -466,6 +504,10 @@ def mark(step): base + ["developer", "dvt", "xcuitest", "--userspace", WDA_BUNDLE_ID], stdout=subprocess.DEVNULL, stderr=log, env=env) mark("spawn") + self._mux_id = mux_device_id(udid) + self._relaunches = [] + self._gave_up = False + self._start_watchdog() except Exception as e: self._stop_locked() return {"ok": False, "state": "error", "error": str(e)} @@ -477,6 +519,11 @@ def status(self): return {"state": "disconnected"} if (not self._alive(self._xctest) or not self._alive(self._forward)) \ and not self._wda_up(): + if self._gave_up: + return {"state": "error", "udid": self._udid, "code": "replug", + "error": "the phone keeps re-plugging on USB", + "hint": "Something keeps re-selecting the phone (QuickTime's screen " + "mirror?). Leave it alone during a run, then try again."} info = self._classify(self._log_tail()) return {"state": "error", "udid": self._udid, **info} state = "connected" if self._wda_up() else "connecting" @@ -490,7 +537,8 @@ def holds(self, owner): the port, now for that run. Read without the lock (cheap, called every step), so it can be a moment late.""" return (owner is not None and owner is self._owner and self._udid is not None - and self._alive(self._xctest) and self._alive(self._forward)) + and (self._reconnecting + or (self._alive(self._xctest) and self._alive(self._forward)))) def busy_elsewhere(self): """True when another process holds the WDA port: a terminal run's @@ -574,7 +622,7 @@ def deactivate(self, owner=None): self._stop_locked() return {"ok": True, "state": "disconnected"} - def _stop_locked(self): + def _kill_locked(self): # Kill INSTANTLY: xctest first (it owns the phone-side session), tiny # grace for a clean teardown, then SIGKILL. The whole stop stays ~1s. for attr in ("_xctest", "_forward", "_mjpeg_forward"): @@ -596,9 +644,122 @@ def _stop_locked(self): except Exception: pass self._log_path = None + + def _stop_locked(self): + self._kill_locked() self._udid = None self._owner = None self._shared = False + self._mux_id = None + self._relaunches = [] + self._gave_up = False + + # -- re-plug watchdog -- + RECONNECTS_MAX = 5 # restarts within RECONNECT_WINDOW before giving up + RECONNECT_WINDOW = 600.0 # seconds: a phone re-plugging that often is left alone + + def _start_watchdog(self): + if self._watchdog is not None and self._watchdog.is_alive(): + return + self._watchdog = threading.Thread(target=self._watch_replug, daemon=True) + self._watchdog.start() + + def _watch_replug(self): + """The phone re-plugged on USB (new usbmux id): start the session + again in place, same phone, owner and sharing. The processes of the + old session stay alive after a re-plug, so nothing else notices.""" + while True: + time.sleep(2) + try: + udid, seen = self._udid, self._mux_id + if udid is None: + continue + now = mux_device_id(udid) + if now is None: + continue # off the cable: status and the link report that + if seen is None: + with self._lock: # not readable at the spawn: adopt it + if self._udid == udid and self._mux_id is None: + self._mux_id = now + continue + if now == seen: + continue + with self._lock: + if self._udid != udid or self._mux_id != seen: + continue # stopped or restarted by another caller meanwhile + self._mux_id = now + # Only the re-plug signature is ours to mend: the id changed + # while this session's own processes live. Dead processes + # mean another run's activate() took the phone (it kills + # this forward), a start that failed, or the give-up below. + if not (self._alive(self._forward) and self._alive(self._xctest)): + continue + t_now = time.monotonic() + self._relaunches = [t for t in self._relaunches + if t_now - t < self.RECONNECT_WINDOW] + if len(self._relaunches) >= self.RECONNECTS_MAX: + self._gave_up = True + self._kill_locked() # so status() reports the dead session + print(f"[ios_connector] the phone re-plugged again (usbmux id {seen} -> {now}): " + f"giving up after {len(self._relaunches)} restarts in " + f"{int(self.RECONNECT_WINDOW // 60)} min", flush=True) + continue + self._relaunches.append(t_now) + self._relaunch_locked() + print(f"[ios_connector] the phone re-plugged (usbmux id {seen} -> {now}): started the " + f"session again ({len(self._relaunches)}/{self.RECONNECTS_MAX})", flush=True) + except Exception: + continue + + def _relaunch_locked(self): + """Fresh forward and runner for the same phone: `holds()` stays true + meanwhile, so a request keeps its phone and only the step in flight + fails. No mount step: the image survives a re-plug (not a reboot; + that fails like any dead session and is reported by status()).""" + base = _pmd3_base() + if not base: + return + self._reconnecting = True + try: + mjpeg_port = self.mjpeg_port if self._alive(self._mjpeg_forward) else None + self._kill_locked() + env = dict(os.environ) + env["PYMOBILEDEVICE3_UDID"] = self._udid + log = tempfile.NamedTemporaryFile(prefix="AutoCua_wda_", suffix=".log", delete=False) + self._log_path = log.name + self._forward = subprocess.Popen( + base + ["usbmux", "forward", str(WDA_PORT), str(WDA_PORT)], + stdout=subprocess.DEVNULL, stderr=log, env=env) + # The old runner on the phone can still answer for ~20-30 s after a + # re-plug (iOS ends it when testmanagerd notices). A second launch + # of the same bundle meeting it is undefined, so tell it to exit + # first, the way deactivate() does, and wait until the port is + # silent, bounded. + for _ in range(8): # the new forward needs a moment to listen + if _port_answers(WDA_PORT): + break + time.sleep(0.25) + if self._wda_up(): + try: + urllib.request.urlopen(f"http://127.0.0.1:{WDA_PORT}/wda/shutdown", timeout=2) + except Exception: + pass + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and self._wda_up(): + time.sleep(0.5) + self._xctest = subprocess.Popen( + base + ["developer", "dvt", "xcuitest", "--userspace", WDA_BUNDLE_ID], + stdout=subprocess.DEVNULL, stderr=log, env=env) + if mjpeg_port: + # the phone window keeps reading the same local port + self._mjpeg_forward = subprocess.Popen( + base + ["usbmux", "forward", "--serial", self._udid, str(mjpeg_port), str(MJPEG_PORT)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self.mjpeg_port = mjpeg_port + except Exception: + pass + finally: + self._reconnecting = False wda_session = WDASession() diff --git a/AutoCua/linux/agent/coder/claude_prompt.md b/AutoCua/linux/agent/coder/claude_prompt.md index 7d692b9..302d868 100644 --- a/AutoCua/linux/agent/coder/claude_prompt.md +++ b/AutoCua/linux/agent/coder/claude_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not re-deciding where nothing has changed ( -- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. +- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. - Both fields are filled every step. Decide first, then write: nothing about how you decided goes into either field — `memory` keeps only what must survive the step (the verdict, a corrected belief, the key context, and a Route when one is written), `next_goal` keeps this step's move and its guard, and the detail behind a decision (findings, approach, risks, proof) goes into the plan, when the task has one. Most steps are route steps, where nothing new is decided and `memory` stays short. - Neither field is a reply to the user: the user never reads them mid-run. They are notes for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -267,7 +267,7 @@ Prove every code change correct with concrete execution output before treating i -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` is not shown it is left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` or the actions are not shown they are left out for space only: you fill both fields every step, and every step before [G] carries its action calls in the same turn as `agent_memory`. [A] TASK START — short memory: "Task start. Request: switch the scratchpad cache to an LRU. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. FULL: it edits code I have not seen, and a cache is shared by nature, so it has callers. Route: 1. dispatch 3 parallel minions, if all three reports return with path:line anchors → decide: build the model, choose the approach, write the plan." diff --git a/AutoCua/linux/agent/coder/service.py b/AutoCua/linux/agent/coder/service.py index 555de89..9689269 100644 --- a/AutoCua/linux/agent/coder/service.py +++ b/AutoCua/linux/agent/coder/service.py @@ -14,7 +14,7 @@ import subprocess from ...tool_registry.service import (LLMManager, tool_calls_to_steps, REASONING_FIELDS, drop_bundled_terminal, CLAUDE_REASONING_FIELDS, ACTION_DEFAULTS_CLAUDE, - CODER_TOOL_NAMES_CLAUDE) + CODER_TOOL_NAMES, CODER_TOOL_NAMES_CLAUDE) from ...controller.view import ControllerView from ...controller.cli.service import render_tool_response from ....memory_compression.controller import CompressionController @@ -134,6 +134,33 @@ def _is_bridge_entry(text) -> bool: """ +# Appended LAST to the live turn, every step, for a +# non-Claude model. The coder treats its notes tool as a think step: it calls +# `reasoning`, ends its turn to wait for the result and acts on the NEXT turn - +# one wasted LLM call per step. A rule in the live message moves batching far +# more than the same rule in the system prompt. Live message only, never +# persisted, so the cached prefix is unchanged. +# +# Claude gets NO live reminder: the same sentence about `agent_memory`, on top +# of this prompt's notes-for-your-future-self language, made Anthropic refuse +# the whole request (stop_reason "refusal", category reasoning_extraction; +# measured 2026-10-21 on Opus 5.5: refused with it, answered without it). Its +# batching rides on claude_prompt.md, the `agent_memory` description and the +# notes pause below. +BATCH_REMINDER = ( + "\n\nThis turn must carry the `reasoning` call AND the action calls that do its " + "\"Doing\" in this one response. A step that thinks is no exception: think in `reasoning`, " + "then make the calls for its \"Doing\" in the same response. `reasoning` alone is not a step: its " + "result is only `ok`, so never end the turn after it. The one exception is the final review " + "right before `exit` (and `exit` is always alone).") + +# A notes-only turn (the notes call and nothing else) is answered with a result +# that says no action ran. After NOTES_PAUSE_AFTER of them in a row the notes +# tool is taken out of the tool list for the next NOTES_PAUSE_STEPS steps, so +# the model can only call the tools that do the work; then it comes back. +NOTES_PAUSE_AFTER = 2 +NOTES_PAUSE_STEPS = 5 + class AgentService: """CLI Agent Service - Agentic loop only""" @@ -591,6 +618,7 @@ def _run_agent_loop(self, task: str) -> str: is_first = True json_fail_count = 0 # Track consecutive JSON parse failures (max 3 before exit) reasoning_only_count = 0 # Consecutive steps that called `reasoning` and nothing else + notes_paused_until = 0 # last step number with the notes tool off (see NOTES_PAUSE_AFTER) web_memory_index = None # tool slot of the web step to backfill with the digest's scratchpad web_pending = None # [(call_id, compacted dict)] of the web step, for the digest fold is_web_digest = False # True only on the iteration that digests a web result @@ -752,6 +780,13 @@ def _run_agent_loop(self, task: str) -> str: "web call's durable `result` in ; the raw text below is " "shown once and never stored, so anything you do not record is gone.\n" f"\n\n{pending_web}") + # The notes call and the action calls belong in ONE response (see + # BATCH_REMINDER). Last in the live message. Not for Claude (see + # there), and not while the notes tool is paused: the reminder + # would ask for a call the tool list does not offer. + notes_on = step_number > notes_paused_until + if not self._claude and notes_on: + content += BATCH_REMINDER messages.append({"role": "user", "content": content}) # Prompt caching: mark the newest PERSISTENT turn — the last tool @@ -783,6 +818,10 @@ def _run_agent_loop(self, task: str) -> str: self.last_messages = messages try: + # The notes tool rides only while it is not paused (see + # NOTES_PAUSE_AFTER); the provider reads its list every request. + self.llm.show_notes_tool(notes_on) + # Call LLM — show a working spinner on a TTY so the terminal doesn't # look frozen during the (multi-second) LLM wait. if sys.stdout.isatty(): @@ -830,9 +869,16 @@ def _run_agent_loop(self, task: str) -> str: json.dumps(raw_response, indent=2, ensure_ascii=False, default=str), step_number, ) + # While the notes tool is paused it is not offered, so a call + # to it is rejected like any unknown tool (the driver does the + # same for its hidden screen tools); the error names the tools + # that ARE offered this step. + allowed = CODER_TOOL_NAMES_CLAUDE if self._claude else CODER_TOOL_NAMES + if not notes_on: + allowed = allowed.difference({self._notes_tool}) actions, calls, rejects, track = tool_calls_to_steps( raw_response.get("tool_calls"), - allowed=CODER_TOOL_NAMES_CLAUDE if self._claude else None, + allowed=allowed, defaults_map=ACTION_DEFAULTS_CLAUDE if self._claude else None, track_params=CLAUDE_REASONING_FIELDS if self._claude else REASONING_FIELDS) if self._claude: @@ -869,18 +915,32 @@ def _run_agent_loop(self, task: str) -> str: if not actions and not rejects: if note_ids: - # A reasoning-only step: the model chose to think - # before acting. Accepted as a normal step - the turn - # is recorded and its call answered like any other. - # Only a RUN of them is bounded: from the third in a - # row, the result carries a one-line reminder to act. + # A notes-only step: the model ended its turn after the + # notes call. The turn is recorded and its call answered + # like any other, but from the FIRST one the result says + # no action ran, so the next turn makes the calls this + # one skipped. The NOTES_PAUSE_AFTER-th in a row takes + # the notes tool out of the tool list for the next + # NOTES_PAUSE_STEPS steps (see show_notes_tool above). reasoning_only_count += 1 - if reasoning_only_count >= 3: - note_results = [{"tool_call_id": i, "content": render_tool_response({ - "status": "success", "action": self._notes_tool, - "message": f"{reasoning_only_count} steps in a row without an action: " - "call a tool that does the work in this turn."})} - for i in note_ids] + if reasoning_only_count >= NOTES_PAUSE_AFTER: + notes_paused_until = step_number + NOTES_PAUSE_STEPS + reasoning_only_count = 0 + message = (f"No action ran, {NOTES_PAUSE_AFTER} steps in a row: `{self._notes_tool}` " + f"is off for the next {NOTES_PAUSE_STEPS} steps. Call the tools that do " + "the work directly (or `exit` if the work is done and reviewed); it " + "comes back after that.") + if sys.stdout.isatty(): + safe_print(f"⚠️ {NOTES_PAUSE_AFTER} notes-only turns in a row: " + f"`{self._notes_tool}` off for the next {NOTES_PAUSE_STEPS} steps") + else: + message = (f"No action ran ({reasoning_only_count} step(s) in a row): " + f"`{self._notes_tool}` alone does nothing. If this was the final " + "review and it passed, call `exit` now; otherwise make the calls for " + "its \"Doing\" now, in the same response as the notes.") + note_results = [{"tool_call_id": i, "content": render_tool_response({ + "status": "success", "action": self._notes_tool, "message": message})} + for i in note_ids] normalized = encode_step(resp_text, wire_calls_from(all_calls), raw_response.get("provider_meta")) display_payload = json.dumps( diff --git a/AutoCua/linux/agent/coder/system_prompt.md b/AutoCua/linux/agent/coder/system_prompt.md index 17ef256..0c10522 100644 --- a/AutoCua/linux/agent/coder/system_prompt.md +++ b/AutoCua/linux/agent/coder/system_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not thinking where nothing is being decided -- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. A turn may be `reasoning` alone when you need to think before you act; the next turn then does the work. +- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `reasoning` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message, on a step that thinks too. The one exception is the final review right before `exit`. - `memory` and `next_goal` are filled every step. `thinking` comes in bursts: FULL when you look ahead (it ends with a ROUTE), RECOVERY after a failure, BRIEF on a surprise, and "" (or omitted) on every route step in between. Most steps are route steps. - Your thinking is NOT a reply to the user: the user never reads it mid-run. It is your private reasoning trace, written for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -294,7 +294,7 @@ Prove every code change correct with concrete execution output before treating i -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` are not shown they are left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` or the actions are not shown they are left out for space only: you fill both every step, and every step before [G] carries its action calls in the same turn as `reasoning`. [A] TASK START — FULL, short thinking: "JUDGE: task start, nothing to judge. UNDERSTAND: The user wants the scratchpad cache to stop growing without bound. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. It edits code I have not seen, and a cache is shared by nature, so it has callers → FULL. DECIDE: I cannot choose an approach until I know three things: where the cache lives and how it is read and written; who uses it, and whether anyone reaches into the underlying structure directly; how this project runs tests and what conventions that file follows. Independent questions → three parallel minions, each asked for path:line anchors. ROUTE: 1. dispatch 3 minions, if all three reports return with path:line anchors → think: build the model, choose the approach, write the plan." diff --git a/AutoCua/linux/tool_registry/service.py b/AutoCua/linux/tool_registry/service.py index 39505fd..355e822 100644 --- a/AutoCua/linux/tool_registry/service.py +++ b/AutoCua/linux/tool_registry/service.py @@ -160,16 +160,19 @@ def as_jpeg_base64(image_base64): _tool("reasoning", REASONING_FIELDS, r"""Your notes to yourself for this step. Call it FIRST in every step, then the calls that do the work, all in the same turn. The harness does nothing with it (its result is just `ok`), but the call is replayed in , so your future self can read the reasoning behind every step. - `memory` and `next_goal` are filled every step. `thinking` is not: you think in bursts (see ). A FULL thinking ends with a ROUTE of the next 3-5 steps; on each step of that route omit `thinking` (or pass ""), and think again only when the route is used up, a guard fails, or the route said "think". -- A step may be `reasoning` alone when you need to think before you act; the next step then does the work. Do not chain several such steps. +- It never ends a turn: the calls that do this step's "Doing" always follow it in the SAME message, on a step that thinks too. A message holding only this call is a wasted turn. The one exception is the final review right before `exit`. - Not used on the `exit` step: `exit` is always the only call of its turn. - Format: reasoning {"thinking": "", "memory": "...", "next_goal": "..."} -- Examples: +- Examples (each `reasoning` call is followed, in the same turn, by the calls that do its "Doing"): 1. Route step (no thinking; "Doing" is the next route line): reasoning {"memory": "Last step worked: replace applied at service.py:233, no mismatch. Verification script ready at ./.AutoCua_verify/test_cache.py.", "next_goal": "Doing: run ./.AutoCua_verify/test_cache.py (ToDo: #3 verify). If output shows 6/6 cases pass → Next: think: judge proof, clean up ./.AutoCua_verify/, mark #3 done."} + + shell {"command": "python ./.AutoCua_verify/test_cache.py", "input": ""} 2. Planning moment (FULL thinking, ends with the ROUTE): reasoning {"thinking": "JUDGE: PASS, three reports, all anchored, and they agree. UNDERSTAND: known: the cache is a plain dict at service.py:233, put() inserts and nothing evicts; callers api.py:41 and worker.py:88 use only get/put. Assumed: no direct access to the dict elsewhere. DECIDE: OrderedDict inside get/put with a CACHE_MAX constant, no new dependency, callers untouched; the part most likely to be wrong is recency on a hit, so the proof tests exactly that. ROUTE: 1. plan set + todo_list, if both accepted. 2. view service.py:220-250, if the dict and put() sit at the reported anchors. 3. replace put()/get(), add CACHE_MAX, bottom-up, if all apply with no mismatch. 4. write + run ./.AutoCua_verify/test_cache.py, if output shows 4/4 cases pass → think: judge the proof.", "memory": "Last step worked: 3 minion reports with anchors, consistent. Cache = dict at service.py:233, callers api.py:41 and worker.py:88 use only get/put. Approach: OrderedDict LRU inside get/put. Unverified: no direct dict access elsewhere.", "next_goal": "Doing: write the plan (`plan` set) and derive the ToDo (ToDo: none yet — plan). If both are accepted → Next: view service.py:220-250 to confirm the anchors."} + + plan {"op": "set", "from": 0, "to": 0, "value": "..."} + todo_list {"value": "..."} 3. Recovery after a failed guard: - reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."}""", + reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."} + + replace {"path": "test_cache.py", "line": 3, "old_block": "...", "new_block": "..."} + shell {"command": "python -m pytest test_cache.py", "input": ""}""", optional=REASONING_OPTIONAL), _tool("shell", {"command": {"type": "string"}, diff --git a/AutoCua/llm_provider/__init__.py b/AutoCua/llm_provider/__init__.py index f19b1dd..51f7341 100644 --- a/AutoCua/llm_provider/__init__.py +++ b/AutoCua/llm_provider/__init__.py @@ -24,6 +24,14 @@ LLM_HTTP_TIMEOUT = (15, 180) +class RefusalError(Exception): + """The API refused the request itself (Anthropic stop_reason "refusal": + no content, 0 output tokens, stop_details naming the policy). Not a + transport error: the same request gets the same answer, so LLMManager + skips its retries and falls back where it can instead of asking three + times and reporting an empty response.""" + + def screenshots(shot) -> list: """The images a step's user message carries, in order. The step passes one base64 JPEG (the usual case on every platform) or, from the web agent, diff --git a/AutoCua/llm_provider/anthropic/service.py b/AutoCua/llm_provider/anthropic/service.py index eb86308..7a8d199 100644 --- a/AutoCua/llm_provider/anthropic/service.py +++ b/AutoCua/llm_provider/anthropic/service.py @@ -4,7 +4,7 @@ from typing import Dict, Any, Optional from .view import get_model_info, get_sampling_params, get_thinking_params, get_tool_choice -from .. import LLM_HTTP_TIMEOUT, screenshots +from .. import LLM_HTTP_TIMEOUT, RefusalError, screenshots def _as_text(content) -> str: @@ -232,7 +232,16 @@ def send_request(self, messages: list, model: str, annotated_screenshot_base64: response = self.session.post(self.api_url, json=data, headers=headers, timeout=LLM_HTTP_TIMEOUT) response.raise_for_status() result = response.json() - + + if result.get("stop_reason") == "refusal": + # The API refused this request (no content, 0 output tokens); + # stop_details says which policy. Raised, not returned empty: + # the manager skips its retries and falls back where it can. + details = result.get("stop_details") or {} + raise RefusalError( + f"stop_reason=refusal, category={details.get('category')!r}: " + f"{details.get('explanation') or 'no explanation given'}") + # Normalize response to match OpenAI-style format (choices[0].message.content) # Anthropic returns: content: [{type: "thinking", ...}, {type: "text", text: "..."}, # {type: "tool_use", name, input}, ...] @@ -252,6 +261,14 @@ def send_request(self, messages: list, model: str, annotated_screenshot_base64: "name": block.get("name") or "", "arguments": args if isinstance(args, dict) else {}, }) + if not calls and not any(t.strip() for t in texts): + # The loops only see "empty response"; why it is empty + # (an immediate "end_turn", thinking only) is in stop_reason. + print(f" Anthropic returned no text and no tool call: " + f"stop_reason={result.get('stop_reason')!r}, " + f"content={json.dumps([b.get('type') for b in result.get('content', [])])}" + + (f", stop_details={json.dumps(result.get('stop_details'), ensure_ascii=False)}" + if result.get("stop_details") else "")) return { "choices": [{ "message": { diff --git a/AutoCua/llm_provider/llm_manager.py b/AutoCua/llm_provider/llm_manager.py index 0960cd4..340c7f7 100644 --- a/AutoCua/llm_provider/llm_manager.py +++ b/AutoCua/llm_provider/llm_manager.py @@ -6,7 +6,7 @@ from dotenv import load_dotenv -from . import _ensure_tls_works +from . import _ensure_tls_works, RefusalError from .openrouter.service import OpenRouterProvider from .openrouter.view import get_model_info as get_openrouter_model_info from .groq.service import GroqProvider @@ -665,6 +665,12 @@ def _attempt(self, messages: list, annotated_screenshot_base64: Optional[str] = _META_KEY: message.get(_META_KEY) or {}, } return message['content'] + except RefusalError as e: + # The API refused the request itself; the same request gets + # the same answer, so no retry. send_request falls back where + # it can. + print(f"⛔ {self.display_name} refused the request: {e}") + raise except Exception as e: last_error = e if attempt < 2: @@ -693,7 +699,7 @@ def send_request(self, messages: list, annotated_screenshot_base64: Optional[str raise fallback_info = self._resolve_model_info(self._cli_fallback_model) - print(f"⚠️ {self.display_name} failed 3 attempts — this step only, " + print(f"⚠️ {self.display_name} failed — this step only, " f"falling back to {fallback_info['display_name']}...") self._apply_model_info(fallback_info) try: @@ -714,17 +720,35 @@ def async_tools(self) -> bool: for it. Read from the primary entry, so a one-call fallback does not flip it.""" return bool(self._primary_model_info.get("async_tools")) + def _show_tools(self, on: bool, hidden) -> None: + """Send every tool, or every tool but `hidden`. The provider holds its + tools in its own dialect and reads them on every request, so each short + list is cut from the full one once per provider instance and swapped + onto the provider. One toggle per manager: the driver hides its screen + tools, the coder its notes tool.""" + inst = self.provider_instance + cache = getattr(self, "_tool_cuts", None) + if cache is None or cache[0] is not inst: + cache = self._tool_cuts = (inst, inst.tools, {}) + full, cuts = cache[1], cache[2] + if full is None: + return # mode="text": no tools to hide + key = frozenset(hidden) + if key not in cuts: + cuts[key] = [t for t in full if _dialect_tool_name(t) not in key] + inst.tools = full if on else cuts[key] + def show_screen_tools(self, on: bool) -> None: """Scan toggle (main driver): send every tool, or every tool but - SCREEN_TOOLS while the scan is off. The provider holds its tools in its - own dialect and reads them on every request, so the short list is cut - from that one once and the two are swapped on the provider.""" - inst = self.provider_instance - if getattr(self, "_tool_lists_of", None) is not inst: - full = inst.tools - short = [t for t in full or [] if _dialect_tool_name(t) not in self.SCREEN_TOOLS] - self._tool_lists, self._tool_lists_of = (full, short), inst - inst.tools = self._tool_lists[0] if on else self._tool_lists[1] + SCREEN_TOOLS while the scan is off.""" + self._show_tools(on, self.SCREEN_TOOLS) + + def show_notes_tool(self, on: bool) -> None: + """Notes pause (coder): send every tool, or every tool but the notes + tool (`reasoning` / `agent_memory`) while the coder has it paused after + a run of notes-only turns, so the model can only call the tools that + do the work.""" + self._show_tools(on, NOTE_TOOLS) def get_model_name(self) -> str: """Get the current model short name (preserves vertex suffix for downstream routing)""" diff --git a/AutoCua/llm_provider/responses.py b/AutoCua/llm_provider/responses.py index 96fae16..10095f5 100644 --- a/AutoCua/llm_provider/responses.py +++ b/AutoCua/llm_provider/responses.py @@ -32,6 +32,12 @@ # how long that idling can last. 2,000 bounds it to about 40 s while leaving # room for the longest legitimate turn. MAX_OUTPUT_TOKENS = 2000 +# The coder and minion (cli_agent) write far longer turns: a FULL `reasoning` +# call plus a whole-file `write` or a `plan` set. At 2,000 such a turn was cut +# mid-call and the trim below kept only the notes call, so the step read as +# notes-only. 8,000 (the models allow 128,000) idles out in about 150 s, still +# inside LLM_HTTP_TIMEOUT's 180 s read, so a cut reply is kept, not timed out. +CLI_MAX_OUTPUT_TOKENS = 8000 def async_route(info: dict) -> bool: @@ -149,7 +155,7 @@ def send(session: requests.Session, url: str, api_key: str, error_prefix: str, if instructions: data["instructions"] = instructions data["input"] = items - data["max_output_tokens"] = MAX_OUTPUT_TOKENS + data["max_output_tokens"] = CLI_MAX_OUTPUT_TOKENS if cli_agent else MAX_OUTPUT_TOKENS data.update(reasoning or {}) data.update(extra or {}) data["tools"] = responses_tools(tools) diff --git a/AutoCua/logo/autocua-bots.png b/AutoCua/logo/autocua-bots.png new file mode 100644 index 0000000..cf76193 Binary files /dev/null and b/AutoCua/logo/autocua-bots.png differ diff --git a/AutoCua/mac/agent/coder/claude_prompt.md b/AutoCua/mac/agent/coder/claude_prompt.md index 17953fc..52f1c0a 100644 --- a/AutoCua/mac/agent/coder/claude_prompt.md +++ b/AutoCua/mac/agent/coder/claude_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not re-deciding where nothing has changed ( -- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. +- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. - Both fields are filled every step. Decide first, then write: nothing about how you decided goes into either field — `memory` keeps only what must survive the step (the verdict, a corrected belief, the key context, and a Route when one is written), `next_goal` keeps this step's move and its guard, and the detail behind a decision (findings, approach, risks, proof) goes into the plan, when the task has one. Most steps are route steps, where nothing new is decided and `memory` stays short. - Neither field is a reply to the user: the user never reads them mid-run. They are notes for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -263,10 +263,11 @@ Prove every code change correct with concrete execution output before treating i - All tool calls in one step execute sequentially, in the order you emit them. - The same tool can be called multiple times within a single step. - Batch what is independent: parallel minions, several `view`s, an edit plus the run that checks it. Do not batch past a guard — a call that only makes sense once you have inspected an earlier result belongs to the next step. +- In one go you can iterate at most 100 times. The loop stops at iteration 100 and reports the task incomplete with the ToDo status, so budget for it: explore with parallel minions, batch independent calls, and start verification early enough to finish inside the cap. -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` is not shown it is left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` or the actions are not shown they are left out for space only: you fill both fields every step, and every step before [G] carries its action calls in the same turn as `agent_memory`. [A] TASK START — short memory: "Task start. Request: switch the scratchpad cache to an LRU. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. FULL: it edits code I have not seen, and a cache is shared by nature, so it has callers. Route: 1. dispatch 3 parallel minions, if all three reports return with path:line anchors → decide: build the model, choose the approach, write the plan." diff --git a/AutoCua/mac/agent/coder/service.py b/AutoCua/mac/agent/coder/service.py index 3372061..58ed163 100644 --- a/AutoCua/mac/agent/coder/service.py +++ b/AutoCua/mac/agent/coder/service.py @@ -14,7 +14,7 @@ import subprocess from ...tool_registry.service import (LLMManager, tool_calls_to_steps, REASONING_FIELDS, drop_bundled_terminal, CLAUDE_REASONING_FIELDS, ACTION_DEFAULTS_CLAUDE, - CODER_TOOL_NAMES_CLAUDE) + CODER_TOOL_NAMES, CODER_TOOL_NAMES_CLAUDE) from ...controller.view import ControllerView from ...controller.cli.service import render_tool_response from ....memory_compression.controller import CompressionController @@ -88,7 +88,7 @@ def safe_print(*args, **kwargs): debug_log(f"[PRINT] {msg}") # Maximum iterations before CLI agent auto-exits (prevents infinite loops) -MAX_CLI_ITERATIONS = 50 +MAX_CLI_ITERATIONS = 100 # ── Shell-use conversation continuity ──────────────────────────────────────── # The bridge ("terminal note") is appended by agent_conversation's _build_history @@ -134,6 +134,33 @@ def _is_bridge_entry(text) -> bool: """ +# Appended LAST to the live turn, every step, for a +# non-Claude model. The coder treats its notes tool as a think step: it calls +# `reasoning`, ends its turn to wait for the result and acts on the NEXT turn - +# one wasted LLM call per step. A rule in the live message moves batching far +# more than the same rule in the system prompt. Live message only, never +# persisted, so the cached prefix is unchanged. +# +# Claude gets NO live reminder: the same sentence about `agent_memory`, on top +# of this prompt's notes-for-your-future-self language, made Anthropic refuse +# the whole request (stop_reason "refusal", category reasoning_extraction; +# measured 2026-10-21 on Opus 5.5: refused with it, answered without it). Its +# batching rides on claude_prompt.md, the `agent_memory` description and the +# notes pause below. +BATCH_REMINDER = ( + "\n\nThis turn must carry the `reasoning` call AND the action calls that do its " + "\"Doing\" in this one response. A step that thinks is no exception: think in `reasoning`, " + "then make the calls for its \"Doing\" in the same response. `reasoning` alone is not a step: its " + "result is only `ok`, so never end the turn after it. The one exception is the final review " + "right before `exit` (and `exit` is always alone).") + +# A notes-only turn (the notes call and nothing else) is answered with a result +# that says no action ran. After NOTES_PAUSE_AFTER of them in a row the notes +# tool is taken out of the tool list for the next NOTES_PAUSE_STEPS steps, so +# the model can only call the tools that do the work; then it comes back. +NOTES_PAUSE_AFTER = 2 +NOTES_PAUSE_STEPS = 5 + class AgentService: """CLI Agent Service - Agentic loop only""" @@ -591,6 +618,7 @@ def _run_agent_loop(self, task: str) -> str: is_first = True json_fail_count = 0 # Track consecutive JSON parse failures (max 3 before exit) reasoning_only_count = 0 # Consecutive steps that called `reasoning` and nothing else + notes_paused_until = 0 # last step number with the notes tool off (see NOTES_PAUSE_AFTER) web_memory_index = None # tool slot of the web step to backfill with the digest's scratchpad web_pending = None # [(call_id, compacted dict)] of the web step, for the digest fold is_web_digest = False # True only on the iteration that digests a web result @@ -752,6 +780,13 @@ def _run_agent_loop(self, task: str) -> str: "web call's durable `result` in ; the raw text below is " "shown once and never stored, so anything you do not record is gone.\n" f"\n\n{pending_web}") + # The notes call and the action calls belong in ONE response (see + # BATCH_REMINDER). Last in the live message. Not for Claude (see + # there), and not while the notes tool is paused: the reminder + # would ask for a call the tool list does not offer. + notes_on = step_number > notes_paused_until + if not self._claude and notes_on: + content += BATCH_REMINDER messages.append({"role": "user", "content": content}) # Prompt caching: mark the newest PERSISTENT turn — the last tool @@ -783,6 +818,10 @@ def _run_agent_loop(self, task: str) -> str: self.last_messages = messages try: + # The notes tool rides only while it is not paused (see + # NOTES_PAUSE_AFTER); the provider reads its list every request. + self.llm.show_notes_tool(notes_on) + # Call LLM — show a working spinner on a TTY so the terminal doesn't # look frozen during the (multi-second) LLM wait. if sys.stdout.isatty(): @@ -830,9 +869,16 @@ def _run_agent_loop(self, task: str) -> str: json.dumps(raw_response, indent=2, ensure_ascii=False, default=str), step_number, ) + # While the notes tool is paused it is not offered, so a call + # to it is rejected like any unknown tool (the driver does the + # same for its hidden screen tools); the error names the tools + # that ARE offered this step. + allowed = CODER_TOOL_NAMES_CLAUDE if self._claude else CODER_TOOL_NAMES + if not notes_on: + allowed = allowed.difference({self._notes_tool}) actions, calls, rejects, track = tool_calls_to_steps( raw_response.get("tool_calls"), - allowed=CODER_TOOL_NAMES_CLAUDE if self._claude else None, + allowed=allowed, defaults_map=ACTION_DEFAULTS_CLAUDE if self._claude else None, track_params=CLAUDE_REASONING_FIELDS if self._claude else REASONING_FIELDS) if self._claude: @@ -869,18 +915,32 @@ def _run_agent_loop(self, task: str) -> str: if not actions and not rejects: if note_ids: - # A reasoning-only step: the model chose to think - # before acting. Accepted as a normal step - the turn - # is recorded and its call answered like any other. - # Only a RUN of them is bounded: from the third in a - # row, the result carries a one-line reminder to act. + # A notes-only step: the model ended its turn after the + # notes call. The turn is recorded and its call answered + # like any other, but from the FIRST one the result says + # no action ran, so the next turn makes the calls this + # one skipped. The NOTES_PAUSE_AFTER-th in a row takes + # the notes tool out of the tool list for the next + # NOTES_PAUSE_STEPS steps (see show_notes_tool above). reasoning_only_count += 1 - if reasoning_only_count >= 3: - note_results = [{"tool_call_id": i, "content": render_tool_response({ - "status": "success", "action": self._notes_tool, - "message": f"{reasoning_only_count} steps in a row without an action: " - "call a tool that does the work in this turn."})} - for i in note_ids] + if reasoning_only_count >= NOTES_PAUSE_AFTER: + notes_paused_until = step_number + NOTES_PAUSE_STEPS + reasoning_only_count = 0 + message = (f"No action ran, {NOTES_PAUSE_AFTER} steps in a row: `{self._notes_tool}` " + f"is off for the next {NOTES_PAUSE_STEPS} steps. Call the tools that do " + "the work directly (or `exit` if the work is done and reviewed); it " + "comes back after that.") + if sys.stdout.isatty(): + safe_print(f"⚠️ {NOTES_PAUSE_AFTER} notes-only turns in a row: " + f"`{self._notes_tool}` off for the next {NOTES_PAUSE_STEPS} steps") + else: + message = (f"No action ran ({reasoning_only_count} step(s) in a row): " + f"`{self._notes_tool}` alone does nothing. If this was the final " + "review and it passed, call `exit` now; otherwise make the calls for " + "its \"Doing\" now, in the same response as the notes.") + note_results = [{"tool_call_id": i, "content": render_tool_response({ + "status": "success", "action": self._notes_tool, "message": message})} + for i in note_ids] normalized = encode_step(resp_text, wire_calls_from(all_calls), raw_response.get("provider_meta")) display_payload = json.dumps( diff --git a/AutoCua/mac/agent/coder/system_prompt.md b/AutoCua/mac/agent/coder/system_prompt.md index d7f303e..c83d236 100644 --- a/AutoCua/mac/agent/coder/system_prompt.md +++ b/AutoCua/mac/agent/coder/system_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not thinking where nothing is being decided -- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. A turn may be `reasoning` alone when you need to think before you act; the next turn then does the work. +- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `reasoning` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message, on a step that thinks too. The one exception is the final review right before `exit`. - `memory` and `next_goal` are filled every step. `thinking` comes in bursts: FULL when you look ahead (it ends with a ROUTE), RECOVERY after a failure, BRIEF on a surprise, and "" (or omitted) on every route step in between. Most steps are route steps. - Your thinking is NOT a reply to the user: the user never reads it mid-run. It is your private reasoning trace, written for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -290,10 +290,11 @@ Prove every code change correct with concrete execution output before treating i - All tool calls in one step execute sequentially, in the order you emit them. - The same tool can be called multiple times within a single step. - Batch what is independent: parallel minions, several `view`s, an edit plus the run that checks it. Do not batch past a guard — a call that only makes sense once you have inspected an earlier result belongs to the next step. +- In one go you can iterate at most 100 times. The loop stops at iteration 100 and reports the task incomplete with the ToDo status, so budget for it: explore with parallel minions, batch independent calls, and start verification early enough to finish inside the cap. -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` are not shown they are left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` or the actions are not shown they are left out for space only: you fill both every step, and every step before [G] carries its action calls in the same turn as `reasoning`. [A] TASK START — FULL, short thinking: "JUDGE: task start, nothing to judge. UNDERSTAND: The user wants the scratchpad cache to stop growing without bound. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. It edits code I have not seen, and a cache is shared by nature, so it has callers → FULL. DECIDE: I cannot choose an approach until I know three things: where the cache lives and how it is read and written; who uses it, and whether anyone reaches into the underlying structure directly; how this project runs tests and what conventions that file follows. Independent questions → three parallel minions, each asked for path:line anchors. ROUTE: 1. dispatch 3 minions, if all three reports return with path:line anchors → think: build the model, choose the approach, write the plan." diff --git a/AutoCua/mac/agent/minions/claude_prompt.md b/AutoCua/mac/agent/minions/claude_prompt.md index 41209e4..5e58810 100644 --- a/AutoCua/mac/agent/minions/claude_prompt.md +++ b/AutoCua/mac/agent/minions/claude_prompt.md @@ -289,6 +289,7 @@ Rules for the report: - Do not re-read files you've already viewed in `` - line numbers don't drift here (read-only). - Avoid `find /` on huge trees; use `glob` with a specific pattern instead. - Prefer `grep --files_with_matches` first to scope, then `content` mode on the narrow set. +- Up to 100 iterations are allowed in one run. Reaching the cap ends the run with whatever `` already holds, so record each confirmed finding as you go and `exit` as soon as the report is complete. diff --git a/AutoCua/mac/agent/minions/service.py b/AutoCua/mac/agent/minions/service.py index 54601a2..028f60b 100644 --- a/AutoCua/mac/agent/minions/service.py +++ b/AutoCua/mac/agent/minions/service.py @@ -82,7 +82,7 @@ def safe_print(*args, **kwargs): # Minion runs are bounded — its job is location-finding, not multi-step coding. -MAX_MINION_ITERATIONS = 30 +MAX_MINION_ITERATIONS = 100 # Appended to the system prompt for an `async_tools` model (GPT-6 Astra, see diff --git a/AutoCua/mac/agent/minions/system_prompt.md b/AutoCua/mac/agent/minions/system_prompt.md index 8a4b8c8..4bce49a 100644 --- a/AutoCua/mac/agent/minions/system_prompt.md +++ b/AutoCua/mac/agent/minions/system_prompt.md @@ -315,6 +315,7 @@ Rules for the report: - Do not re-read files you've already viewed in `` - line numbers don't drift here (read-only). - Avoid `find /` on huge trees; use `glob` with a specific pattern instead. - Prefer `grep --files_with_matches` first to scope, then `content` mode on the narrow set. +- Up to 100 iterations are allowed in one run. Reaching the cap ends the run with whatever `` already holds, so record each confirmed finding as you go and `exit` as soon as the report is complete. diff --git a/AutoCua/mac/controller/tool/ios_agent.py b/AutoCua/mac/controller/tool/ios_agent.py index 4f97474..c960b45 100644 --- a/AutoCua/mac/controller/tool/ios_agent.py +++ b/AutoCua/mac/controller/tool/ios_agent.py @@ -400,7 +400,15 @@ def _watch(self): # its activate() killed this link's forward. WDA still answers on # the port, but for that run, so status() alone cannot tell. if not wda_session.holds(self): - self._fail("another AutoCua run took over the phone") + # A session that died says so in status() (the re-plug watchdog + # gave up, a runner that could not start); a take-over does + # not: WDA still answers there, for the other run. + try: + st = wda_session.status() + except Exception: + st = {} + self._fail(_reason(st) if st.get("state") == "error" + else "another AutoCua run took over the phone") return try: st = wda_session.status() @@ -417,8 +425,15 @@ def _watch(self): # its own. Off the cable is what counts. plugged = plugged_in_udids() if plugged is not None and self.udid not in plugged: - self._fail("the phone was unplugged") - return + # A re-plug (QuickTime's screen mirror picking the phone) takes + # it off the cable for well under a second and the session + # starts itself again (session.py's watchdog): look once more + # before calling it unplugged. + time.sleep(3) + plugged = plugged_in_udids() + if plugged is not None and self.udid not in plugged: + self._fail("the phone was unplugged") + return def _fail(self, reason: str): with self._lock: diff --git a/AutoCua/mac/tool_registry/service.py b/AutoCua/mac/tool_registry/service.py index 676ec97..2133cd7 100644 --- a/AutoCua/mac/tool_registry/service.py +++ b/AutoCua/mac/tool_registry/service.py @@ -112,16 +112,19 @@ _tool("reasoning", REASONING_FIELDS, r"""Your notes to yourself for this step. Call it FIRST in every step, then the calls that do the work, all in the same turn. The harness does nothing with it (its result is just `ok`), but the call is replayed in , so your future self can read the reasoning behind every step. - `memory` and `next_goal` are filled every step. `thinking` is not: you think in bursts (see ). A FULL thinking ends with a ROUTE of the next 3-5 steps; on each step of that route omit `thinking` (or pass ""), and think again only when the route is used up, a guard fails, or the route said "think". -- A step may be `reasoning` alone when you need to think before you act; the next step then does the work. Do not chain several such steps. +- It never ends a turn: the calls that do this step's "Doing" always follow it in the SAME message, on a step that thinks too. A message holding only this call is a wasted turn. The one exception is the final review right before `exit`. - Not used on the `exit` step: `exit` is always the only call of its turn. - Format: reasoning {"thinking": "", "memory": "...", "next_goal": "..."} -- Examples: +- Examples (each `reasoning` call is followed, in the same turn, by the calls that do its "Doing"): 1. Route step (no thinking; "Doing" is the next route line): reasoning {"memory": "Last step worked: replace applied at service.py:233, no mismatch. Verification script ready at ./.AutoCua_verify/test_cache.py.", "next_goal": "Doing: run ./.AutoCua_verify/test_cache.py (ToDo: #3 verify). If output shows 6/6 cases pass → Next: think: judge proof, clean up ./.AutoCua_verify/, mark #3 done."} + + shell {"command": "python ./.AutoCua_verify/test_cache.py", "input": ""} 2. Planning moment (FULL thinking, ends with the ROUTE): reasoning {"thinking": "JUDGE: PASS, three reports, all anchored, and they agree. UNDERSTAND: known: the cache is a plain dict at service.py:233, put() inserts and nothing evicts; callers api.py:41 and worker.py:88 use only get/put. Assumed: no direct access to the dict elsewhere. DECIDE: OrderedDict inside get/put with a CACHE_MAX constant, no new dependency, callers untouched; the part most likely to be wrong is recency on a hit, so the proof tests exactly that. ROUTE: 1. plan set + todo_list, if both accepted. 2. view service.py:220-250, if the dict and put() sit at the reported anchors. 3. replace put()/get(), add CACHE_MAX, bottom-up, if all apply with no mismatch. 4. write + run ./.AutoCua_verify/test_cache.py, if output shows 4/4 cases pass → think: judge the proof.", "memory": "Last step worked: 3 minion reports with anchors, consistent. Cache = dict at service.py:233, callers api.py:41 and worker.py:88 use only get/put. Approach: OrderedDict LRU inside get/put. Unverified: no direct dict access elsewhere.", "next_goal": "Doing: write the plan (`plan` set) and derive the ToDo (ToDo: none yet — plan). If both are accepted → Next: view service.py:220-250 to confirm the anchors."} + + plan {"op": "set", "from": 0, "to": 0, "value": "..."} + todo_list {"value": "..."} 3. Recovery after a failed guard: - reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."}""", + reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."} + + replace {"path": "test_cache.py", "line": 3, "old_block": "...", "new_block": "..."} + shell {"command": "python -m pytest test_cache.py", "input": ""}""", optional=REASONING_OPTIONAL), _tool("shell", {"command": {"type": "string"}, diff --git a/AutoCua/ui/container/agent/agent.js b/AutoCua/ui/container/agent/agent.js index e248f6d..daf225d 100644 --- a/AutoCua/ui/container/agent/agent.js +++ b/AutoCua/ui/container/agent/agent.js @@ -1743,8 +1743,10 @@ scratchpad: { shape: 'book', label: 'saving to agent memory' }, done: { shape: 'loader', label: 'done', tick: true }, // reuse the packet tick vault: { shape: 'vault', label: 'unlocking the vault' }, - // mobile (iOS) action names — the iOS driver sends its flat action types + // mobile (iOS) action names — the iOS driver sends its flat action types. + // click taps an element by [id], ground_click a raw point: the same finger. click: { shape: 'tap', label: 'tapping the screen' }, + ground_click: { shape: 'tap', label: 'tapping the screen' }, // web (browser) tool names — the browser driver's own set, from // AutoCua/web/controller/view.rs. `input`, `scroll`, `wait`, // `scratchpad`, `todo_list`, `update_todo` and `done` are already above diff --git a/AutoCua/windows/agent/coder/claude_prompt.md b/AutoCua/windows/agent/coder/claude_prompt.md index 9cead5d..08f537c 100644 --- a/AutoCua/windows/agent/coder/claude_prompt.md +++ b/AutoCua/windows/agent/coder/claude_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not re-deciding where nothing has changed ( -- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. +- Your turn = ONE `agent_memory` call first (`memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `agent_memory` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message. For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously rather than sequentially. - Both fields are filled every step. Decide first, then write: nothing about how you decided goes into either field — `memory` keeps only what must survive the step (the verdict, a corrected belief, the key context, and a Route when one is written), `next_goal` keeps this step's move and its guard, and the detail behind a decision (findings, approach, risks, proof) goes into the plan, when the task has one. Most steps are route steps, where nothing new is decided and `memory` stays short. - Neither field is a reply to the user: the user never reads them mid-run. They are notes for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -268,7 +268,7 @@ Prove every code change correct with concrete execution output before treating i -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` is not shown it is left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of `memory`, `next_goal` and the plan — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `next_goal` or the actions are not shown they are left out for space only: you fill both fields every step, and every step before [G] carries its action calls in the same turn as `agent_memory`. [A] TASK START — short memory: "Task start. Request: switch the scratchpad cache to an LRU. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. FULL: it edits code I have not seen, and a cache is shared by nature, so it has callers. Route: 1. dispatch 3 parallel minions, if all three reports return with path:line anchors → decide: build the model, choose the approach, write the plan." diff --git a/AutoCua/windows/agent/coder/service.py b/AutoCua/windows/agent/coder/service.py index e155e36..5f40f2a 100644 --- a/AutoCua/windows/agent/coder/service.py +++ b/AutoCua/windows/agent/coder/service.py @@ -13,7 +13,7 @@ from ...tool_registry.service import (LLMManager, tool_calls_to_steps, REASONING_FIELDS, drop_bundled_terminal, CLAUDE_REASONING_FIELDS, ACTION_DEFAULTS_CLAUDE, - CODER_TOOL_NAMES_CLAUDE) + CODER_TOOL_NAMES, CODER_TOOL_NAMES_CLAUDE) from ...controller.view import ControllerView from ....memory_compression.controller import CompressionController from .view import (CLIAgentResponseFormatter, render_tool_response, snapshot_turn, @@ -132,6 +132,33 @@ def _is_bridge_entry(text) -> bool: """ +# Appended LAST to the live turn, every step, for a +# non-Claude model. The coder treats its notes tool as a think step: it calls +# `reasoning`, ends its turn to wait for the result and acts on the NEXT turn - +# one wasted LLM call per step. A rule in the live message moves batching far +# more than the same rule in the system prompt. Live message only, never +# persisted, so the cached prefix is unchanged. +# +# Claude gets NO live reminder: the same sentence about `agent_memory`, on top +# of this prompt's notes-for-your-future-self language, made Anthropic refuse +# the whole request (stop_reason "refusal", category reasoning_extraction; +# measured 2026-10-21 on Opus 5.5: refused with it, answered without it). Its +# batching rides on claude_prompt.md, the `agent_memory` description and the +# notes pause below. +BATCH_REMINDER = ( + "\n\nThis turn must carry the `reasoning` call AND the action calls that do its " + "\"Doing\" in this one response. A step that thinks is no exception: think in `reasoning`, " + "then make the calls for its \"Doing\" in the same response. `reasoning` alone is not a step: its " + "result is only `ok`, so never end the turn after it. The one exception is the final review " + "right before `exit` (and `exit` is always alone).") + +# A notes-only turn (the notes call and nothing else) is answered with a result +# that says no action ran. After NOTES_PAUSE_AFTER of them in a row the notes +# tool is taken out of the tool list for the next NOTES_PAUSE_STEPS steps, so +# the model can only call the tools that do the work; then it comes back. +NOTES_PAUSE_AFTER = 2 +NOTES_PAUSE_STEPS = 5 + class AgentService: """CLI Agent Service - Agentic loop only""" @@ -596,6 +623,7 @@ def _run_agent_loop(self, task: str) -> str: is_first = True json_fail_count = 0 # Track consecutive JSON parse failures (max 3 before exit) reasoning_only_count = 0 # Consecutive steps that called `reasoning` and nothing else + notes_paused_until = 0 # last step number with the notes tool off (see NOTES_PAUSE_AFTER) web_memory_index = None # tool slot of the web step to backfill with the digest's scratchpad web_pending = None # [(call_id, compacted dict)] of the web step, for the digest fold is_web_digest = False # True only on the iteration that digests a web result @@ -757,6 +785,13 @@ def _run_agent_loop(self, task: str) -> str: "web call's durable `result` in ; the raw text below is " "shown once and never stored, so anything you do not record is gone.\n" f"\n\n{pending_web}") + # The notes call and the action calls belong in ONE response (see + # BATCH_REMINDER). Last in the live message. Not for Claude (see + # there), and not while the notes tool is paused: the reminder + # would ask for a call the tool list does not offer. + notes_on = step_number > notes_paused_until + if not self._claude and notes_on: + content += BATCH_REMINDER messages.append({"role": "user", "content": content}) # Prompt caching: mark the newest PERSISTENT turn — the last tool @@ -788,6 +823,10 @@ def _run_agent_loop(self, task: str) -> str: self.last_messages = messages try: + # The notes tool rides only while it is not paused (see + # NOTES_PAUSE_AFTER); the provider reads its list every request. + self.llm.show_notes_tool(notes_on) + # Call LLM — show a working spinner on a TTY so the terminal doesn't # look frozen during the (multi-second) LLM wait. if sys.stdout.isatty(): @@ -835,9 +874,16 @@ def _run_agent_loop(self, task: str) -> str: json.dumps(raw_response, indent=2, ensure_ascii=False, default=str), step_number, ) + # While the notes tool is paused it is not offered, so a call + # to it is rejected like any unknown tool (the driver does the + # same for its hidden screen tools); the error names the tools + # that ARE offered this step. + allowed = CODER_TOOL_NAMES_CLAUDE if self._claude else CODER_TOOL_NAMES + if not notes_on: + allowed = allowed.difference({self._notes_tool}) actions, calls, rejects, track = tool_calls_to_steps( raw_response.get("tool_calls"), - allowed=CODER_TOOL_NAMES_CLAUDE if self._claude else None, + allowed=allowed, defaults_map=ACTION_DEFAULTS_CLAUDE if self._claude else None, track_params=CLAUDE_REASONING_FIELDS if self._claude else REASONING_FIELDS) if self._claude: @@ -874,18 +920,32 @@ def _run_agent_loop(self, task: str) -> str: if not actions and not rejects: if note_ids: - # A reasoning-only step: the model chose to think - # before acting. Accepted as a normal step - the turn - # is recorded and its call answered like any other. - # Only a RUN of them is bounded: from the third in a - # row, the result carries a one-line reminder to act. + # A notes-only step: the model ended its turn after the + # notes call. The turn is recorded and its call answered + # like any other, but from the FIRST one the result says + # no action ran, so the next turn makes the calls this + # one skipped. The NOTES_PAUSE_AFTER-th in a row takes + # the notes tool out of the tool list for the next + # NOTES_PAUSE_STEPS steps (see show_notes_tool above). reasoning_only_count += 1 - if reasoning_only_count >= 3: - note_results = [{"tool_call_id": i, "content": render_tool_response({ - "status": "success", "action": self._notes_tool, - "message": f"{reasoning_only_count} steps in a row without an action: " - "call a tool that does the work in this turn."})} - for i in note_ids] + if reasoning_only_count >= NOTES_PAUSE_AFTER: + notes_paused_until = step_number + NOTES_PAUSE_STEPS + reasoning_only_count = 0 + message = (f"No action ran, {NOTES_PAUSE_AFTER} steps in a row: `{self._notes_tool}` " + f"is off for the next {NOTES_PAUSE_STEPS} steps. Call the tools that do " + "the work directly (or `exit` if the work is done and reviewed); it " + "comes back after that.") + if sys.stdout.isatty(): + safe_print(f"⚠️ {NOTES_PAUSE_AFTER} notes-only turns in a row: " + f"`{self._notes_tool}` off for the next {NOTES_PAUSE_STEPS} steps") + else: + message = (f"No action ran ({reasoning_only_count} step(s) in a row): " + f"`{self._notes_tool}` alone does nothing. If this was the final " + "review and it passed, call `exit` now; otherwise make the calls for " + "its \"Doing\" now, in the same response as the notes.") + note_results = [{"tool_call_id": i, "content": render_tool_response({ + "status": "success", "action": self._notes_tool, "message": message})} + for i in note_ids] normalized = encode_step(resp_text, wire_calls_from(all_calls), raw_response.get("provider_meta")) display_payload = json.dumps( diff --git a/AutoCua/windows/agent/coder/system_prompt.md b/AutoCua/windows/agent/coder/system_prompt.md index d47719d..a0801bd 100644 --- a/AutoCua/windows/agent/coder/system_prompt.md +++ b/AutoCua/windows/agent/coder/system_prompt.md @@ -81,7 +81,7 @@ Quality over speed. Time is saved by not thinking where nothing is being decided -- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. A turn may be `reasoning` alone when you need to think before you act; the next turn then does the work. +- Your turn = ONE `reasoning` call first (`thinking`, `memory`, `next_goal`), then the action calls that do the work, all in the same turn. Action tools carry only their own arguments. `reasoning` has no result and nothing waits on it, so it never ends a turn: the action call(s) for this step's "Doing" - one is enough - always follow it in the SAME message, on a step that thinks too. The one exception is the final review right before `exit`. - `memory` and `next_goal` are filled every step. `thinking` comes in bursts: FULL when you look ahead (it ends with a ROUTE), RECOVERY after a failure, BRIEF on a surprise, and "" (or omitted) on every route step in between. Most steps are route steps. - Your thinking is NOT a reply to the user: the user never reads it mid-run. It is your private reasoning trace, written for your future self reading this conversation — so that self can see what you knew, what you assumed, and why you chose. @@ -295,7 +295,7 @@ Prove every code change correct with concrete execution output before treating i -One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` are not shown they are left out for space only: you fill both every step. +One task, start to finish, showing the cycle. Request: "switch the scratchpad cache to an LRU". It shows the SHAPE and QUALITY of the thinking — its content (three minions, OrderedDict, four cases) belongs to that task, not to yours. Where `memory`/`next_goal` or the actions are not shown they are left out for space only: you fill both every step, and every step before [G] carries its action calls in the same turn as `reasoning`. [A] TASK START — FULL, short thinking: "JUDGE: task start, nothing to judge. UNDERSTAND: The user wants the scratchpad cache to stop growing without bound. Done = the cache never holds more than a fixed number of entries, the entry evicted is the least recently used one, and every existing caller keeps working unchanged. It edits code I have not seen, and a cache is shared by nature, so it has callers → FULL. DECIDE: I cannot choose an approach until I know three things: where the cache lives and how it is read and written; who uses it, and whether anyone reaches into the underlying structure directly; how this project runs tests and what conventions that file follows. Independent questions → three parallel minions, each asked for path:line anchors. ROUTE: 1. dispatch 3 minions, if all three reports return with path:line anchors → think: build the model, choose the approach, write the plan." diff --git a/AutoCua/windows/tool_registry/service.py b/AutoCua/windows/tool_registry/service.py index 217e2ca..732f53e 100644 --- a/AutoCua/windows/tool_registry/service.py +++ b/AutoCua/windows/tool_registry/service.py @@ -112,16 +112,19 @@ _tool("reasoning", REASONING_FIELDS, r"""Your notes to yourself for this step. Call it FIRST in every step, then the calls that do the work, all in the same turn. The harness does nothing with it (its result is just `ok`), but the call is replayed in , so your future self can read the reasoning behind every step. - `memory` and `next_goal` are filled every step. `thinking` is not: you think in bursts (see ). A FULL thinking ends with a ROUTE of the next 3-5 steps; on each step of that route omit `thinking` (or pass ""), and think again only when the route is used up, a guard fails, or the route said "think". -- A step may be `reasoning` alone when you need to think before you act; the next step then does the work. Do not chain several such steps. +- It never ends a turn: the calls that do this step's "Doing" always follow it in the SAME message, on a step that thinks too. A message holding only this call is a wasted turn. The one exception is the final review right before `exit`. - Not used on the `exit` step: `exit` is always the only call of its turn. - Format: reasoning {"thinking": "", "memory": "...", "next_goal": "..."} -- Examples: +- Examples (each `reasoning` call is followed, in the same turn, by the calls that do its "Doing"): 1. Route step (no thinking; "Doing" is the next route line): reasoning {"memory": "Last step worked: replace applied at service.py:233, no mismatch. Verification script ready at .\\.AutoCua_verify\\test_cache.py.", "next_goal": "Doing: run .\\.AutoCua_verify\\test_cache.py (ToDo: #3 verify). If output shows 6/6 cases pass → Next: think: judge proof, clean up .\\.AutoCua_verify\\, mark #3 done."} + + shell {"command": "python .\\.AutoCua_verify\\test_cache.py", "input": ""} 2. Planning moment (FULL thinking, ends with the ROUTE): reasoning {"thinking": "JUDGE: PASS, three reports, all anchored, and they agree. UNDERSTAND: known: the cache is a plain dict at service.py:233, put() inserts and nothing evicts; callers api.py:41 and worker.py:88 use only get/put. Assumed: no direct access to the dict elsewhere. DECIDE: OrderedDict inside get/put with a CACHE_MAX constant, no new dependency, callers untouched; the part most likely to be wrong is recency on a hit, so the proof tests exactly that. ROUTE: 1. plan set + todo_list, if both accepted. 2. view service.py:220-250, if the dict and put() sit at the reported anchors. 3. replace put()/get(), add CACHE_MAX, bottom-up, if all apply with no mismatch. 4. write + run .\\.AutoCua_verify\\test_cache.py, if output shows 4/4 cases pass → think: judge the proof.", "memory": "Last step worked: 3 minion reports with anchors, consistent. Cache = dict at service.py:233, callers api.py:41 and worker.py:88 use only get/put. Approach: OrderedDict LRU inside get/put. Unverified: no direct dict access elsewhere.", "next_goal": "Doing: write the plan (`plan` set) and derive the ToDo (ToDo: none yet — plan). If both are accepted → Next: view service.py:220-250 to confirm the anchors."} + + plan {"op": "set", "from": 0, "to": 0, "value": "..."} + todo_list {"value": "..."} 3. Recovery after a failed guard: - reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."}""", + reasoning {"thinking": "pytest exited 1 with ImportError at test_cache.py:3; the import path is stale after the move. Narrowest fix: correct the import and re-run. Guard: pytest exits 0.", "memory": "Last step failed: pytest exited 1, ImportError in test_cache.py line 3. The code change at service.py:233 is in place.", "next_goal": "Doing: fix the stale import path in test_cache.py (ToDo: #2 fix caching). If pytest exits 0 → Next: re-run the verification script."} + + replace {"path": "test_cache.py", "line": 3, "old_block": "...", "new_block": "..."} + shell {"command": "python -m pytest test_cache.py", "input": ""}""", optional=REASONING_OPTIONAL), _tool("shell", {"command": {"type": "string"}, diff --git a/README.md b/README.md index 83e8e97..fb59d11 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-AutoCua +AutoCua

AutoCua