financial-rag / src /agents /executor.py
tolivert's picture
period grounding + data coverage display
bcd2455 verified
Raw
History Blame Contribute Delete
13.7 kB
"""
Multi-step agent executor implementing the ReAct pattern.
ReAct (Reason + Act) interleaves thinking and tool use: the model
reasons about what to do, calls a tool, observes the result, then
decides whether to call another tool or give a final answer.
The loop has hard guardrails for a small model:
* *max_steps* (default 5) prevents infinite loops.
* If the model never produces ``<answer>``, the last response is used.
* Tool errors are fed back as observations so the model can recover.
* Repeated identical tool calls break the loop early.
The executor is pure Python with no Streamlit dependency. It takes
a ``GenerationEngine`` and ``ToolRegistry``, and returns an
``AgentResult``.
"""
from __future__ import annotations
from typing import Iterator
from src.agents.parser import coerce_param, parse_tool_call
from src.agents.registry import ToolRegistry
from src.agents.schemas import AgentResult, AgentStep, ToolCall
# Optional guardrails import — available when src.guardrails is installed.
try:
from src.guardrails.middleware import GuardrailsMiddleware
except ImportError: # pragma: no cover
GuardrailsMiddleware = None # type: ignore[assignment, misc]
# ---------------------------------------------------------------------------
# System prompt template
# ---------------------------------------------------------------------------
_SYSTEM_TEMPLATE = """\
You are a helpful assistant with access to tools.
## How to use a tool
Thought: your reasoning about what to do
<tool>tool_name</tool>
<param name="param_name">value</param>
## How to give a final answer
Thought: your reasoning
<answer>your final answer here</answer>
## Rules
- Use exactly ONE tool per response.
- Always wrap your final answer in <answer></answer> tags.
- If you can answer without tools, use <answer> immediately.
- When the user asks about documents, files, policies, reports, revenue, or project-specific information, ALWAYS use retrieve_documents first — do not answer from your own knowledge or use web_search.
- Only use web_search for current events, live data, or topics clearly outside the uploaded documents.
- If unsure whether to use retrieve_documents or web_search, prefer retrieve_documents.
## Examples
User: What does the document say about pricing?
Assistant: Thought: The user is asking about document content, so I should search the uploaded documents.
<tool>retrieve_documents</tool>
<param name="query">pricing</param>
User: What models does the project support?
Assistant: Thought: This is about uploaded project documentation, so I should search the documents.
<tool>retrieve_documents</tool>
<param name="query">supported models</param>
User: What is the average salary?
Assistant: Thought: I need to query the data table for the average salary.
<tool>query_data</tool>
<param name="query">SELECT AVG(salary) FROM employees</param>
User: What is the latest news about Python?
Assistant: Thought: This is about current events, so I need to search the web.
<tool>web_search</tool>
<param name="query">latest Python news</param>
User: Hello!
Assistant: Thought: This is a greeting, no tools needed.
<answer>Hello! How can I help you today?</answer>
{tool_descriptions}"""
class AgentExecutor:
"""ReAct agent loop with tool calling."""
def __init__(
self,
engine,
registry: ToolRegistry,
max_steps: int = 5,
temperature: float = 0.3,
max_tokens_per_step: int = 300,
guardrails: GuardrailsMiddleware | None = None,
extra_instructions: str = "",
) -> None:
self.engine = engine
self.registry = registry
self.max_steps = max_steps
self.temperature = temperature
self.max_tokens_per_step = max_tokens_per_step
self.guardrails = guardrails
# Optional domain-specific rules appended to the system prompt
# (e.g. financial period-grounding), kept out of the generic template.
self.extra_instructions = extra_instructions
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run(
self,
user_query: str,
chat_history: list[dict] | None = None,
) -> AgentResult:
"""Execute the agent loop and return the final result."""
# Scan user input.
if self.guardrails:
input_scan = self.guardrails.scan_input(user_query)
if input_scan.blocked:
return AgentResult(
answer="I can't process that request — it was flagged by safety filters.",
steps=[],
num_steps=0,
finished=True,
guardrail_flags=[f.__dict__ for f in input_scan.flags],
)
steps: list[AgentStep] = []
system_prompt = self._build_system_prompt()
prev_call: tuple[str, str] | None = None # (tool_name, args_key) for repeat detection
for _ in range(self.max_steps):
messages = self._build_step_messages(
system_prompt, user_query, steps, chat_history,
)
raw_output = self.engine.generate_answer(
messages,
max_tokens=self.max_tokens_per_step,
temperature=self.temperature,
)
thought, tool_call, final_answer = parse_tool_call(raw_output)
# Final answer path.
if final_answer is not None:
step = AgentStep(thought=thought)
steps.append(step)
result = AgentResult(
answer=final_answer,
steps=steps,
num_steps=len(steps),
finished=True,
)
return self._scan_answer(result)
# Tool call path.
assert tool_call is not None
call_key = (tool_call.tool_name, str(sorted(tool_call.arguments.items())))
# Detect repeated identical tool call.
if call_key == prev_call:
step = AgentStep(
thought=thought,
tool_call=tool_call,
error="Repeated tool call — stopping.",
)
steps.append(step)
break
prev_call = call_key
observation = self._execute_tool(tool_call)
step = AgentStep(
thought=thought,
tool_call=tool_call,
observation=observation,
)
steps.append(step)
# Exhausted max_steps — use last observation or raw output as answer.
fallback = steps[-1].observation if steps else ""
result = AgentResult(
answer=fallback or "I was unable to determine an answer.",
steps=steps,
num_steps=len(steps),
finished=False,
)
return self._scan_answer(result)
def run_stream(
self,
user_query: str,
chat_history: list[dict] | None = None,
) -> Iterator[tuple[int, str, str]]:
"""Yield ``(step_index, event_type, content)`` tuples for streaming UI.
*event_type* is one of: ``"thought"``, ``"tool_call"``,
``"observation"``, ``"answer"``, ``"guardrail"``.
"""
# Scan user input.
if self.guardrails:
input_scan = self.guardrails.scan_input(user_query)
if input_scan.blocked:
yield 0, "guardrail", "; ".join(f.description for f in input_scan.flags)
yield 0, "answer", "I can't process that request — it was flagged by safety filters."
return
steps: list[AgentStep] = []
system_prompt = self._build_system_prompt()
prev_call: tuple[str, str] | None = None
for step_idx in range(self.max_steps):
messages = self._build_step_messages(
system_prompt, user_query, steps, chat_history,
)
raw_output = self.engine.generate_answer(
messages,
max_tokens=self.max_tokens_per_step,
temperature=self.temperature,
)
thought, tool_call, final_answer = parse_tool_call(raw_output)
if thought:
yield step_idx, "thought", thought
# Final answer.
if final_answer is not None:
steps.append(AgentStep(thought=thought))
if self.guardrails:
out_scan = self.guardrails.scan_output(final_answer)
if out_scan.flags:
yield step_idx, "guardrail", "; ".join(f.description for f in out_scan.flags)
if out_scan.blocked:
yield step_idx, "answer", "The response was blocked by safety filters."
return
yield step_idx, "answer", final_answer
return
# Tool call.
assert tool_call is not None
call_key = (tool_call.tool_name, str(sorted(tool_call.arguments.items())))
call_desc = "{}({})".format(
tool_call.tool_name,
", ".join("{}={}".format(k, v) for k, v in tool_call.arguments.items()),
)
yield step_idx, "tool_call", call_desc
if call_key == prev_call:
steps.append(AgentStep(
thought=thought, tool_call=tool_call,
error="Repeated tool call — stopping.",
))
break
prev_call = call_key
observation = self._execute_tool(tool_call)
steps.append(AgentStep(
thought=thought, tool_call=tool_call, observation=observation,
))
yield step_idx, "observation", observation
# Exhausted steps — yield whatever we have.
fallback = steps[-1].observation if steps else "I was unable to determine an answer."
yield len(steps) - 1, "answer", fallback
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _scan_answer(self, result: AgentResult) -> AgentResult:
"""Run guardrail checks on the final answer, if configured."""
if not self.guardrails:
return result
out_scan = self.guardrails.scan_output(result.answer)
if out_scan.flags:
result.guardrail_flags = [
{"check_name": f.check_name, "severity": f.severity.value, "description": f.description}
for f in out_scan.flags
]
if out_scan.blocked:
result.answer = "The response was blocked by safety filters."
return result
def _build_system_prompt(self) -> str:
prompt = _SYSTEM_TEMPLATE.format(
tool_descriptions=self.registry.format_tool_descriptions(),
)
if self.extra_instructions:
prompt += "\n\n## Additional rules\n" + self.extra_instructions
return prompt
def _build_step_messages(
self,
system_prompt: str,
user_query: str,
steps: list[AgentStep],
chat_history: list[dict] | None,
) -> list[dict]:
messages: list[dict] = [{"role": "system", "content": system_prompt}]
# Include recent chat history (last 4 turns for context window budget).
if chat_history:
messages.extend(chat_history[-4:])
# Append /no_think for Qwen3 to suppress <think> blocks — they waste
# the token budget on internal reasoning that duplicates our Thought: line.
# Other architectures (Gemma3, Gemma4) don't use this control token.
arch = getattr(self.engine, "arch", "qwen3")
suffix = " /no_think" if arch == "qwen3" else ""
messages.append({"role": "user", "content": user_query + suffix})
# Encode previous steps as assistant/user turn pairs.
for step in steps:
# Reconstruct the assistant's raw output.
raw = step.tool_call.raw_text if step.tool_call and step.tool_call.raw_text else ""
if not raw and step.thought:
raw = "Thought: {}".format(step.thought)
if raw:
messages.append({"role": "assistant", "content": raw})
if step.observation:
messages.append({
"role": "user",
"content": "Observation: {}".format(step.observation),
})
return messages
def _execute_tool(self, tool_call: ToolCall) -> str:
"""Look up and execute the tool, returning the observation string."""
tool = self.registry.get(tool_call.tool_name)
if tool is None:
return "Error: unknown tool '{}'. Available tools: {}".format(
tool_call.tool_name,
", ".join(t.name for t in self.registry.list_tools()),
)
if tool.execute is None:
return "Error: tool '{}' has no execute function.".format(tool_call.tool_name)
# Coerce parameter types.
kwargs: dict = {}
param_types = {p.name: p.type for p in tool.params}
for key, value in tool_call.arguments.items():
expected = param_types.get(key, "str")
kwargs[key] = coerce_param(value, expected)
try:
return tool.execute(**kwargs)
except Exception as e:
return "Error executing tool '{}': {}".format(tool_call.tool_name, e)