|
17 | 17 | _THINK_BLOCK = re.compile(r"<think>.*?(?:</think>|\Z)\s*", re.DOTALL | re.IGNORECASE) |
18 | 18 |
|
19 | 19 |
|
| 20 | +class _ParseFailure: |
| 21 | + """Sentinel returned by ``_parse_action`` when the action name parsed but |
| 22 | + the JSON args did not. The run loop surfaces ``reason`` as an error |
| 23 | + Observation so the model can re-emit valid JSON next turn. |
| 24 | + """ |
| 25 | + __slots__ = ("reason",) |
| 26 | + |
| 27 | + def __init__(self, reason: str): |
| 28 | + self.reason = reason |
| 29 | + |
| 30 | + |
20 | 31 | def _llm_model_id(llm: BaseLLM) -> str: |
21 | 32 | return getattr(llm, "model_id", None) or getattr(llm, "model", "") or "" |
22 | 33 |
|
@@ -127,50 +138,140 @@ def run_session(self, task: str, session_id: int = 0) -> dict: |
127 | 138 | ] |
128 | 139 | tool_calls = [] |
129 | 140 |
|
| 141 | + # Local cache: identical (tool, args) calls reuse the result so the |
| 142 | + # model is not punished with extra LLM turns for re-issuing the |
| 143 | + # same call. Discarded when run_session returns. |
| 144 | + tool_cache: dict[tuple, object] = {} |
| 145 | + |
| 146 | + def _tool_cache_key(name: str, args: dict) -> tuple: |
| 147 | + try: |
| 148 | + return (name, json.dumps(args, sort_keys=True, default=str)) |
| 149 | + except Exception: |
| 150 | + return (name, repr(args)) |
| 151 | + |
130 | 152 | for turn in range(self.max_turns): |
131 | 153 | response = self.llm.chat(messages) |
132 | | - messages.append({"role": "assistant", "content": strip_thinking(response, self.llm)}) |
133 | | - |
134 | | - final = self._parse_final_answer(response) |
| 154 | + # Parse the visible (stripped) content, not the raw response — |
| 155 | + # otherwise Actions or Final Answers inside <think> blocks of a |
| 156 | + # thinking model (Gemma 4, DeepSeek R1) get treated as committed |
| 157 | + # protocol output. Visible is also what we add to history. |
| 158 | + visible = strip_thinking(response, self.llm) |
| 159 | + messages.append({"role": "assistant", "content": visible}) |
| 160 | + |
| 161 | + final = self._parse_final_answer(visible) |
135 | 162 | if final is not None: |
136 | 163 | return {"output": final, "tool_calls": tool_calls, "turns": turn + 1} |
137 | 164 |
|
138 | | - parsed = self._parse_action(response) |
| 165 | + parsed = self._parse_action(visible) |
139 | 166 | if parsed: |
140 | 167 | tool_name, tool_input = parsed |
| 168 | + if isinstance(tool_input, _ParseFailure): |
| 169 | + messages.append({ |
| 170 | + "role": "user", |
| 171 | + "content": f"Observation: ERROR: {tool_input.reason}", |
| 172 | + }) |
| 173 | + continue |
141 | 174 | spec = self.tools.get(tool_name) |
142 | 175 | if spec: |
143 | | - result = spec.call(tool_input) |
| 176 | + cache_key = _tool_cache_key(tool_name, tool_input) |
| 177 | + if cache_key in tool_cache: |
| 178 | + result = tool_cache[cache_key] |
| 179 | + cached_hint = ( |
| 180 | + " [repeated call — same result as before; " |
| 181 | + "try a different argument or commit a Final Answer]" |
| 182 | + ) |
| 183 | + obs_text = f"Observation: {result}{cached_hint}" |
| 184 | + else: |
| 185 | + try: |
| 186 | + result = spec.call(tool_input) |
| 187 | + except Exception as e: |
| 188 | + result = f"ERROR: {type(e).__name__}: {e}" |
| 189 | + tool_cache[cache_key] = result |
| 190 | + obs_text = f"Observation: {result}" |
144 | 191 | tool_calls.append({"tool": tool_name, "version": spec.version, |
145 | 192 | "input": tool_input, "result": result}) |
146 | | - messages.append({"role": "user", "content": f"Observation: {result}"}) |
| 193 | + messages.append({"role": "user", "content": obs_text}) |
147 | 194 | else: |
148 | 195 | messages.append( |
149 | 196 | {"role": "user", "content": f"Error: unknown tool '{tool_name}'"} |
150 | 197 | ) |
151 | 198 | else: |
152 | | - # No action or final answer — nudge the model to continue. |
153 | | - # Ensures messages end with a user turn (required by some providers). |
| 199 | + # No Action and no Final Answer; ensure messages end with a |
| 200 | + # user turn (required by some providers) and nudge. |
154 | 201 | messages.append( |
155 | 202 | {"role": "user", "content": "Continue. Use the Action/Final Answer format."} |
156 | 203 | ) |
157 | 204 |
|
158 | | - return {"output": response, "tool_calls": tool_calls, "turns": self.max_turns} |
| 205 | + # max_turns exhausted without a Final Answer; return the stripped |
| 206 | + # last response so downstream scoring doesn't see <think> blocks. |
| 207 | + return {"output": visible, "tool_calls": tool_calls, "turns": self.max_turns} |
159 | 208 |
|
160 | 209 | # ---------------------------------------------------------------- helpers |
161 | 210 |
|
162 | 211 | @staticmethod |
163 | | - def _parse_action(text: str) -> Optional[tuple[str, dict]]: |
164 | | - action = re.search(r"Action:\s*(\w+)", text) |
165 | | - inp = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL) |
166 | | - if action and inp: |
167 | | - try: |
168 | | - return action.group(1), json.loads(inp.group(1)) |
169 | | - except json.JSONDecodeError: |
170 | | - return action.group(1), {"raw": inp.group(1)} |
| 212 | + def _extract_balanced_json(text: str, start: int) -> Optional[str]: |
| 213 | + """Find a balanced ``{...}`` JSON object at or after ``start``. |
| 214 | +
|
| 215 | + Walks the string respecting string literals and escapes so a ``}`` |
| 216 | + inside a quoted value does not close the object early. Returns |
| 217 | + None if no balanced object is found. |
| 218 | + """ |
| 219 | + depth = 0 |
| 220 | + in_string = False |
| 221 | + escape = False |
| 222 | + open_idx = None |
| 223 | + for i in range(start, len(text)): |
| 224 | + ch = text[i] |
| 225 | + if in_string: |
| 226 | + if escape: |
| 227 | + escape = False |
| 228 | + elif ch == "\\": |
| 229 | + escape = True |
| 230 | + elif ch == '"': |
| 231 | + in_string = False |
| 232 | + continue |
| 233 | + if ch == '"': |
| 234 | + in_string = True |
| 235 | + continue |
| 236 | + if ch == "{": |
| 237 | + if open_idx is None: |
| 238 | + open_idx = i |
| 239 | + depth += 1 |
| 240 | + elif ch == "}": |
| 241 | + depth -= 1 |
| 242 | + if depth == 0 and open_idx is not None: |
| 243 | + return text[open_idx : i + 1] |
| 244 | + if depth < 0: |
| 245 | + return None |
171 | 246 | return None |
172 | 247 |
|
| 248 | + @staticmethod |
| 249 | + def _parse_action(text: str) -> Optional[tuple[str, dict]]: |
| 250 | + action = re.search(r"Action:\s*([\w-]+)", text) |
| 251 | + if not action: |
| 252 | + return None |
| 253 | + inp_marker = re.search(r"Action Input:\s*", text) |
| 254 | + if inp_marker is None: |
| 255 | + return None |
| 256 | + balanced = ReferenceAgent._extract_balanced_json(text, inp_marker.end()) |
| 257 | + if balanced is None: |
| 258 | + return action.group(1), _ParseFailure( |
| 259 | + "Action Input did not contain a parseable JSON object." |
| 260 | + ) |
| 261 | + try: |
| 262 | + return action.group(1), json.loads(balanced) |
| 263 | + except json.JSONDecodeError as e: |
| 264 | + return action.group(1), _ParseFailure( |
| 265 | + f"Action Input JSON was malformed: {e}. Retry with valid JSON." |
| 266 | + ) |
| 267 | + |
173 | 268 | @staticmethod |
174 | 269 | def _parse_final_answer(text: str) -> Optional[str]: |
175 | | - m = re.search(r"Final Answer:\s*(.*)", text, re.DOTALL) |
| 270 | + # Stop at the next ReAct marker so commit-then-retry scaffolding |
| 271 | + # does not leak into the scored output. |
| 272 | + m = re.search( |
| 273 | + r"Final Answer:\s*(.*?)(?=\n\s*(?:Thought|Action|Observation)\s*:|\Z)", |
| 274 | + text, |
| 275 | + re.DOTALL, |
| 276 | + ) |
176 | 277 | return m.group(1).strip() if m else None |
0 commit comments