GitHub Actions commited on
Commit
833bc64
·
1 Parent(s): b9c58f7

feat(agent): wire real ReAct tool-calling loop into Ask tab (M21)

Browse files

Turns 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 CHANGED
@@ -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
- from hearthnet.bus.router import RouteRequest
199
-
200
- req = RouteRequest(
201
- capability=definition.bound_capability,
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 = await self._bus.call(req)
208
- out = resp.get("output", resp)
 
 
 
 
 
 
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
- """Run a full tool-call loop and return the accumulated message list.
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
- This is a *stub* for the protocol loop — the actual LLM invocation
244
- happens in the LLM service (M04) which calls back via the bus. This
245
- helper is used by the Gradio UI (M08) and tests.
246
 
247
- Returns the extended message list after all tool rounds are complete.
 
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)
hearthnet/ui/tabs/ask.py CHANGED
@@ -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 history, "", gr.update(visible=False), gr.update(visible=False)
 
 
 
 
 
 
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 history, "", gr.update(visible=False), gr.update(visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  )
tests/test_phase1_agent.py ADDED
@@ -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