1+ """Minimal ReAct-style reasoning loop.
2+
3+ This is *not* a full implementation – it is intentionally lightweight so that
4+ unit tests can stub the LLM responses. The protocol is:
5+
6+ 1. Build a transcript containing a list of tools and the user input.
7+ 2. Ask the LLM for a response.
8+ 3. If the response starts with `ACTION:` and `ARG:` we call that tool,
9+ append an `OBSERVATION:` line, and loop.
10+ 4. If the response starts with `FINAL:` we stop and return the remaining text.
11+ 5. After `max_iters` fall back to returning the last model output.
12+ """
13+ from __future__ import annotations
14+
15+ from typing import Callable , Dict
16+
17+ from .llm import LLM
18+
19+
20+ class ReActLoop :
21+ """Lightweight ReAct executor."""
22+
23+ def __init__ (self , llm : LLM , tools : Dict [str , Callable ], max_iters : int = 3 ):
24+ self .llm = llm
25+ self .tools = tools
26+ self .max_iters = max_iters
27+
28+ def run (self , user_input : str ) -> str :
29+ transcript = [f"Tools: { ', ' .join (self .tools )} " , f"User: { user_input } " ]
30+
31+ for _ in range (self .max_iters ):
32+ prompt = "\n " .join (transcript + ["Assistant:" ])
33+ model_response = self .llm .generate (prompt )
34+
35+ if model_response .startswith ("FINAL:" ):
36+ return model_response [len ("FINAL:" ) :].strip ()
37+
38+ if model_response .startswith ("ACTION:" ):
39+ # Parse lines
40+ lines = model_response .splitlines ()
41+ try :
42+ action = lines [0 ].split (":" , 1 )[1 ].strip ()
43+ arg_line = next (l for l in lines [1 :] if l .startswith ("ARG:" ))
44+ arg = arg_line .split (":" , 1 )[1 ].strip ()
45+ except Exception : # pragma: no cover
46+ # If parsing fails, treat whole response as final.
47+ return model_response
48+
49+ if action not in self .tools :
50+ observation = f"ERROR: unknown tool { action } "
51+ else :
52+ try :
53+ observation = str (self .tools [action ](arg ))
54+ except Exception as exc : # pragma: no cover
55+ observation = f"ERROR: tool raised { exc } "
56+
57+ transcript .extend ([model_response , f"OBSERVATION: { observation } " ])
58+ continue
59+
60+ # Fallback: treat model response as final answer
61+ return model_response .strip ()
62+
63+ # Reached iteration cap
64+ return "ERROR: max iterations reached"
0 commit comments