Skip to content

Commit fe7845a

Browse files
feat(core): minimal ReAct loop with tool invocation
1 parent 27986cd commit fe7845a

3 files changed

Lines changed: 96 additions & 1 deletion

File tree

mini_agent_harness/core/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .llm import get_default_llm, LLM
1111
from ..tools import load_tools_from_manifest
12+
from .loop import ReActLoop
1213

1314

1415
@dataclass
@@ -42,7 +43,11 @@ def run(self, input_text: str) -> AgentResult:
4243
and tool execution steps.
4344
"""
4445

45-
# Naive tool invocation: if the prompt starts with "<tool>: " call it
46+
if self.manifest.get("mode") == "react":
47+
loop = ReActLoop(self.llm, self.tools)
48+
return AgentResult(response_text=loop.run(input_text))
49+
50+
# Simple prefix tool call mode
4651
if ":" in input_text:
4752
tool_name, _, arg = input_text.partition(":")
4853
tool_name = tool_name.strip()

mini_agent_harness/core/loop.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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"

tests/test_react_loop.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from mini_agent_harness.core import Agent, AgentResult
2+
from mini_agent_harness.testing import agent_fixture
3+
4+
class MockLLM:
5+
"""Returns scripted responses for each call."""
6+
7+
def __init__(self):
8+
self.calls = 0
9+
10+
def generate(self, prompt: str) -> str: # noqa: D401
11+
self.calls += 1
12+
if self.calls == 1:
13+
return "ACTION: echo\nARG: hello"
14+
return "FINAL: done"
15+
16+
17+
def test_react_loop_invokes_tool():
18+
manifest = {
19+
"name": "react-agent",
20+
"description": "demo",
21+
"mode": "react",
22+
"tools": ["tools/echo.yaml"],
23+
}
24+
agent = Agent(manifest, llm=MockLLM())
25+
result: AgentResult = agent.run("ignored user input")
26+
assert result.response_text == "done"

0 commit comments

Comments
 (0)