From b66b99ddd862117f723b4563ffae7001eebf1e23 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 3 Jul 2026 21:31:43 +0000 Subject: [PATCH 1/2] focuschain-ab: make P3 focus_chain pure append-only (no screenshot dropping) Treatment arm for the focus-chain A/B: identical context to keep_all (history never edited, only_n_most_recent_images pinned to None) plus the model-maintained task_notes echoed at the end of each user turn. Prompt and tool-schema text no longer claim screenshots are dropped. Co-Authored-By: Claude Fable 5 --- mm_agents/anthropic/context_policies.py | 20 ++++++++++---------- mm_agents/anthropic/main.py | 17 ++++++++--------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/mm_agents/anthropic/context_policies.py b/mm_agents/anthropic/context_policies.py index b663fff4..bbb58ded 100644 --- a/mm_agents/anthropic/context_policies.py +++ b/mm_agents/anthropic/context_policies.py @@ -10,11 +10,11 @@ Four flag-selectable policies for the cache-aware context experiment: context exceeds 60% of `context_budget_tokens`, flatten the oldest ~80% of messages to text, summarize them with an LLM call, and replace them with a block. - focus_chain (P3): append-only notepad. The model returns a running - `task_notes` field in each tool call; the harness appends - the latest note at the END of the next user turn (never - rewriting earlier text) and drops old screenshots in - cache-aligned chunks. + focus_chain (P3, focuschain-ab variant): append-only notepad. The model + returns a running `task_notes` field in each tool call; + the harness appends the latest note at the END of the next + user turn. History is NEVER edited — no screenshot + dropping, no rewrites; context is keep_all + notes. Adapted from mm_agents/pointer/llm_context_manager.py (not modified in place). All summarizer API usage is returned to the caller so it can be logged into @@ -57,9 +57,9 @@ FOCUS_CHAIN_PROMPT_ADDITION = ( "top-level `task_notes` field (alongside `actions`) with a concise running " "note (max ~120 words) of your task state: the goal, key facts discovered so " "far (exact values, file paths, cell references, settings), what is already " - "done, and what remains. Older screenshots are removed from your context as " - "the task grows; your latest task_notes are echoed back to you and, together " - "with recent screenshots, are your only memory of earlier steps. Keep them " + "done, and what remains. Your latest task_notes are echoed back to you at " + "the end of the next user turn — use them to stay focused on the goal and " + "to avoid re-deriving facts you already discovered. Keep them " "self-contained and up to date." ) @@ -67,8 +67,8 @@ TASK_NOTES_SCHEMA_PROPERTY = { "type": "string", "description": ( "REQUIRED running note of task state (max ~120 words): goal, key facts " - "(exact values, paths, cell refs), what is done, what remains. This is " - "your persistent memory; older screenshots get dropped from context." + "(exact values, paths, cell refs), what is done, what remains. It is " + "echoed back to you next turn as a persistent focus aid." ), } diff --git a/mm_agents/anthropic/main.py b/mm_agents/anthropic/main.py index 734eba3c..f1aeab1c 100644 --- a/mm_agents/anthropic/main.py +++ b/mm_agents/anthropic/main.py @@ -102,21 +102,20 @@ class AnthropicAgent: self.context_policy = context_policy self.context_budget_tokens = context_budget_tokens # Per-policy defaults: image_trim keeps more images, trims in small - # chunks; focus_chain keeps fewer (notes carry the memory) in bigger, - # cache-friendlier chunks. + # chunks. focus_chain is pure append-only in this experiment + # (focuschain-ab): notes ride at the end of each user turn and NO + # screenshots are ever dropped — history is identical to keep_all. if context_policy == POLICY_IMAGE_TRIM: self.keep_n_images = keep_n_images or 8 self.image_chunk_size = image_chunk_size or 4 - elif context_policy == POLICY_FOCUS_CHAIN: - self.keep_n_images = keep_n_images or 4 - self.image_chunk_size = image_chunk_size or 8 else: self.keep_n_images = keep_n_images self.image_chunk_size = image_chunk_size - if context_policy == POLICY_KEEP_ALL: - # Control arm: never edit history (the constructor default of 10 - # would silently trim on 40-step runs). + if context_policy in (POLICY_KEEP_ALL, POLICY_FOCUS_CHAIN): + # Control arm / pure focus chain: never edit history (the + # constructor default of 10 would silently trim on long runs). self.only_n_most_recent_images = None + self.keep_n_images = None self.latest_task_note: Optional[str] = None self.compaction_count = 0 self._pending_context_usage: Optional[dict] = None @@ -685,7 +684,7 @@ class AnthropicAgent: # --- Context-management policy dispatch (cache-aware-context experiment) --- self._pending_context_event = None - if self.context_policy in (POLICY_IMAGE_TRIM, POLICY_FOCUS_CHAIN): + if self.context_policy == POLICY_IMAGE_TRIM: images_before = context_policies.count_images(self.messages) _maybe_filter_to_n_most_recent_images( self.messages, -- 2.43.0 From 2d8ca929eb5c4f7ef201efbd6d108bcfea5ad71f Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 4 Jul 2026 22:46:25 +0000 Subject: [PATCH 2/2] focus_chain_v2: observation-gated structured ledger + DONE-gate Fixes the four trace-verified flaws of the piggybacked focus chain while keeping its speed mechanism (per-call note + end-of-context echo) intact: - task_notes becomes a structured object: constraints (verbatim, write-once), remaining (primary), next_check, verified_done - tense rule: verified_done only for items confirmed by a screenshot RECEIVED AFTER the actions that produced them - DONE-gate: a no-tool-call completion while the ledger lists remaining items is rejected once (converted to a forced fresh screenshot + feedback turn); the next completion is always accepted. Telemetry via context_event done_gate_fired. History stays append-only; cache-safe. Pre-registered targets vs focuschain-ab data: long-task DONE-precision >= 0.75 (v1: 0.52, control: 0.82) with steps/run <= 31 (v1: 29.8, control: 35.4). Co-Authored-By: Claude Fable 5 --- mm_agents/anthropic/context_policies.py | 67 ++++++++++++++ mm_agents/anthropic/main.py | 115 ++++++++++++++++++++++-- scripts/python/run_multienv_claude.py | 4 +- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/mm_agents/anthropic/context_policies.py b/mm_agents/anthropic/context_policies.py index bbb58ded..e464bfa5 100644 --- a/mm_agents/anthropic/context_policies.py +++ b/mm_agents/anthropic/context_policies.py @@ -33,11 +33,13 @@ POLICY_KEEP_ALL = "keep_all" POLICY_IMAGE_TRIM = "image_trim" POLICY_SUMMARIZE = "summarize" POLICY_FOCUS_CHAIN = "focus_chain" +POLICY_FOCUS_CHAIN_V2 = "focus_chain_v2" ALL_POLICIES = ( POLICY_KEEP_ALL, POLICY_IMAGE_TRIM, POLICY_SUMMARIZE, POLICY_FOCUS_CHAIN, + POLICY_FOCUS_CHAIN_V2, ) # 960x540 screenshot ~= (960*540)/750 ~= 691 tokens; round up a little for @@ -72,6 +74,71 @@ TASK_NOTES_SCHEMA_PROPERTY = { ), } +# --- P3 v2 (focuschain-v2 experiment): observation-gated structured ledger --- + +FOCUS_CHAIN_V2_PROMPT_ADDITION = ( + "TASK LEDGER (IMPORTANT): In EVERY computer tool call, you MUST fill the " + "top-level `task_notes` object (alongside `actions`):\n" + "- `constraints`: requirements copied VERBATIM from the instruction " + "(including any 'do not ...' clauses). Write once, keep unchanged.\n" + "- `remaining`: the ordered list of everything not yet VERIFIED complete. " + "This is your primary field — keep it accurate above all else.\n" + "- `next_check`: what to confirm on the next screenshot to trust your " + "latest actions worked.\n" + "- `verified_done`: ONLY items you have confirmed on a screenshot RECEIVED " + "AFTER the actions that produced them. NEVER move an item here in the same " + "call as the actions that do it — the outcome does not exist yet. When " + "unsure whether something worked, it belongs in `remaining`.\n" + "Your latest ledger is echoed back each turn. Declare the task done ONLY " + "when `remaining` is empty; a done declaration while items remain will be " + "rejected and cost you a step." +) + +TASK_NOTES_V2_SCHEMA_PROPERTY = { + "type": "object", + "description": ( + "REQUIRED structured task ledger; echoed back to you each turn. " + "verified_done may only contain items confirmed by a screenshot " + "received AFTER the actions that produced them." + ), + "properties": { + "constraints": { + "type": "string", + "description": "Instruction requirements, copied verbatim. Write once.", + }, + "remaining": { + "type": "array", + "items": {"type": "string"}, + "description": "Everything not yet VERIFIED complete, in order. Primary field.", + }, + "next_check": { + "type": "string", + "description": "What to confirm on the next screenshot to trust your latest actions.", + }, + "verified_done": { + "type": "array", + "items": {"type": "string"}, + "description": "Only items confirmed on a LATER screenshot than their actions.", + }, + }, + "required": ["remaining", "next_check"], +} + + +def render_task_notes_v2(note: dict) -> str: + """Render the structured v2 ledger to the echo text.""" + parts = [] + if note.get("constraints"): + parts.append(f"CONSTRAINTS (verbatim): {note['constraints']}") + remaining = note.get("remaining") or [] + parts.append("REMAINING: " + ("; ".join(str(r) for r in remaining) if remaining else "(empty)")) + if note.get("next_check"): + parts.append(f"NEXT CHECK: {note['next_check']}") + done = note.get("verified_done") or [] + if done: + parts.append("VERIFIED DONE: " + "; ".join(str(d) for d in done)) + return "\n".join(parts) + def estimate_tokens(messages: list[dict[str, Any]], system_text: str = "") -> int: """Heuristic token estimate (~4 chars/token text, IMAGE_TOKEN_ESTIMATE/image).""" diff --git a/mm_agents/anthropic/main.py b/mm_agents/anthropic/main.py index f1aeab1c..148a9010 100644 --- a/mm_agents/anthropic/main.py +++ b/mm_agents/anthropic/main.py @@ -37,6 +37,7 @@ from .context_policies import ( POLICY_IMAGE_TRIM, POLICY_SUMMARIZE, POLICY_FOCUS_CHAIN, + POLICY_FOCUS_CHAIN_V2, ALL_POLICIES, ) @@ -111,7 +112,7 @@ class AnthropicAgent: else: self.keep_n_images = keep_n_images self.image_chunk_size = image_chunk_size - if context_policy in (POLICY_KEEP_ALL, POLICY_FOCUS_CHAIN): + if context_policy in (POLICY_KEEP_ALL, POLICY_FOCUS_CHAIN, POLICY_FOCUS_CHAIN_V2): # Control arm / pure focus chain: never edit history (the # constructor default of 10 would silently trim on long runs). self.only_n_most_recent_images = None @@ -120,6 +121,10 @@ class AnthropicAgent: self.compaction_count = 0 self._pending_context_usage: Optional[dict] = None self._pending_context_event: Optional[str] = None + # focus_chain_v2 DONE-gate state (per task; also reset in reset()) + self._latest_remaining: list[str] = [] + self._done_gate_fired = False + self._pending_done_gate_msg: Optional[str] = None if self.image_target_size: self.resize_factor = ( @@ -219,6 +224,17 @@ class AnthropicAgent: self._pending_context_usage = None return {key: value for key, value in usage_metrics.items() if value is not None} + def _focus_chain_v2_tool_schema(self) -> dict: + """BATCHED_TOOL_SCHEMA plus the required structured v2 ledger.""" + schema = copy.deepcopy(BATCHED_TOOL_SCHEMA) + schema["input_schema"]["properties"]["task_notes"] = copy.deepcopy( + context_policies.TASK_NOTES_V2_SCHEMA_PROPERTY + ) + required = schema["input_schema"].setdefault("required", []) + if "task_notes" not in required: + required.append("task_notes") + return schema + def _focus_chain_tool_schema(self) -> dict: """BATCHED_TOOL_SCHEMA plus a required running task_notes field (P3).""" schema = copy.deepcopy(BATCHED_TOOL_SCHEMA) @@ -529,6 +545,8 @@ class AnthropicAgent: system_text += f"\n\n{context_policies.SUMMARIZE_PROMPT_ADDITION}" elif self.context_policy == POLICY_FOCUS_CHAIN: system_text += f"\n\n{context_policies.FOCUS_CHAIN_PROMPT_ADDITION}" + elif self.context_policy == POLICY_FOCUS_CHAIN_V2: + system_text += f"\n\n{context_policies.FOCUS_CHAIN_V2_PROMPT_ADDITION}" system = BetaTextBlockParam( type="text", @@ -586,6 +604,36 @@ class AnthropicAgent: ] }) + # v2 DONE-gate delivery: the rejected completion was a plain-text + # assistant message (no tool_use), so no tool_result turn will be + # created below. Append a plain user turn: fresh screenshot + the + # rejection + the ledger echo. Append-only. + if ( + self.context_policy == POLICY_FOCUS_CHAIN_V2 + and self._pending_done_gate_msg + and self.messages + and self.messages[-1]["role"] == "assistant" + and obs and obs.get("screenshot") + ): + gate_content = [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": base64.b64encode(obs["screenshot"]).decode("utf-8"), + }, + }, + {"type": "text", "text": self._pending_done_gate_msg}, + ] + if self.latest_task_note: + gate_content.append({ + "type": "text", + "text": f"[TASK_STATE_NOTES from your previous step]\n{self.latest_task_note}", + }) + self.messages.append({"role": "user", "content": gate_content}) + self._pending_done_gate_msg = None + # Add tool_result for ALL tool_use blocks in the last message if self.messages: last_message_content = self.messages[-1]["content"] @@ -622,7 +670,7 @@ class AnthropicAgent: # the END of the new user turn. Append-only — earlier history text # is never rewritten, so the cached prefix stays valid. if ( - self.context_policy == POLICY_FOCUS_CHAIN + self.context_policy in (POLICY_FOCUS_CHAIN, POLICY_FOCUS_CHAIN_V2) and tool_use_blocks and self.latest_task_note and self.messages @@ -735,6 +783,8 @@ class AnthropicAgent: if self.provider == APIProvider.OPENROUTER: if self.context_policy == POLICY_FOCUS_CHAIN: tools = [self._focus_chain_tool_schema()] + elif self.context_policy == POLICY_FOCUS_CHAIN_V2: + tools = [self._focus_chain_v2_tool_schema()] else: tools = [BATCHED_TOOL_SCHEMA] else: @@ -910,6 +960,15 @@ class AnthropicAgent: note = (content_block.get("input") or {}).get("task_notes") if isinstance(note, str) and note.strip(): self.latest_task_note = note.strip() + elif self.context_policy == POLICY_FOCUS_CHAIN_V2: + for content_block in response_params: + if content_block.get("type") == "tool_use": + note = (content_block.get("input") or {}).get("task_notes") + if isinstance(note, dict) and note: + self.latest_task_note = context_policies.render_task_notes_v2(note) + self._latest_remaining = [ + str(r) for r in (note.get("remaining") or []) if str(r).strip() + ] max_parse_retry = 3 for parse_retry in range(max_parse_retry): @@ -949,12 +1008,49 @@ class AnthropicAgent: logger.info(f"Received actions: {actions}") logger.info(f"Received reasonings: {reasonings}") if len(actions) == 0: - actions = [{ - "action_type": "DONE", - "raw_response": raw_response_str, - "usage": usage_metrics, - "image_metrics": image_metrics, - }] + # A response with no tool call is this stack's DONE signal. + # v2 DONE-gate: reject it once if the model's own ledger + # still lists unverified items; force a fresh observation + # instead. History is untouched (append-only); the model + # sees its attempt + the rejection next turn. + if ( + self.context_policy == POLICY_FOCUS_CHAIN_V2 + and not self._done_gate_fired + and self._latest_remaining + ): + self._done_gate_fired = True + remaining_txt = "; ".join(self._latest_remaining[:6]) + self._pending_done_gate_msg = ( + "[DONE_GATE] Completion NOT accepted: your task ledger " + f"still lists unverified items: {remaining_txt}. " + "A fresh screenshot follows. Verify each item on screen " + "(move it to verified_done) or finish it. Your next " + "done declaration will be accepted." + ) + self._pending_context_event = ( + f"done_gate_fired:remaining={len(self._latest_remaining)}" + ) + logger.info( + f"[focus_chain_v2] DONE-gate rejected completion; " + f"remaining={self._latest_remaining}" + ) + actions = [{ + "action_type": "tool_use", + "name": "computer", + "id": "done_gate_forced_screenshot", + "input": {"actions": [{"action": "screenshot"}]}, + "command": "pyautogui.sleep(0.1)\n", + "raw_response": raw_response_str, + "usage": usage_metrics, + "image_metrics": image_metrics, + }] + else: + actions = [{ + "action_type": "DONE", + "raw_response": raw_response_str, + "usage": usage_metrics, + "image_metrics": image_metrics, + }] return reasonings, actions except Exception as e: logger.warning(f"parse_actions_from_tool_call parsing failed (attempt {parse_retry+1}/3), will retry API request: {e}") @@ -1019,4 +1115,7 @@ class AnthropicAgent: self.compaction_count = 0 self._pending_context_usage = None self._pending_context_event = None + self._latest_remaining = [] + self._done_gate_fired = False + self._pending_done_gate_msg = None logger.info(f"{self.class_name} reset.") diff --git a/scripts/python/run_multienv_claude.py b/scripts/python/run_multienv_claude.py index 3ba9ff5b..5559640e 100644 --- a/scripts/python/run_multienv_claude.py +++ b/scripts/python/run_multienv_claude.py @@ -105,8 +105,8 @@ def config() -> argparse.Namespace: # context-management policy config (cache-aware-context experiment) parser.add_argument( "--context_policy", type=str, default="keep_all", - choices=["keep_all", "image_trim", "summarize", "focus_chain"], - help="Context management policy: keep_all (P0), image_trim (P1), summarize (P2), focus_chain (P3)" + choices=["keep_all", "image_trim", "summarize", "focus_chain", "focus_chain_v2"], + help="Context management policy: keep_all (P0), image_trim (P1), summarize (P2), focus_chain (P3), focus_chain_v2 (P3 + observation-gated ledger + DONE-gate)" ) parser.add_argument( "--context_budget_tokens", type=int, default=25000, -- 2.43.0