4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""The ReAct loop: reason, act (call tools), observe results, repeat.
|
|
|
|
``ReactLoop`` runs the loop. Each pass asks the LLM what to do (reason); if it
|
|
requests tools, they run and their results are fed back in (act + observe); this
|
|
repeats until the LLM answers with no tool calls or an iteration cap is hit. The
|
|
loop knows nothing about ``Agent`` — it takes an ``AgentRunInput``
|
|
(``core.agent.run_io``, the resolved inputs) and a ``LoopHost``
|
|
(``core.agent.loop_host``, the callbacks it needs), so anything implementing that
|
|
host can drive it. ``run_react_loop`` is the one-line functional entry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from core.agent.loop_host import LoopHost
|
|
from core.agent.run_io import AgentRunInput, AgentRunResult
|
|
from core.context_budget import context_budget_ceiling_for_model, enforce_context_budget
|
|
from core.events import (
|
|
AgentEndEvent,
|
|
AgentStartEvent,
|
|
MessageStartEvent,
|
|
MessageUpdateEvent,
|
|
ProviderRequestEndEvent,
|
|
ProviderRequestStartEvent,
|
|
ToolExecutionEndEvent,
|
|
ToolExecutionStartEvent,
|
|
ToolExecutionUpdateEvent,
|
|
TurnEndEvent,
|
|
TurnStartEvent,
|
|
)
|
|
from core.execution import (
|
|
ToolExecutionHooks,
|
|
ToolExecutionRequest,
|
|
ToolExecutionResult,
|
|
execute_tool_calls,
|
|
public_tool_input,
|
|
)
|
|
from core.llm.types import ToolCall
|
|
from core.messages import MessageMapper, UserRuntimeMessage
|
|
from core.provider import ProviderRequest
|
|
from core.types import RuntimeTool
|
|
from platform.observability.trace.redaction import redact_sensitive
|
|
from platform.observability.trace.spans import llm_span
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ReactLoop[RuntimeToolT: RuntimeTool]:
|
|
"""Runs one ReAct loop over a single ``AgentRunInput``.
|
|
|
|
The per-run state — the running message list, the tool results, whether a
|
|
tool ended the turn — lives in the instance fields; ``run()`` drives it to
|
|
completion. The loop never decides things like which tools to expose or when
|
|
to stop; it asks the ``LoopHost`` at each of those points.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
run_input: AgentRunInput[RuntimeToolT],
|
|
host: LoopHost[RuntimeToolT],
|
|
) -> None:
|
|
self._host = host
|
|
self._llm = run_input.llm
|
|
self._system = run_input.system
|
|
self._resolved = run_input.resolved
|
|
self._tool_resources = run_input.tool_resources
|
|
self._max_iterations = run_input.max_iterations
|
|
self._messages = run_input.messages
|
|
self._msg_formatter = MessageMapper(self._llm)
|
|
self._runtime_tools = list(host._filter_tools(run_input.tools))
|
|
self._tool_schemas = self._llm.tool_schemas(self._runtime_tools)
|
|
self._ceiling = context_budget_ceiling_for_model(getattr(self._llm, "_model", None))
|
|
self._executed: list[tuple[ToolCall, Any]] = []
|
|
self._tool_results: list[tuple[ToolCall, ToolExecutionResult]] = []
|
|
self._final_text = ""
|
|
self._final_system_prompt = self._system
|
|
self._hit_cap = True
|
|
self._terminated_by_tool = False
|
|
self._iterations_used = 0
|
|
|
|
def run(self) -> AgentRunResult:
|
|
"""Drive the loop to completion and return its outcome."""
|
|
self._host._emit_runtime(
|
|
AgentStartEvent(
|
|
data={
|
|
"tool_count": len(self._runtime_tools),
|
|
"max_iterations": self._max_iterations,
|
|
"message_count": len(self._messages),
|
|
}
|
|
)
|
|
)
|
|
try:
|
|
for iteration in range(self._max_iterations):
|
|
self._iterations_used = iteration + 1
|
|
if self._run_iteration(iteration):
|
|
break
|
|
return self._finalize()
|
|
finally:
|
|
note_progress = getattr(self._host, "_note_react_run_progress", None)
|
|
if callable(note_progress):
|
|
note_progress(
|
|
iterations_used=self._iterations_used,
|
|
executed=list(self._executed),
|
|
hit_iteration_cap=self._hit_cap,
|
|
)
|
|
|
|
def _run_iteration(self, iteration: int) -> bool:
|
|
"""Run one think -> observe step. Return True when the loop should stop."""
|
|
self._host._drain_steering_messages(self._messages)
|
|
self._host._emit_runtime(
|
|
TurnStartEvent(
|
|
iteration=iteration,
|
|
data={"message_count": len(self._messages), "tool_count": len(self._runtime_tools)},
|
|
)
|
|
)
|
|
response = self._think(iteration)
|
|
assistant_message = self._msg_formatter.to_assistant_runtime_message(response)
|
|
self._host._emit_runtime(MessageStartEvent(message=assistant_message, iteration=iteration))
|
|
if response.content:
|
|
self._host._emit_runtime(
|
|
MessageUpdateEvent(
|
|
message=assistant_message,
|
|
delta=response.content,
|
|
iteration=iteration,
|
|
)
|
|
)
|
|
self._messages.append(assistant_message)
|
|
|
|
if not response.has_tool_calls:
|
|
return self._handle_conclusion(response, assistant_message, iteration)
|
|
return self._observe(response, assistant_message, iteration)
|
|
|
|
def _think(self, iteration: int) -> Any:
|
|
"""Build the request, apply the provider hooks, and call the LLM."""
|
|
transformed_messages = self._host._transform_messages(self._messages)
|
|
llm_messages = self._host._convert_to_llm(self._llm, transformed_messages)
|
|
enforce_context_budget(
|
|
llm_messages, system=self._system, tools=self._tool_schemas, ceiling=self._ceiling
|
|
)
|
|
provider_request = ProviderRequest(
|
|
messages=llm_messages,
|
|
system=self._system,
|
|
tools=self._tool_schemas,
|
|
metadata={"iteration": iteration},
|
|
)
|
|
provider_request = self._host._before_request(provider_request)
|
|
self._final_system_prompt = provider_request.system or self._system
|
|
self._host._emit_runtime(
|
|
ProviderRequestStartEvent(
|
|
iteration=iteration,
|
|
message_count=len(provider_request.messages),
|
|
)
|
|
)
|
|
model_name = str(getattr(self._llm, "model_id", None) or "invoke")
|
|
with llm_span(model_name, iteration=iteration):
|
|
response = self._llm.invoke(
|
|
provider_request.messages,
|
|
system=provider_request.system,
|
|
tools=provider_request.tools,
|
|
)
|
|
response = self._host._after_response(provider_request, response)
|
|
self._host._emit_runtime(
|
|
ProviderRequestEndEvent(
|
|
iteration=iteration,
|
|
has_tool_calls=response.has_tool_calls,
|
|
)
|
|
)
|
|
return response
|
|
|
|
def _handle_conclusion(self, response: Any, assistant_message: Any, iteration: int) -> bool:
|
|
"""No tool calls: accept the answer (maybe after a follow-up) or nudge and continue."""
|
|
accept, nudge = self._host._should_accept_conclusion(
|
|
evidence_count=len(self._executed), iteration=iteration
|
|
)
|
|
if accept:
|
|
follow_up = self._host._pop_follow_up_message()
|
|
if follow_up is not None:
|
|
self._messages.append(UserRuntimeMessage(content=follow_up))
|
|
self._host._emit_runtime(
|
|
TurnEndEvent(
|
|
iteration=iteration,
|
|
message=assistant_message,
|
|
data={"accepted": False, "queued_follow_up": True},
|
|
)
|
|
)
|
|
return False
|
|
self._final_text = response.content or ""
|
|
self._hit_cap = False
|
|
self._host._emit_runtime(
|
|
TurnEndEvent(
|
|
iteration=iteration,
|
|
message=assistant_message,
|
|
data={"accepted": True},
|
|
)
|
|
)
|
|
return True
|
|
if nudge is None:
|
|
raise ValueError(
|
|
f"{type(self._host).__name__}._should_accept_conclusion returned "
|
|
"(False, None) — a nudge string is required when rejecting "
|
|
"the conclusion, otherwise the LLM will loop on an unchanged "
|
|
"message history until max_iterations."
|
|
)
|
|
self._messages.append(UserRuntimeMessage(content=nudge))
|
|
self._host._emit_runtime(
|
|
TurnEndEvent(
|
|
iteration=iteration,
|
|
message=assistant_message,
|
|
data={"accepted": False, "nudge": True},
|
|
)
|
|
)
|
|
return False
|
|
|
|
def _observe(self, response: Any, assistant_message: Any, iteration: int) -> bool:
|
|
"""Execute the requested tools, record results, emit events. Return True if a tool terminated."""
|
|
for tc in response.tool_calls:
|
|
self._host._emit_runtime(
|
|
ToolExecutionStartEvent(
|
|
tool_call_id=tc.id,
|
|
tool_name=tc.name,
|
|
args=public_tool_input(tc.input),
|
|
iteration=iteration,
|
|
)
|
|
)
|
|
|
|
def on_tool_update(
|
|
request: ToolExecutionRequest,
|
|
update: Any,
|
|
*,
|
|
event_iteration: int = iteration,
|
|
) -> None:
|
|
self._emit_tool_update(request, update, event_iteration=event_iteration)
|
|
|
|
hooks = ToolExecutionHooks(
|
|
before_tool_call=self._host._tool_hooks.before_tool_call,
|
|
after_tool_call=self._host._tool_hooks.after_tool_call,
|
|
on_tool_update=on_tool_update,
|
|
)
|
|
results = execute_tool_calls(
|
|
response.tool_calls,
|
|
self._runtime_tools,
|
|
self._resolved,
|
|
hooks=hooks,
|
|
tool_resources=self._tool_resources,
|
|
)
|
|
provider_results = [result.provider_content() for result in results]
|
|
tool_result_message = self._msg_formatter.to_tool_result_runtime_message(
|
|
response.tool_calls, provider_results
|
|
)
|
|
self._messages.append(tool_result_message)
|
|
|
|
for tc, result in zip(response.tool_calls, results):
|
|
compat_payload = result.compat_payload()
|
|
self._executed.append((tc, compat_payload))
|
|
self._tool_results.append((tc, result))
|
|
self._host._emit_runtime(
|
|
ToolExecutionEndEvent(
|
|
tool_call_id=tc.id,
|
|
tool_name=tc.name,
|
|
args=public_tool_input(tc.input),
|
|
result=redact_sensitive(compat_payload),
|
|
is_error=result.is_error,
|
|
iteration=iteration,
|
|
data={"terminate": result.terminate},
|
|
)
|
|
)
|
|
self._host._emit_runtime(
|
|
TurnEndEvent(
|
|
iteration=iteration,
|
|
message=assistant_message,
|
|
tool_results=tuple(result.compat_payload() for result in results),
|
|
data={"accepted": False},
|
|
)
|
|
)
|
|
if any(result.terminate for result in results):
|
|
self._terminated_by_tool = True
|
|
self._hit_cap = False
|
|
return True
|
|
return False
|
|
|
|
def _emit_tool_update(
|
|
self, request: ToolExecutionRequest, update: Any, *, event_iteration: int
|
|
) -> None:
|
|
if self._host._tool_hooks.on_tool_update is not None:
|
|
try:
|
|
self._host._tool_hooks.on_tool_update(request, update)
|
|
except Exception: # noqa: BLE001 - observer failures must not break execution
|
|
logger.debug(
|
|
"[runtime] on_tool_update(%s) raised; ignoring",
|
|
request.tool_call.name,
|
|
exc_info=True,
|
|
)
|
|
self._host._emit_runtime(
|
|
ToolExecutionUpdateEvent(
|
|
tool_call_id=request.tool_call.id,
|
|
tool_name=request.tool_call.name,
|
|
args=public_tool_input(request.tool_call.input),
|
|
partial_result=redact_sensitive(update),
|
|
iteration=event_iteration,
|
|
)
|
|
)
|
|
|
|
def _finalize(self) -> AgentRunResult:
|
|
"""Build the run result, emit the end-of-run event, and return the result."""
|
|
run_result = AgentRunResult(
|
|
messages=self._messages,
|
|
final_text=self._final_text,
|
|
executed=self._executed,
|
|
tool_results=self._tool_results,
|
|
terminated_by_tool=self._terminated_by_tool,
|
|
hit_iteration_cap=self._hit_cap,
|
|
llm_iterations_used=self._iterations_used,
|
|
final_system_prompt=self._final_system_prompt,
|
|
)
|
|
self._host._emit_runtime(
|
|
AgentEndEvent(
|
|
messages=tuple(self._messages),
|
|
data={
|
|
"final_text": self._final_text,
|
|
"hit_iteration_cap": self._hit_cap,
|
|
"terminated_by_tool": self._terminated_by_tool,
|
|
"message_count": len(self._messages),
|
|
"executed_count": len(self._executed),
|
|
},
|
|
)
|
|
)
|
|
return run_result
|
|
|
|
|
|
def run_react_loop[RuntimeToolT: RuntimeTool](
|
|
run_input: AgentRunInput[RuntimeToolT],
|
|
host: LoopHost[RuntimeToolT],
|
|
) -> AgentRunResult:
|
|
"""Run the think -> call-tools -> observe loop and return its outcome."""
|
|
return ReactLoop(run_input, host).run()
|
|
|
|
|
|
__all__ = ["ReactLoop", "run_react_loop"]
|