Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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"]