Skip to content

Commit 9fb60f9

Browse files
committed
clean ReAct framework
1 parent aae17f9 commit 9fb60f9

13 files changed

Lines changed: 469 additions & 228 deletions

File tree

prototype/agingbench/cli/runners.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,14 @@ def _run_s2(sut_cfg: dict, scenario_cfg: dict, output_dir: Path,
392392
from agingbench.generators.s2_generator import S2Generator
393393
from agingbench.generators.pressure_config import PressureConfig
394394
gen_n = gen_sessions if gen_sessions > 0 else n_sessions
395-
generated_data = S2Generator(seed=sut_cfg.get("seed", 42),
396-
pressure=_resolve_pressure(sut_cfg, scenario_cfg)).generate(gen_n)
395+
generated_data = S2Generator(
396+
seed=sut_cfg.get("seed", 42),
397+
pressure=_resolve_pressure(sut_cfg, scenario_cfg),
398+
dense_accumulator=sut_cfg.get(
399+
"dense_accumulator",
400+
scenario_cfg.get("dense_accumulator", False),
401+
),
402+
).generate(gen_n)
397403
n_sessions = gen_n
398404

399405
with TraceLogger(str(trace_path)) as tracer:

prototype/agingbench/core/adapters/react_file_adapter.py

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Optional
1818

1919
from ..agent_adapter import AgentAdapter, AgentResponse
20-
from ..agent import ReferenceAgent, strip_thinking
20+
from ..agent import ReferenceAgent, _ParseFailure, strip_thinking
2121
from ..llm import BaseLLM
2222
from ..tools import ToolSpec, ToolRegistry
2323
from ..memory.workspace import WorkspaceMemoryPolicy
@@ -116,15 +116,11 @@ def _build_tools(self) -> ToolRegistry:
116116
return registry
117117

118118
def _write_file(self, args: dict) -> str:
119-
# Defensive: handle various argument formats
120119
path_str = args.get("path") or args.get("filename") or args.get("file") or "notes.txt"
121120
content = args.get("content") or args.get("text") or args.get("data") or str(args)
122-
# Llama / small models sometimes emit numeric or boolean `content`
123-
# values (e.g. {"content": 42}); pathlib.write_text requires str.
124121
if not isinstance(content, str):
125122
content = str(content)
126123
path = self.workspace_dir / path_str
127-
# Sanitize path (prevent directory traversal)
128124
try:
129125
path.resolve().relative_to(self.workspace_dir.resolve())
130126
except ValueError:
@@ -183,19 +179,33 @@ def send_message(self, message: str) -> AgentResponse:
183179
total_input = 0
184180
total_output = 0
185181

182+
# Same-call cache: identical (tool, args) reuse the result so
183+
# repeated retries don't redundantly hit the filesystem and
184+
# consume the turn budget.
185+
tool_cache: dict[tuple, object] = {}
186+
187+
def _tool_cache_key(name: str, args: dict) -> tuple:
188+
try:
189+
return (name, json.dumps(args, sort_keys=True, default=str))
190+
except Exception:
191+
return (name, repr(args))
192+
186193
# ReAct loop
187194
for turn in range(self.max_turns):
188195
response = self.llm.chat_with_usage(messages)
189196
total_input += response.input_tokens
190197
total_output += response.output_tokens
191-
text = response.text
198+
# Parse the visible (stripped) content, not the raw text — otherwise
199+
# Actions or Final Answers inside <think> blocks of a thinking
200+
# model (Gemma 4, DeepSeek R1) get treated as committed protocol
201+
# output. Visible text is also what we add to history.
202+
text = strip_thinking(response.text, self.llm)
192203

193204
# Check for Final Answer
194205
final = ReferenceAgent._parse_final_answer(text)
195206
if final is not None:
196-
# Save to conversation history
197207
self._conversation_history.append({"role": "user", "content": message})
198-
self._conversation_history.append({"role": "assistant", "content": strip_thinking(final, self.llm)})
208+
self._conversation_history.append({"role": "assistant", "content": final})
199209
return AgentResponse(
200210
text=final,
201211
tool_calls=tool_calls,
@@ -209,26 +219,53 @@ def send_message(self, message: str) -> AgentResponse:
209219
action = ReferenceAgent._parse_action(text)
210220
if action is not None:
211221
tool_name, tool_args = action
222+
# Malformed Action Input — surface as an Observation
223+
# instead of dispatching the tool with a sentinel that
224+
# would crash any args.get(...) inside the tool fn.
225+
if isinstance(tool_args, _ParseFailure):
226+
messages.append({"role": "assistant", "content": text})
227+
messages.append({
228+
"role": "user",
229+
"content": f"Observation: ERROR: {tool_args.reason}",
230+
})
231+
continue
212232
tool_spec = self._tool_registry.get(tool_name)
213233
if tool_spec:
214-
result = tool_spec.call(tool_args)
234+
cache_key = _tool_cache_key(tool_name, tool_args)
235+
if cache_key in tool_cache:
236+
result = tool_cache[cache_key]
237+
obs = (
238+
f"Observation: {result} "
239+
f"[repeated call — same result as before; "
240+
f"try different arguments or commit a Final Answer]"
241+
)
242+
else:
243+
# Catch tool exceptions so a raising tool fn doesn't
244+
# crash the entire run (the file-tool layer can raise
245+
# TypeError on numeric content, OSError on path
246+
# collisions, etc).
247+
try:
248+
result = tool_spec.call(tool_args)
249+
except Exception as e:
250+
result = f"ERROR: {type(e).__name__}: {e}"
251+
tool_cache[cache_key] = result
252+
obs = f"Observation: {result}"
215253
tool_calls.append({
216254
"tool": tool_name,
217255
"input": tool_args,
218256
"result": str(result)[:500],
219257
})
220258
if tool_name == "write_file":
221259
files_changed.append(tool_args.get("path", ""))
222-
# Add observation to messages
223-
messages.append({"role": "assistant", "content": strip_thinking(text, self.llm)})
224-
messages.append({"role": "user", "content": f"Observation: {result}"})
260+
messages.append({"role": "assistant", "content": text})
261+
messages.append({"role": "user", "content": obs})
225262
else:
226-
messages.append({"role": "assistant", "content": strip_thinking(text, self.llm)})
263+
messages.append({"role": "assistant", "content": text})
227264
messages.append({"role": "user", "content": f"Observation: Unknown tool '{tool_name}'. Available: {', '.join(t.name for t in self._tool_registry)}"})
228265
else:
229266
# No action or final answer — treat as final answer
230267
self._conversation_history.append({"role": "user", "content": message})
231-
self._conversation_history.append({"role": "assistant", "content": strip_thinking(text, self.llm)})
268+
self._conversation_history.append({"role": "assistant", "content": text})
232269
return AgentResponse(
233270
text=text,
234271
tool_calls=tool_calls,

prototype/agingbench/core/agent.py

Lines changed: 119 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@
1717
_THINK_BLOCK = re.compile(r"<think>.*?(?:</think>|\Z)\s*", re.DOTALL | re.IGNORECASE)
1818

1919

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+
2031
def _llm_model_id(llm: BaseLLM) -> str:
2132
return getattr(llm, "model_id", None) or getattr(llm, "model", "") or ""
2233

@@ -127,50 +138,140 @@ def run_session(self, task: str, session_id: int = 0) -> dict:
127138
]
128139
tool_calls = []
129140

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+
130152
for turn in range(self.max_turns):
131153
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)
135162
if final is not None:
136163
return {"output": final, "tool_calls": tool_calls, "turns": turn + 1}
137164

138-
parsed = self._parse_action(response)
165+
parsed = self._parse_action(visible)
139166
if parsed:
140167
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
141174
spec = self.tools.get(tool_name)
142175
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}"
144191
tool_calls.append({"tool": tool_name, "version": spec.version,
145192
"input": tool_input, "result": result})
146-
messages.append({"role": "user", "content": f"Observation: {result}"})
193+
messages.append({"role": "user", "content": obs_text})
147194
else:
148195
messages.append(
149196
{"role": "user", "content": f"Error: unknown tool '{tool_name}'"}
150197
)
151198
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.
154201
messages.append(
155202
{"role": "user", "content": "Continue. Use the Action/Final Answer format."}
156203
)
157204

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}
159208

160209
# ---------------------------------------------------------------- helpers
161210

162211
@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
171246
return None
172247

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+
173268
@staticmethod
174269
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+
)
176277
return m.group(1).strip() if m else None

0 commit comments

Comments
 (0)