feat(agent): wire real ReAct tool-calling loop into Ask tab (M21)
Browse filesTurns the stubbed ToolExecutor into a working multi-step agent:
- Fix ToolExecutor.execute() bus dispatch: it built a RouteRequest and called
bus.call(req), but the HearthNet CapabilityBus API is positional
bus.call(name, version, body). The tool path was therefore never reachable.
Now dispatches correctly and surfaces bus errors instead of fabricating.
- Add ToolExecutor.run_react_loop(): a model-agnostic ReAct loop mirroring the
proven browser agent format (action: {json} -> Observation:), so it works on
tiny local models with no native function-calling. Returns {final, steps}
with a Thought -> Tool -> Observation trace.
- Add default_tool_set(bus): binds read-only tools to REAL existing caps
(rag.query, rag.list_corpora, trans.text, market.list, moe.route,
tool.plant_identify). No side-effecting tools, no mock handlers.
- Ask tab: add 'Agent mode' toggle + Agent Steps panel showing each tool call
and observation over the live bus.
- tests/test_phase1_agent.py: 5 no-mock tests (real bus + demo rag.query;
only the LLM role is scripted) covering dispatch, grounded answers,
budget finalisation, bad-JSON recovery, and no-silent-fallback errors.
- hearthnet/services/llm/tools.py +252 -26
- hearthnet/ui/tabs/ask.py +83 -11
- tests/test_phase1_agent.py +106 -0
|
@@ -192,20 +192,27 @@ class ToolExecutor:
|
|
| 192 |
is_error=True,
|
| 193 |
)
|
| 194 |
|
| 195 |
-
# 2. Bus-dispatched capability
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
if definition and definition.bound_capability and self._bus is not None:
|
| 197 |
try:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
version_req=definition.bound_version or (1, 0),
|
| 203 |
-
body={"input": call.arguments},
|
| 204 |
-
caller="tool-executor",
|
| 205 |
-
trace_id=str(uuid.uuid4()),
|
| 206 |
)
|
| 207 |
-
resp
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
return ToolResult(
|
| 210 |
tool_call_id=call.id,
|
| 211 |
name=call.name,
|
|
@@ -228,38 +235,257 @@ class ToolExecutor:
|
|
| 228 |
is_error=True,
|
| 229 |
)
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
async def run_loop(
|
| 232 |
self,
|
| 233 |
initial_messages: list[dict],
|
| 234 |
max_rounds: int = 5,
|
| 235 |
) -> list[dict]:
|
| 236 |
-
"""
|
| 237 |
-
|
| 238 |
-
Each round:
|
| 239 |
-
1. Calls the LLM (not implemented here — caller provides messages so far)
|
| 240 |
-
2. If the LLM response contains tool calls, executes them
|
| 241 |
-
3. Appends tool results and continues
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
|
| 247 |
-
|
|
|
|
| 248 |
"""
|
| 249 |
messages = list(initial_messages)
|
| 250 |
for _ in range(max_rounds):
|
| 251 |
-
# Check for pending tool calls in the last assistant message
|
| 252 |
last = messages[-1] if messages else {}
|
| 253 |
tool_calls_raw = last.get("tool_calls", [])
|
| 254 |
if not tool_calls_raw:
|
| 255 |
break
|
| 256 |
-
# Execute each tool call
|
| 257 |
-
tool_results = []
|
| 258 |
for tc_raw in tool_calls_raw:
|
| 259 |
tc = ToolCall.from_openai_delta(tc_raw)
|
| 260 |
result = await self.execute(tc)
|
| 261 |
-
tool_results.append(result)
|
| 262 |
messages.append(result.to_message())
|
| 263 |
-
# If all results are returned, no more rounds needed
|
| 264 |
break
|
| 265 |
return messages
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
is_error=True,
|
| 193 |
)
|
| 194 |
|
| 195 |
+
# 2. Bus-dispatched capability.
|
| 196 |
+
# NOTE: the HearthNet CapabilityBus API is positional:
|
| 197 |
+
# bus.call(capability_name, version_tuple, body_dict)
|
| 198 |
+
# (see hearthnet/bus and ui/tabs/ask.py). An earlier draft constructed a
|
| 199 |
+
# RouteRequest and called bus.call(req) — that never matched the real bus
|
| 200 |
+
# and is why the tool path was never exercised. Use the real API here.
|
| 201 |
if definition and definition.bound_capability and self._bus is not None:
|
| 202 |
try:
|
| 203 |
+
resp = await self._bus.call(
|
| 204 |
+
definition.bound_capability,
|
| 205 |
+
definition.bound_version or (1, 0),
|
| 206 |
+
{"input": call.arguments},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
)
|
| 208 |
+
if isinstance(resp, dict) and "error" in resp:
|
| 209 |
+
return ToolResult(
|
| 210 |
+
tool_call_id=call.id,
|
| 211 |
+
name=call.name,
|
| 212 |
+
content={"error": resp.get("message", resp.get("error"))},
|
| 213 |
+
is_error=True,
|
| 214 |
+
)
|
| 215 |
+
out = resp.get("output", resp) if isinstance(resp, dict) else resp
|
| 216 |
return ToolResult(
|
| 217 |
tool_call_id=call.id,
|
| 218 |
name=call.name,
|
|
|
|
| 235 |
is_error=True,
|
| 236 |
)
|
| 237 |
|
| 238 |
+
# ------------------------------------------------------------------
|
| 239 |
+
# ReAct agent loop
|
| 240 |
+
# ------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
def tool_help(self) -> str:
|
| 243 |
+
"""Human/LLM-readable catalogue of the registered tools."""
|
| 244 |
+
lines = []
|
| 245 |
+
for t in self._tools.values():
|
| 246 |
+
props = (t.parameters_schema or {}).get("properties", {})
|
| 247 |
+
arg_names = ", ".join(props.keys()) if props else ""
|
| 248 |
+
lines.append(f"- {t.name}({arg_names}): {t.description}")
|
| 249 |
+
return "\n".join(lines)
|
| 250 |
+
|
| 251 |
+
def system_prompt(self) -> str:
|
| 252 |
+
"""Build the ReAct system prompt that teaches the model to call tools.
|
| 253 |
+
|
| 254 |
+
Mirrors the proven browser-agent format (webagent/src/agent/runtime.js):
|
| 255 |
+
the model emits a line ``action: {json}`` to call a tool, and receives an
|
| 256 |
+
``Observation:`` back. When it has the answer it replies normally with no
|
| 257 |
+
``action:`` line. This JSON-in-text protocol works on tiny models that
|
| 258 |
+
have no native function-calling API (Tiny Titan friendly).
|
| 259 |
+
"""
|
| 260 |
+
return (
|
| 261 |
+
"You are a HearthNet agent. You can use tools to answer questions about "
|
| 262 |
+
"the local mesh, documents, neighbours, and the world.\n\n"
|
| 263 |
+
"Available tools:\n"
|
| 264 |
+
f"{self.tool_help()}\n\n"
|
| 265 |
+
"To use a tool, output EXACTLY one line:\n"
|
| 266 |
+
'action: {"tool": "<tool_name>", "<arg>": "<value>"}\n'
|
| 267 |
+
"Then stop and wait. You will receive a line starting with 'Observation:'.\n"
|
| 268 |
+
"You may use tools several times. When you have enough information, "
|
| 269 |
+
"reply to the user directly in plain text with NO 'action:' line.\n"
|
| 270 |
+
"Keep tool arguments minimal and valid JSON."
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
async def run_react_loop(
|
| 274 |
+
self,
|
| 275 |
+
user_text: str,
|
| 276 |
+
call_llm: Callable[[list[dict]], Any],
|
| 277 |
+
*,
|
| 278 |
+
history: list[dict] | None = None,
|
| 279 |
+
max_iterations: int = 6,
|
| 280 |
+
on_step: Callable[[dict], Any] | None = None,
|
| 281 |
+
) -> dict:
|
| 282 |
+
"""Run a ReAct tool-use loop and return ``{"final", "steps"}``.
|
| 283 |
+
|
| 284 |
+
``call_llm`` is an async callable taking the running message list and
|
| 285 |
+
returning the assistant text (str). The caller supplies it — typically a
|
| 286 |
+
thin wrapper around ``bus.call("llm.chat", ...)`` — so this loop has no
|
| 287 |
+
hard dependency on any particular backend and is fully testable without
|
| 288 |
+
mocks (a scripted echo backend suffices).
|
| 289 |
+
|
| 290 |
+
Each step dict (also passed to ``on_step``) is one of:
|
| 291 |
+
{"type": "thought", "text": ...}
|
| 292 |
+
{"type": "tool", "name": ..., "args": ..., "observation": ..., "is_error": ...}
|
| 293 |
+
{"type": "final", "text": ...}
|
| 294 |
+
"""
|
| 295 |
+
import asyncio
|
| 296 |
+
import re
|
| 297 |
+
|
| 298 |
+
chat: list[dict] = [{"role": "system", "content": self.system_prompt()}]
|
| 299 |
+
if history:
|
| 300 |
+
chat.extend(history)
|
| 301 |
+
chat.append({"role": "user", "content": user_text})
|
| 302 |
+
|
| 303 |
+
steps: list[dict] = []
|
| 304 |
+
|
| 305 |
+
async def _emit(step: dict) -> None:
|
| 306 |
+
steps.append(step)
|
| 307 |
+
if on_step is not None:
|
| 308 |
+
res = on_step(step)
|
| 309 |
+
if asyncio.iscoroutine(res):
|
| 310 |
+
await res
|
| 311 |
+
|
| 312 |
+
action_re = re.compile(r"action\s*:\s*(\{.*?\})", re.IGNORECASE | re.DOTALL)
|
| 313 |
+
final_text = ""
|
| 314 |
+
|
| 315 |
+
for _ in range(max(1, max_iterations)):
|
| 316 |
+
raw = await call_llm(chat)
|
| 317 |
+
text = raw if isinstance(raw, str) else str(raw)
|
| 318 |
+
match = action_re.search(text)
|
| 319 |
+
|
| 320 |
+
if not match:
|
| 321 |
+
final_text = text.strip()
|
| 322 |
+
await _emit({"type": "final", "text": final_text})
|
| 323 |
+
return {"final": final_text, "steps": steps}
|
| 324 |
+
|
| 325 |
+
await _emit({"type": "thought", "text": text[: match.start()].strip()})
|
| 326 |
+
|
| 327 |
+
try:
|
| 328 |
+
action = json.loads(match.group(1))
|
| 329 |
+
except json.JSONDecodeError:
|
| 330 |
+
chat.append({"role": "assistant", "content": text})
|
| 331 |
+
chat.append(
|
| 332 |
+
{
|
| 333 |
+
"role": "user",
|
| 334 |
+
"content": "Observation: action JSON was invalid. Re-emit a single valid JSON action.",
|
| 335 |
+
}
|
| 336 |
+
)
|
| 337 |
+
continue
|
| 338 |
+
|
| 339 |
+
tool_name = action.get("tool", "")
|
| 340 |
+
args = {k: v for k, v in action.items() if k != "tool"}
|
| 341 |
+
call = ToolCall(id=str(uuid.uuid4()), name=tool_name, arguments=args)
|
| 342 |
+
result = await self.execute(call)
|
| 343 |
+
observation = (
|
| 344 |
+
json.dumps(result.content)
|
| 345 |
+
if isinstance(result.content, dict)
|
| 346 |
+
else str(result.content)
|
| 347 |
+
)
|
| 348 |
+
await _emit(
|
| 349 |
+
{
|
| 350 |
+
"type": "tool",
|
| 351 |
+
"name": tool_name,
|
| 352 |
+
"args": args,
|
| 353 |
+
"observation": observation[:2000],
|
| 354 |
+
"is_error": result.is_error,
|
| 355 |
+
}
|
| 356 |
+
)
|
| 357 |
+
chat.append({"role": "assistant", "content": text})
|
| 358 |
+
chat.append(
|
| 359 |
+
{"role": "user", "content": f"Observation from {tool_name}: {observation[:8000]}"}
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
# Iteration budget exhausted — force a final answer.
|
| 363 |
+
chat.append(
|
| 364 |
+
{
|
| 365 |
+
"role": "user",
|
| 366 |
+
"content": "Observation: tool budget reached. Give your best final answer now, no action.",
|
| 367 |
+
}
|
| 368 |
+
)
|
| 369 |
+
raw = await call_llm(chat)
|
| 370 |
+
final_text = (raw if isinstance(raw, str) else str(raw)).strip()
|
| 371 |
+
# Strip any trailing action line the model may still emit.
|
| 372 |
+
final_text = action_re.sub("", final_text).strip()
|
| 373 |
+
await _emit({"type": "final", "text": final_text})
|
| 374 |
+
return {"final": final_text, "steps": steps}
|
| 375 |
+
|
| 376 |
async def run_loop(
|
| 377 |
self,
|
| 378 |
initial_messages: list[dict],
|
| 379 |
max_rounds: int = 5,
|
| 380 |
) -> list[dict]:
|
| 381 |
+
"""OpenAI-style tool-call loop over a pre-populated message list.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
+
Used when the LLM backend emits native ``tool_calls`` (OpenAI / Ollama /
|
| 384 |
+
Nemotron / MiniCPM). For models without native tool calling, use
|
| 385 |
+
:meth:`run_react_loop` instead.
|
| 386 |
|
| 387 |
+
Executes any pending ``tool_calls`` on the last assistant message and
|
| 388 |
+
appends ``tool`` role result messages. Returns the extended list.
|
| 389 |
"""
|
| 390 |
messages = list(initial_messages)
|
| 391 |
for _ in range(max_rounds):
|
|
|
|
| 392 |
last = messages[-1] if messages else {}
|
| 393 |
tool_calls_raw = last.get("tool_calls", [])
|
| 394 |
if not tool_calls_raw:
|
| 395 |
break
|
|
|
|
|
|
|
| 396 |
for tc_raw in tool_calls_raw:
|
| 397 |
tc = ToolCall.from_openai_delta(tc_raw)
|
| 398 |
result = await self.execute(tc)
|
|
|
|
| 399 |
messages.append(result.to_message())
|
|
|
|
| 400 |
break
|
| 401 |
return messages
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
# Default tool set
|
| 406 |
+
# ---------------------------------------------------------------------------
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def default_tool_set(bus: Any) -> ToolExecutor:
|
| 410 |
+
"""Build a :class:`ToolExecutor` wired to capabilities already on the bus.
|
| 411 |
+
|
| 412 |
+
Only read-only / low-risk tools are bound by default. Side-effecting tools
|
| 413 |
+
(scheduling, peer writes) are excluded so the agent cannot take destructive
|
| 414 |
+
actions without explicit opt-in. Every tool maps to a REAL capability — there
|
| 415 |
+
are no mock handlers here. If a capability is not registered on the bus the
|
| 416 |
+
tool simply errors at call time with a clear message (no silent fallback).
|
| 417 |
+
"""
|
| 418 |
+
tools = [
|
| 419 |
+
ToolDefinition(
|
| 420 |
+
name="search_corpus",
|
| 421 |
+
description="Search indexed local documents (RAG). Returns matching passages with sources.",
|
| 422 |
+
parameters_schema={
|
| 423 |
+
"type": "object",
|
| 424 |
+
"properties": {
|
| 425 |
+
"query": {"type": "string", "description": "Natural-language search query"},
|
| 426 |
+
"corpus": {"type": "string", "description": "Optional corpus id to scope the search"},
|
| 427 |
+
"top_k": {"type": "integer", "description": "Max passages to return", "default": 4},
|
| 428 |
+
},
|
| 429 |
+
"required": ["query"],
|
| 430 |
+
},
|
| 431 |
+
bound_capability="rag.query",
|
| 432 |
+
bound_version=(1, 0),
|
| 433 |
+
),
|
| 434 |
+
ToolDefinition(
|
| 435 |
+
name="list_corpora",
|
| 436 |
+
description="List the document corpora available locally for search.",
|
| 437 |
+
parameters_schema={"type": "object", "properties": {}},
|
| 438 |
+
bound_capability="rag.list_corpora",
|
| 439 |
+
bound_version=(1, 0),
|
| 440 |
+
),
|
| 441 |
+
ToolDefinition(
|
| 442 |
+
name="translate",
|
| 443 |
+
description="Translate text between languages using a local model.",
|
| 444 |
+
parameters_schema={
|
| 445 |
+
"type": "object",
|
| 446 |
+
"properties": {
|
| 447 |
+
"text": {"type": "string"},
|
| 448 |
+
"target_lang": {"type": "string", "description": "Target language code, e.g. 'de'"},
|
| 449 |
+
"source_lang": {"type": "string", "description": "Optional source language code"},
|
| 450 |
+
},
|
| 451 |
+
"required": ["text", "target_lang"],
|
| 452 |
+
},
|
| 453 |
+
bound_capability="trans.text",
|
| 454 |
+
bound_version=(1, 0),
|
| 455 |
+
),
|
| 456 |
+
ToolDefinition(
|
| 457 |
+
name="list_marketplace",
|
| 458 |
+
description="List skills/capabilities offered by neighbours on the mesh.",
|
| 459 |
+
parameters_schema={"type": "object", "properties": {}},
|
| 460 |
+
bound_capability="market.list",
|
| 461 |
+
bound_version=(1, 0),
|
| 462 |
+
),
|
| 463 |
+
ToolDefinition(
|
| 464 |
+
name="route_expert",
|
| 465 |
+
description="Ask the mixture-of-experts router which local expert should handle a task.",
|
| 466 |
+
parameters_schema={
|
| 467 |
+
"type": "object",
|
| 468 |
+
"properties": {
|
| 469 |
+
"task": {"type": "string", "description": "Description of the task to route"},
|
| 470 |
+
"tags": {"type": "array", "items": {"type": "string"}},
|
| 471 |
+
},
|
| 472 |
+
"required": ["task"],
|
| 473 |
+
},
|
| 474 |
+
bound_capability="moe.route",
|
| 475 |
+
bound_version=(1, 0),
|
| 476 |
+
),
|
| 477 |
+
ToolDefinition(
|
| 478 |
+
name="identify_plant",
|
| 479 |
+
description="Identify a plant from an image reference using local vision + LLM.",
|
| 480 |
+
parameters_schema={
|
| 481 |
+
"type": "object",
|
| 482 |
+
"properties": {
|
| 483 |
+
"image": {"type": "string", "description": "Image id, path, or data URL"},
|
| 484 |
+
},
|
| 485 |
+
"required": ["image"],
|
| 486 |
+
},
|
| 487 |
+
bound_capability="tool.plant_identify",
|
| 488 |
+
bound_version=(1, 0),
|
| 489 |
+
),
|
| 490 |
+
]
|
| 491 |
+
return ToolExecutor(tools=tools, bus=bus)
|
|
@@ -113,6 +113,12 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 113 |
)
|
| 114 |
refresh_corpora_btn = gr.Button("🔄 Refresh Corpora", size="sm", scale=1)
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
chatbot = gr.Chatbot(
|
| 117 |
label="Conversation",
|
| 118 |
height=440,
|
|
@@ -132,13 +138,21 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 132 |
sources_out = gr.JSON(label="📚 RAG Sources", visible=False, scale=2)
|
| 133 |
route_out = gr.JSON(label="🛣️ Routing Trace", visible=False, scale=2)
|
| 134 |
|
|
|
|
|
|
|
| 135 |
def refresh_corpora():
|
| 136 |
choices = ["(none)", *_get_corpora_sync(bus)]
|
| 137 |
return gr.update(choices=choices, value=choices[0])
|
| 138 |
|
| 139 |
-
async def handle_send(message: str, history: list, corpus: str, model: str):
|
| 140 |
if not message.strip():
|
| 141 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
history = history or []
|
| 144 |
history.append({"role": "user", "content": message})
|
|
@@ -150,7 +164,67 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 150 |
"content": "⚠️ Bus not connected — run as a real HearthNet node.",
|
| 151 |
}
|
| 152 |
)
|
| 153 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
trace: dict = {"rag": None, "llm": None, "routed_to": None}
|
| 156 |
try:
|
|
@@ -197,10 +271,6 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 197 |
{"role": h["role"], "content": _msg_text(h["content"])} for h in history
|
| 198 |
)
|
| 199 |
|
| 200 |
-
params: dict = {}
|
| 201 |
-
if model and model != "auto":
|
| 202 |
-
params["model"] = model
|
| 203 |
-
|
| 204 |
result = await bus.call(
|
| 205 |
"llm.chat",
|
| 206 |
(1, 0),
|
|
@@ -233,6 +303,7 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 233 |
"",
|
| 234 |
gr.update(visible=bool(sources), value=sources),
|
| 235 |
gr.update(visible=True, value=trace),
|
|
|
|
| 236 |
)
|
| 237 |
|
| 238 |
except Exception as exc:
|
|
@@ -243,16 +314,17 @@ to the best available LLM node — either on this device or on a peer.
|
|
| 243 |
"",
|
| 244 |
gr.update(visible=False),
|
| 245 |
gr.update(visible=True, value=trace),
|
|
|
|
| 246 |
)
|
| 247 |
|
| 248 |
refresh_corpora_btn.click(refresh_corpora, outputs=corpus_selector)
|
| 249 |
send_btn.click(
|
| 250 |
handle_send,
|
| 251 |
-
inputs=[msg_input, chatbot, corpus_selector, model_selector],
|
| 252 |
-
outputs=[chatbot, msg_input, sources_out, route_out],
|
| 253 |
)
|
| 254 |
msg_input.submit(
|
| 255 |
handle_send,
|
| 256 |
-
inputs=[msg_input, chatbot, corpus_selector, model_selector],
|
| 257 |
-
outputs=[chatbot, msg_input, sources_out, route_out],
|
| 258 |
)
|
|
|
|
| 113 |
)
|
| 114 |
refresh_corpora_btn = gr.Button("🔄 Refresh Corpora", size="sm", scale=1)
|
| 115 |
|
| 116 |
+
agent_toggle = gr.Checkbox(
|
| 117 |
+
label="🤖 Agent mode — the model plans and calls mesh tools "
|
| 118 |
+
"(search_corpus, list_marketplace, translate, route_expert, …) over several steps",
|
| 119 |
+
value=False,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
chatbot = gr.Chatbot(
|
| 123 |
label="Conversation",
|
| 124 |
height=440,
|
|
|
|
| 138 |
sources_out = gr.JSON(label="📚 RAG Sources", visible=False, scale=2)
|
| 139 |
route_out = gr.JSON(label="🛣️ Routing Trace", visible=False, scale=2)
|
| 140 |
|
| 141 |
+
agent_out = gr.JSON(label="🧠 Agent Steps (Thought → Tool → Observation)", visible=False)
|
| 142 |
+
|
| 143 |
def refresh_corpora():
|
| 144 |
choices = ["(none)", *_get_corpora_sync(bus)]
|
| 145 |
return gr.update(choices=choices, value=choices[0])
|
| 146 |
|
| 147 |
+
async def handle_send(message: str, history: list, corpus: str, model: str, agent: bool):
|
| 148 |
if not message.strip():
|
| 149 |
+
return (
|
| 150 |
+
history,
|
| 151 |
+
"",
|
| 152 |
+
gr.update(visible=False),
|
| 153 |
+
gr.update(visible=False),
|
| 154 |
+
gr.update(visible=False),
|
| 155 |
+
)
|
| 156 |
|
| 157 |
history = history or []
|
| 158 |
history.append({"role": "user", "content": message})
|
|
|
|
| 164 |
"content": "⚠️ Bus not connected — run as a real HearthNet node.",
|
| 165 |
}
|
| 166 |
)
|
| 167 |
+
return (
|
| 168 |
+
history,
|
| 169 |
+
"",
|
| 170 |
+
gr.update(visible=False),
|
| 171 |
+
gr.update(visible=False),
|
| 172 |
+
gr.update(visible=False),
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
params: dict = {}
|
| 176 |
+
if model and model != "auto":
|
| 177 |
+
params["model"] = model
|
| 178 |
+
|
| 179 |
+
# --- Agent mode: multi-step ReAct loop over real mesh tools --------
|
| 180 |
+
if agent:
|
| 181 |
+
from hearthnet.services.llm.tools import default_tool_set
|
| 182 |
+
|
| 183 |
+
async def _call_llm(msgs: list) -> str:
|
| 184 |
+
res = await bus.call(
|
| 185 |
+
"llm.chat",
|
| 186 |
+
(1, 0),
|
| 187 |
+
{"params": dict(params), "input": {"messages": msgs}},
|
| 188 |
+
)
|
| 189 |
+
if isinstance(res, dict) and "error" in res:
|
| 190 |
+
raise RuntimeError(res.get("message", res.get("error", "llm error")))
|
| 191 |
+
out = res.get("output", {}) if isinstance(res, dict) else {}
|
| 192 |
+
return (
|
| 193 |
+
out.get("message", {}).get("content")
|
| 194 |
+
or out.get("text")
|
| 195 |
+
or ""
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
executor = default_tool_set(bus)
|
| 199 |
+
loop_history = [
|
| 200 |
+
{"role": h["role"], "content": _msg_text(h["content"])}
|
| 201 |
+
for h in history[:-1]
|
| 202 |
+
]
|
| 203 |
+
try:
|
| 204 |
+
agent_result = await executor.run_react_loop(
|
| 205 |
+
message,
|
| 206 |
+
_call_llm,
|
| 207 |
+
history=loop_history,
|
| 208 |
+
max_iterations=6,
|
| 209 |
+
)
|
| 210 |
+
reply = agent_result["final"] or "(agent produced no final answer)"
|
| 211 |
+
history.append({"role": "assistant", "content": reply})
|
| 212 |
+
return (
|
| 213 |
+
history,
|
| 214 |
+
"",
|
| 215 |
+
gr.update(visible=False),
|
| 216 |
+
gr.update(visible=False),
|
| 217 |
+
gr.update(visible=True, value=agent_result["steps"]),
|
| 218 |
+
)
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
history.append({"role": "assistant", "content": f"❌ Agent error: {exc}"})
|
| 221 |
+
return (
|
| 222 |
+
history,
|
| 223 |
+
"",
|
| 224 |
+
gr.update(visible=False),
|
| 225 |
+
gr.update(visible=False),
|
| 226 |
+
gr.update(visible=True, value=[{"type": "error", "text": str(exc)}]),
|
| 227 |
+
)
|
| 228 |
|
| 229 |
trace: dict = {"rag": None, "llm": None, "routed_to": None}
|
| 230 |
try:
|
|
|
|
| 271 |
{"role": h["role"], "content": _msg_text(h["content"])} for h in history
|
| 272 |
)
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
result = await bus.call(
|
| 275 |
"llm.chat",
|
| 276 |
(1, 0),
|
|
|
|
| 303 |
"",
|
| 304 |
gr.update(visible=bool(sources), value=sources),
|
| 305 |
gr.update(visible=True, value=trace),
|
| 306 |
+
gr.update(visible=False),
|
| 307 |
)
|
| 308 |
|
| 309 |
except Exception as exc:
|
|
|
|
| 314 |
"",
|
| 315 |
gr.update(visible=False),
|
| 316 |
gr.update(visible=True, value=trace),
|
| 317 |
+
gr.update(visible=False),
|
| 318 |
)
|
| 319 |
|
| 320 |
refresh_corpora_btn.click(refresh_corpora, outputs=corpus_selector)
|
| 321 |
send_btn.click(
|
| 322 |
handle_send,
|
| 323 |
+
inputs=[msg_input, chatbot, corpus_selector, model_selector, agent_toggle],
|
| 324 |
+
outputs=[chatbot, msg_input, sources_out, route_out, agent_out],
|
| 325 |
)
|
| 326 |
msg_input.submit(
|
| 327 |
handle_send,
|
| 328 |
+
inputs=[msg_input, chatbot, corpus_selector, model_selector, agent_toggle],
|
| 329 |
+
outputs=[chatbot, msg_input, sources_out, route_out, agent_out],
|
| 330 |
)
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 1 agent loop — M21 tool calls wired to the real capability bus.
|
| 2 |
+
|
| 3 |
+
No mocks: the bus, registry, router and demo ``rag.query`` capability are all
|
| 4 |
+
real in-process components. The only scripted piece is the LLM *role* itself
|
| 5 |
+
(``call_llm``), which is legitimate — a deterministic local stand-in for the
|
| 6 |
+
model's text output, exactly what a tiny local model would emit.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
|
| 13 |
+
from hearthnet.node import InMemoryNetwork
|
| 14 |
+
from hearthnet.services.llm.tools import ToolCall, ToolExecutor, default_tool_set
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _caller_with_demo():
|
| 18 |
+
network = InMemoryNetwork()
|
| 19 |
+
caller = network.add_node("ed25519:caller", "caller")
|
| 20 |
+
provider = network.add_node("ed25519:provider", "provider")
|
| 21 |
+
provider.install_demo_services()
|
| 22 |
+
network.mesh_discover()
|
| 23 |
+
return caller
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_execute_dispatches_tool_to_real_bus() -> None:
|
| 27 |
+
caller = _caller_with_demo()
|
| 28 |
+
executor = default_tool_set(caller.bus)
|
| 29 |
+
|
| 30 |
+
result = asyncio.run(
|
| 31 |
+
executor.execute(
|
| 32 |
+
ToolCall(id="t1", name="search_corpus", arguments={"query": "clean water"})
|
| 33 |
+
)
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
assert result.is_error is False
|
| 37 |
+
chunks = result.content["chunks"]
|
| 38 |
+
assert chunks[0]["metadata"]["doc_title"] == "Water"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_react_loop_grounds_answer_in_tool_result() -> None:
|
| 42 |
+
caller = _caller_with_demo()
|
| 43 |
+
executor = default_tool_set(caller.bus)
|
| 44 |
+
|
| 45 |
+
async def call_llm(messages: list[dict]) -> str:
|
| 46 |
+
# Once the bus has handed back an Observation, answer; otherwise call a tool.
|
| 47 |
+
if any("Observation from" in str(m.get("content", "")) for m in messages):
|
| 48 |
+
return "Based on the corpus, the mesh has a Water document about clean water."
|
| 49 |
+
return 'action: {"tool": "search_corpus", "query": "clean water"}'
|
| 50 |
+
|
| 51 |
+
out = asyncio.run(executor.run_react_loop("Tell me about clean water", call_llm))
|
| 52 |
+
|
| 53 |
+
assert "Water" in out["final"]
|
| 54 |
+
tool_steps = [s for s in out["steps"] if s["type"] == "tool"]
|
| 55 |
+
assert tool_steps and tool_steps[0]["name"] == "search_corpus"
|
| 56 |
+
assert tool_steps[0]["is_error"] is False
|
| 57 |
+
assert out["steps"][-1]["type"] == "final"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_react_loop_finalises_when_budget_exhausted() -> None:
|
| 61 |
+
caller = _caller_with_demo()
|
| 62 |
+
executor = default_tool_set(caller.bus)
|
| 63 |
+
|
| 64 |
+
async def call_llm(messages: list[dict]) -> str:
|
| 65 |
+
# Never stops calling tools on its own — force the budget guard to engage.
|
| 66 |
+
if any("budget reached" in str(m.get("content", "")) for m in messages):
|
| 67 |
+
return "Final answer after the tool budget was reached."
|
| 68 |
+
return 'action: {"tool": "list_marketplace"}'
|
| 69 |
+
|
| 70 |
+
out = asyncio.run(
|
| 71 |
+
executor.run_react_loop("keep going", call_llm, max_iterations=2)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
assert out["final"].startswith("Final answer")
|
| 75 |
+
tool_steps = [s for s in out["steps"] if s["type"] == "tool"]
|
| 76 |
+
assert len(tool_steps) == 2
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_react_loop_recovers_from_bad_action_json() -> None:
|
| 80 |
+
caller = _caller_with_demo()
|
| 81 |
+
executor = default_tool_set(caller.bus)
|
| 82 |
+
calls = {"n": 0}
|
| 83 |
+
|
| 84 |
+
async def call_llm(messages: list[dict]) -> str:
|
| 85 |
+
calls["n"] += 1
|
| 86 |
+
if calls["n"] == 1:
|
| 87 |
+
return 'action: {not valid json}'
|
| 88 |
+
return "Recovered and answering directly."
|
| 89 |
+
|
| 90 |
+
out = asyncio.run(executor.run_react_loop("hi", call_llm))
|
| 91 |
+
|
| 92 |
+
assert out["final"] == "Recovered and answering directly."
|
| 93 |
+
assert calls["n"] == 2
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_unbound_tool_errors_without_silent_fallback() -> None:
|
| 97 |
+
# A tool whose capability is not on the bus must error clearly, not fabricate.
|
| 98 |
+
caller = _caller_with_demo()
|
| 99 |
+
executor = ToolExecutor(bus=caller.bus, tools=default_tool_set(caller.bus).definitions())
|
| 100 |
+
|
| 101 |
+
result = asyncio.run(
|
| 102 |
+
executor.execute(ToolCall(id="x", name="translate", arguments={"text": "hi", "target_lang": "de"}))
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
assert result.is_error is True
|
| 106 |
+
assert "error" in result.content
|