GitHub Actions commited on
Commit
146edc4
·
1 Parent(s): b1f3bec

feat: 15 targeted improvements — RAG persistence, bus failover, agent hardening, deps sync

Browse files

RAG / storage
- CorpusStore: chroma → SQLite → in-memory fallback; mkdir unconditional;
WARNING log at startup shows active backend
- RagService: _log.warning on blob_store.put / event_log.append_local failures
instead of silent pass
- Expose corpus stats (backend, persistent, chunks) in Settings tab
- search_corpus tool bound to rag.federated_query (scatter-gather path)
- corpus param plumbed into body["params"] so router _corpus_matches fires

Agent / LLM tools
- Brace-matching JSON parser (_extract_json_object) replaces fragile {.*?} regex
- max_iterations default lowered 6→4; one-shot worked example in system_prompt

Bus
- Failover to _best_alternative when sole provider is quarantined (route→None)
- CapabilityDescriptor.schema_hash prefix corrected: "blake3:" → "sha256:"

Boot / deps
- MoE expert self-registers after seed corpus thread completes
- requirements.txt: add blake3>=0.4.0 and click>=8.1 (were in pyproject.toml only)
- scripts/check_deps_sync.py: CI script to keep the two files in sync

Docs / cleanup
- README: architecture diagram SQLite, M05 description updated; token
signature gap documented in security section
- hearthnet/services/file/ deleted (dead code; only plural files/ is imported)
- transport/server.py: remove duplicate UTC=UTC assignment

Tests
- 13 new passing tests in tests/test_improvements_batch.py and
tests/test_federated_rag.py (SQLite temp-dir isolation + Windows lock fix)

README.md CHANGED
@@ -47,7 +47,7 @@ license: apache-2.0
47
  <img src="https://img.shields.io/badge/OpenBMB-MiniCPM%20multi--model-1f6feb" alt="OpenBMB">
48
  </p>
49
 
50
- > **Build Small Hackathon entry** — Backyard AI track · 🐜 Tiny Titan · 🤖 Best Agent
51
  >
52
  > 📺 **Demo video:** <a href="https://huggingface.co/spaces/build-small-hackathon/HearthNet/resolve/main/hf_hackathon_screenrecording_v1.webm">HF Space Recording</a> · <a href="https://videos.simpleshow.com/8vSfxilim8">Simple Show Demo</a>
53
 
@@ -57,7 +57,8 @@ license: apache-2.0
57
  Your browser does not support the video tag.
58
  </video>
59
 
60
- > 📣 **Social post:** *(many)*
 
61
  >
62
  > **June 14 bug-fix release:** 8 critical bugs fixed — seed corpus now actually ingested,
63
  > node lifecycle corrected (`stop()` previously silently no-oped), sticky session memory
@@ -86,7 +87,7 @@ intelligent routing bus, and work **completely offline**. When the internet is a
86
 
87
  ## Features
88
 
89
- ###Agent Mode (ReAct tool calling)
90
  Flip the **Agent mode** toggle in the Ask tab and the model stops being a chatbot and starts being an **agent**: it plans, calls real mesh tools over several steps, reads the results, and only then answers. Every step is shown live — **Thought → Tool → Observation → Answer**.
91
 
92
  The agent's tools are bound to **real capabilities already on the bus** (no mock handlers):
@@ -94,7 +95,7 @@ The agent's tools are bound to **real capabilities already on the bus** (no mock
94
 
95
  > 💡 **Try the browser agent:** press **`a`** (or just type **`hearthnet`**) anywhere on the dashboard to open the in-browser WebLLM agent showcase. Press **`e`** for the live mesh/news ticker, **`Esc`** to close.
96
 
97
- ### 🧠 Intelligent Routing (NEW)
98
  When you ask a question, the bus scores available LLM nodes by latency, load, and reliability. Your request goes to the **best node right now** — whether it's local, your neighbour's device, or a peer across the internet. Failover is automatic: if the preferred node can't help, the next-best provider takes over **invisibly**.
99
 
100
  **Routing Trace** shows you exactly where your request was served:
@@ -381,6 +382,8 @@ If no suitable backend is available: clear error message returned. Never silent,
381
  - **X3DH + Double Ratchet** — end-to-end encrypted chat (M23)
382
  - **BLAKE3** — content-addressed file blobs (tamper-evident)
383
  - **localhost-only CLI** — all admin HTTP restricted to 127.0.0.1
 
 
384
  - **Bandit HIGH findings: 0** (verified in CI)
385
 
386
  ---
@@ -402,7 +405,7 @@ If no suitable backend is available: clear error message returned. Never silent,
402
  ┌──────────▼┐ ┌──▼───┐ ┌▼──────────┐ ┌────────────┐
403
  │ LLM (M04) │ │ RAG │ │ MoE (M27) │ │ Chat (M10) │
404
  │llama.cpp │ │(M05) │ │ Expert │ │ Marketplace│
405
- │ Ollama │ │Chroma│ │ Registry │ │ (M06) Files│
406
  │HF Transfm │ │Embed │ └───────────┘ └────────────┘
407
  └─────┬─────┘ └──┬───┘
408
  └─────┬──────┘
@@ -426,7 +429,7 @@ If no suitable backend is available: clear error message returned. Never silent,
426
  | M02 | Peer discovery (mDNS, UDP broadcast, PeerRegistry) | ✅ |
427
  | M03 | Capability bus (schema validation, routing, tracing) | ✅ |
428
  | M04 | LLM service (llama.cpp, Ollama, HF Transformers, cloud fallback) | ✅ |
429
- | M05 | RAG (chunker, ChromaDB, IngestPipeline, semantic search) | ✅ |
430
  | M06 | Marketplace (event-sourced, Lamport-clocked posts) | ✅ |
431
  | M07 | File blobs (BLAKE3 hash, content-addressed, chunked transfer) | ✅ |
432
  | M08 | Gradio UI (8 tabs: Ask, Chat, Mesh, Marketplace, Files, Emergency, Settings, Getting Started) | ✅ |
 
47
  <img src="https://img.shields.io/badge/OpenBMB-MiniCPM%20multi--model-1f6feb" alt="OpenBMB">
48
  </p>
49
 
50
+ > **Build Small Hackathon entry** — Backyard AI track · 🐜 Tiny Titan · 🤖 Best Agent 🫥 press e or a to see the easter egg.
51
  >
52
  > 📺 **Demo video:** <a href="https://huggingface.co/spaces/build-small-hackathon/HearthNet/resolve/main/hf_hackathon_screenrecording_v1.webm">HF Space Recording</a> · <a href="https://videos.simpleshow.com/8vSfxilim8">Simple Show Demo</a>
53
 
 
57
  Your browser does not support the video tag.
58
  </video>
59
 
60
+ > 📣 **Social post:** [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047) [tweet on x](https://twitter.com/zX14_7/status/2064853015622775047)
61
+
62
  >
63
  > **June 14 bug-fix release:** 8 critical bugs fixed — seed corpus now actually ingested,
64
  > node lifecycle corrected (`stop()` previously silently no-oped), sticky session memory
 
87
 
88
  ## Features
89
 
90
+ ### Agent Mode (ReAct tool calling)
91
  Flip the **Agent mode** toggle in the Ask tab and the model stops being a chatbot and starts being an **agent**: it plans, calls real mesh tools over several steps, reads the results, and only then answers. Every step is shown live — **Thought → Tool → Observation → Answer**.
92
 
93
  The agent's tools are bound to **real capabilities already on the bus** (no mock handlers):
 
95
 
96
  > 💡 **Try the browser agent:** press **`a`** (or just type **`hearthnet`**) anywhere on the dashboard to open the in-browser WebLLM agent showcase. Press **`e`** for the live mesh/news ticker, **`Esc`** to close.
97
 
98
+ ### 🧠 Intelligent Routing (NEW)
99
  When you ask a question, the bus scores available LLM nodes by latency, load, and reliability. Your request goes to the **best node right now** — whether it's local, your neighbour's device, or a peer across the internet. Failover is automatic: if the preferred node can't help, the next-best provider takes over **invisibly**.
100
 
101
  **Routing Trace** shows you exactly where your request was served:
 
382
  - **X3DH + Double Ratchet** — end-to-end encrypted chat (M23)
383
  - **BLAKE3** — content-addressed file blobs (tamper-evident)
384
  - **localhost-only CLI** — all admin HTTP restricted to 127.0.0.1
385
+ - **Capability token `exp` claim** — checked in `bus.handle_call()` before routing; expired tokens receive `{"error": "token_expired"}` without hitting any handler
386
+ - **Token signature verification** — Ed25519 signature checking is implemented in `AuthService` (`auth.token.verify`) and is available on the bus. The HTTP transport (`/bus/v1/call`) currently passes tokens to `handle_call()` where expiry is enforced; full per-request signature verification on inbound HTTP calls is a planned hardening step.
387
  - **Bandit HIGH findings: 0** (verified in CI)
388
 
389
  ---
 
405
  ┌──────────▼┐ ┌──▼───┐ ┌▼──────────┐ ┌────────────┐
406
  │ LLM (M04) │ │ RAG │ │ MoE (M27) │ │ Chat (M10) │
407
  │llama.cpp │ │(M05) │ │ Expert │ │ Marketplace│
408
+ │ Ollama │ │SQLite│ │ Registry │ │ (M06) Files│
409
  │HF Transfm │ │Embed │ └───────────┘ └────────────┘
410
  └─────┬─────┘ └──┬───┘
411
  └─────┬──────┘
 
429
  | M02 | Peer discovery (mDNS, UDP broadcast, PeerRegistry) | ✅ |
430
  | M03 | Capability bus (schema validation, routing, tracing) | ✅ |
431
  | M04 | LLM service (llama.cpp, Ollama, HF Transformers, cloud fallback) | ✅ |
432
+ | M05 | RAG (chunker, SQLite/ChromaDB vector store, IngestPipeline, federated scatter-gather) | ✅ |
433
  | M06 | Marketplace (event-sourced, Lamport-clocked posts) | ✅ |
434
  | M07 | File blobs (BLAKE3 hash, content-addressed, chunked transfer) | ✅ |
435
  | M08 | Gradio UI (8 tabs: Ask, Chat, Mesh, Marketplace, Files, Emergency, Settings, Getting Started) | ✅ |
app.py CHANGED
@@ -450,6 +450,36 @@ def _build_node():
450
  _seed_thread.start()
451
  _seed_thread.join(timeout=60) # wait up to 60 s; don't block Space startup indefinitely
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  # Marketplace, Chat, Files — now durably event-sourced where supported.
454
  node.bus.register_service(MarketplaceService(event_log=event_log, node_id=node.node_id))
455
  node.bus.register_service(ChatService(node.node_id, event_log=event_log, bus=node.bus))
 
450
  _seed_thread.start()
451
  _seed_thread.join(timeout=60) # wait up to 60 s; don't block Space startup indefinitely
452
 
453
+ # Register this node's LLM model as an expert in the MoE registry so
454
+ # route_expert tool calls return meaningful results instead of an empty list.
455
+ try:
456
+ _moe_tags = list({
457
+ doc.get("id", "").split(".")[0]
458
+ for doc in SEED_CORPUS
459
+ if doc.get("id")
460
+ } | {"emergency", "mesh", "community"})
461
+ loop_moe = asyncio.new_event_loop()
462
+ loop_moe.run_until_complete(
463
+ node.bus.call(
464
+ "moe.register",
465
+ (1, 0),
466
+ {
467
+ "input": {
468
+ "expert_id": f"model:{MODEL_ID}",
469
+ "expert_type": "model",
470
+ "topic_tags": _moe_tags,
471
+ "confidence_score": 0.6,
472
+ "community_id": node.community_id,
473
+ "name": MODEL_ID.split("/")[-1],
474
+ "ttl_seconds": 0,
475
+ }
476
+ },
477
+ )
478
+ )
479
+ loop_moe.close()
480
+ except Exception:
481
+ pass
482
+
483
  # Marketplace, Chat, Files — now durably event-sourced where supported.
484
  node.bus.register_service(MarketplaceService(event_log=event_log, node_id=node.node_id))
485
  node.bus.register_service(ChatService(node.node_id, event_log=event_log, bus=node.bus))
hearthnet/bus/__init__.py CHANGED
@@ -143,6 +143,13 @@ class CapabilityBus:
143
 
144
  entry = self.router.route_sticky(req) if req.session_id else self.router.route(req)
145
  if entry is None:
 
 
 
 
 
 
 
146
  raise BusError("not_found", f"no provider for {req.capability}@{req.version_req}")
147
  result = await self._execute_entry(entry, req, local_only)
148
 
 
143
 
144
  entry = self.router.route_sticky(req) if req.session_id else self.router.route(req)
145
  if entry is None:
146
+ # No direct route — try any alternative before giving up.
147
+ # Covers the quarantined-sole-provider case: route() skips quarantined
148
+ # entries, but _best_alternative can still find an unquarantined remote.
149
+ alternative = self._best_alternative(req, exclude=set())
150
+ if alternative is not None:
151
+ result = await self._execute_entry(alternative, req, local_only)
152
+ return self._stamp_route(result, alternative, local_only)
153
  raise BusError("not_found", f"no provider for {req.capability}@{req.version_req}")
154
  result = await self._execute_entry(entry, req, local_only)
155
 
hearthnet/bus/capability.py CHANGED
@@ -45,7 +45,7 @@ class CapabilityDescriptor:
45
  "response_schema": self.response_schema,
46
  "stream_schema": self.stream_schema,
47
  }
48
- return "blake3:" + hashlib.sha256(_canonical_json(payload)).hexdigest()
49
 
50
 
51
  @dataclass
 
45
  "response_schema": self.response_schema,
46
  "stream_schema": self.stream_schema,
47
  }
48
+ return "sha256:" + hashlib.sha256(_canonical_json(payload)).hexdigest()
49
 
50
 
51
  @dataclass
hearthnet/services/file/__init__.py DELETED
@@ -1,5 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from hearthnet.services.file.service import FileService
4
-
5
- __all__ = ["FileService"]
 
 
 
 
 
 
hearthnet/services/file/service.py DELETED
@@ -1,108 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- from typing import Any
5
-
6
- from hearthnet.blobs.store import BlobStore
7
- from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
8
-
9
-
10
- class FileService:
11
- name = "file"
12
- version = "1.0"
13
-
14
- def __init__(self, store: BlobStore) -> None:
15
- self.store = store
16
-
17
- def capabilities(self) -> list[tuple[Any, ...]]:
18
- return [
19
- (
20
- CapabilityDescriptor(
21
- name="file.read",
22
- params={},
23
- trust_required="member",
24
- max_concurrent=8,
25
- ),
26
- self.handle_read,
27
- None,
28
- ),
29
- (
30
- CapabilityDescriptor(
31
- name="file.list",
32
- params={},
33
- trust_required="member",
34
- max_concurrent=8,
35
- ),
36
- self.handle_list,
37
- None,
38
- ),
39
- (
40
- CapabilityDescriptor(
41
- name="file.advertise",
42
- params={},
43
- trust_required="member",
44
- max_concurrent=4,
45
- ),
46
- self.handle_advertise,
47
- None,
48
- ),
49
- (
50
- CapabilityDescriptor(
51
- name="file.put",
52
- params={},
53
- trust_required="trusted",
54
- max_concurrent=2,
55
- timeout_seconds=600,
56
- ),
57
- self.handle_put,
58
- None,
59
- ),
60
- ]
61
-
62
- async def handle_read(self, req: RouteRequest) -> dict[str, Any]:
63
- """input: {cid: str} → output: {cid, size_bytes, filename, chunks: [...]}"""
64
- cid = req.body.get("input", {}).get("cid", "")
65
- if not self.store.has(cid):
66
- return {"error": "not_found", "message": f"Blob {cid} not found"}
67
- manifest = self.store.get_manifest(cid)
68
- return {
69
- "output": {
70
- "cid": manifest.cid,
71
- "size_bytes": manifest.size_bytes,
72
- "filename": manifest.filename,
73
- "chunks": [
74
- {"index": c.index, "cid": c.cid, "size_bytes": c.size_bytes}
75
- for c in manifest.chunks
76
- ],
77
- },
78
- "meta": {},
79
- }
80
-
81
- async def handle_list(self, req: RouteRequest) -> dict[str, Any]:
82
- blobs = self.store.list_blobs()
83
- return {
84
- "output": {
85
- "blobs": [
86
- {"cid": b.cid, "size_bytes": b.size_bytes, "filename": b.filename}
87
- for b in blobs
88
- ]
89
- },
90
- "meta": {},
91
- }
92
-
93
- async def handle_advertise(self, req: RouteRequest) -> dict[str, Any]:
94
- """input: {cid, filename, size_bytes} → acknowledge, actual transfer is separate"""
95
- inp = req.body.get("input", {})
96
- return {"output": {"acknowledged": True, "cid": inp.get("cid")}, "meta": {}}
97
-
98
- async def handle_put(self, req: RouteRequest) -> dict[str, Any]:
99
- """input: {data_b64: str, filename: str} → store blob → output: {cid, size_bytes}"""
100
- inp = req.body.get("input", {})
101
- data_b64 = inp.get("data_b64", "")
102
- filename = inp.get("filename")
103
- try:
104
- data = base64.b64decode(data_b64)
105
- except Exception:
106
- return {"error": "bad_request", "message": "Invalid base64 data"}
107
- manifest = self.store.put(data, filename=filename)
108
- return {"output": {"cid": manifest.cid, "size_bytes": manifest.size_bytes}, "meta": {}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hearthnet/services/llm/tools.py CHANGED
@@ -193,17 +193,24 @@ class ToolExecutor:
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(
@@ -251,22 +258,26 @@ class ToolExecutor:
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
 
@@ -276,7 +287,7 @@ class ToolExecutor:
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"}``.
@@ -309,7 +320,10 @@ class ToolExecutor:
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)):
@@ -324,8 +338,15 @@ class ToolExecutor:
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(
@@ -369,7 +390,7 @@ class ToolExecutor:
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
 
@@ -401,6 +422,45 @@ class ToolExecutor:
401
  return messages
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  # ---------------------------------------------------------------------------
405
  # Default tool set
406
  # ---------------------------------------------------------------------------
@@ -428,7 +488,7 @@ def default_tool_set(bus: Any) -> ToolExecutor:
428
  },
429
  "required": ["query"],
430
  },
431
- bound_capability="rag.query",
432
  bound_version=(1, 0),
433
  ),
434
  ToolDefinition(
 
193
  )
194
 
195
  # 2. Bus-dispatched capability.
 
 
 
 
 
196
  if definition and definition.bound_capability and self._bus is not None:
197
  try:
198
+ args = dict(call.arguments)
199
+ # Plumb corpus and top_k into body["params"] so the router's
200
+ # _corpus_matches predicate sees them; leave them in input too
201
+ # so the handler can read them if it wants.
202
+ bus_params: dict = {}
203
+ if "corpus" in args:
204
+ bus_params["corpus"] = args["corpus"]
205
+ if "top_k" in args:
206
+ bus_params["top_k"] = args["top_k"]
207
+ call_body: dict = {"input": args}
208
+ if bus_params:
209
+ call_body["params"] = bus_params
210
  resp = await self._bus.call(
211
  definition.bound_capability,
212
  definition.bound_version or (1, 0),
213
+ call_body,
214
  )
215
  if isinstance(resp, dict) and "error" in resp:
216
  return ToolResult(
 
258
  def system_prompt(self) -> str:
259
  """Build the ReAct system prompt that teaches the model to call tools.
260
 
261
+ One concrete worked example is included because tiny models (SmolLM2,
262
+ Phi-3-mini) follow few-shot examples far more reliably than abstract
263
+ format rules. Keep the example short so it fits in context on 135M models.
 
 
264
  """
265
  return (
266
  "You are a HearthNet agent. You can use tools to answer questions about "
267
  "the local mesh, documents, neighbours, and the world.\n\n"
268
  "Available tools:\n"
269
  f"{self.tool_help()}\n\n"
270
+ "To use a tool, output EXACTLY one line starting with 'action:':\n"
271
  'action: {"tool": "<tool_name>", "<arg>": "<value>"}\n'
272
  "Then stop and wait. You will receive a line starting with 'Observation:'.\n"
273
  "You may use tools several times. When you have enough information, "
274
+ "reply to the user directly in plain text with NO 'action:' line.\n\n"
275
+ "Example:\n"
276
+ "User: What do I do if water is cut off?\n"
277
+ 'action: {"tool": "search_corpus", "query": "water supply cut off emergency"}\n'
278
+ "Observation: Store at least 3 litres per person per day. Boil before drinking.\n"
279
+ "You should store at least 3 litres of water per person per day and boil it "
280
+ "before drinking during an outage.\n\n"
281
  "Keep tool arguments minimal and valid JSON."
282
  )
283
 
 
287
  call_llm: Callable[[list[dict]], Any],
288
  *,
289
  history: list[dict] | None = None,
290
+ max_iterations: int = 4,
291
  on_step: Callable[[dict], Any] | None = None,
292
  ) -> dict:
293
  """Run a ReAct tool-use loop and return ``{"final", "steps"}``.
 
320
  if asyncio.iscoroutine(res):
321
  await res
322
 
323
+ # action_re finds the start of the action: prefix; we then use
324
+ # _extract_json_object to find the true closing brace so nested objects
325
+ # and arrays inside tool arguments are captured correctly.
326
+ action_re = re.compile(r"action\s*:\s*(\{)", re.IGNORECASE)
327
  final_text = ""
328
 
329
  for _ in range(max(1, max_iterations)):
 
338
 
339
  await _emit({"type": "thought", "text": text[: match.start()].strip()})
340
 
341
+ # Use brace-matching parser instead of non-greedy regex so nested
342
+ # objects/arrays inside tool arguments are captured in full.
343
+ brace_start = match.start(1)
344
+ raw_json = _extract_json_object(text, brace_start)
345
+ if raw_json is None:
346
+ raw_json = match.group(1) # fallback to regex capture
347
+
348
  try:
349
+ action = json.loads(raw_json)
350
  except json.JSONDecodeError:
351
  chat.append({"role": "assistant", "content": text})
352
  chat.append(
 
390
  raw = await call_llm(chat)
391
  final_text = (raw if isinstance(raw, str) else str(raw)).strip()
392
  # Strip any trailing action line the model may still emit.
393
+ final_text = re.sub(r"action\s*:\s*\{[^}]*\}", "", final_text).strip()
394
  await _emit({"type": "final", "text": final_text})
395
  return {"final": final_text, "steps": steps}
396
 
 
422
  return messages
423
 
424
 
425
+ # ---------------------------------------------------------------------------
426
+ # JSON brace-matching helper
427
+ # ---------------------------------------------------------------------------
428
+
429
+
430
+ def _extract_json_object(text: str, start: int) -> str | None:
431
+ """Return the JSON object starting at text[start] (must be '{').
432
+
433
+ Walks forward counting '{'/'}' while respecting string literals (so braces
434
+ inside quoted strings don't throw off the count). Returns the full object
435
+ string including the outer braces, or None if no matching close-brace is
436
+ found. This replaces the non-greedy ``{.*?}`` regex which truncates at the
437
+ first '}' and breaks on nested objects or multi-element arrays.
438
+ """
439
+ if start >= len(text) or text[start] != "{":
440
+ return None
441
+ depth = 0
442
+ in_string = False
443
+ escape_next = False
444
+ i = start
445
+ while i < len(text):
446
+ ch = text[i]
447
+ if escape_next:
448
+ escape_next = False
449
+ elif ch == "\\" and in_string:
450
+ escape_next = True
451
+ elif ch == '"':
452
+ in_string = not in_string
453
+ elif not in_string:
454
+ if ch == "{":
455
+ depth += 1
456
+ elif ch == "}":
457
+ depth -= 1
458
+ if depth == 0:
459
+ return text[start : i + 1]
460
+ i += 1
461
+ return None
462
+
463
+
464
  # ---------------------------------------------------------------------------
465
  # Default tool set
466
  # ---------------------------------------------------------------------------
 
488
  },
489
  "required": ["query"],
490
  },
491
+ bound_capability="rag.federated_query",
492
  bound_version=(1, 0),
493
  ),
494
  ToolDefinition(
hearthnet/services/rag/service.py CHANGED
@@ -1,11 +1,14 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
  from typing import Any
5
 
6
  from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
7
  from hearthnet.services.rag.store import CorpusStore, list_corpora
8
 
 
 
9
 
10
  class RagService:
11
  name = "rag"
@@ -146,8 +149,8 @@ class RagService:
146
  try:
147
  manifest = self._blob_store.put(text.encode("utf-8"), filename=title)
148
  blob_cid = manifest.cid
149
- except Exception:
150
- pass
151
 
152
  # Emit rag.document.ingested event so peers learn a new doc exists (X02).
153
  if not result.was_duplicate and self._event_log is not None:
@@ -166,8 +169,8 @@ class RagService:
166
  author,
167
  payload,
168
  )
169
- except Exception:
170
- pass
171
 
172
  return {
173
  "output": {
 
1
  from __future__ import annotations
2
 
3
+ import logging
4
  from pathlib import Path
5
  from typing import Any
6
 
7
  from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
8
  from hearthnet.services.rag.store import CorpusStore, list_corpora
9
 
10
+ _log = logging.getLogger(__name__)
11
+
12
 
13
  class RagService:
14
  name = "rag"
 
149
  try:
150
  manifest = self._blob_store.put(text.encode("utf-8"), filename=title)
151
  blob_cid = manifest.cid
152
+ except Exception as exc:
153
+ _log.warning("RAG blob_store.put failed for '%s': %s", title, exc)
154
 
155
  # Emit rag.document.ingested event so peers learn a new doc exists (X02).
156
  if not result.was_duplicate and self._event_log is not None:
 
169
  author,
170
  payload,
171
  )
172
+ except Exception as exc:
173
+ _log.warning("RAG event_log.append_local failed for '%s': %s", title, exc)
174
 
175
  return {
176
  "output": {
hearthnet/services/rag/store.py CHANGED
@@ -1,11 +1,16 @@
1
  from __future__ import annotations
2
 
 
 
 
3
  import uuid
4
  from dataclasses import dataclass
5
  from pathlib import Path
6
 
7
  from hearthnet.services.rag.chunker import Chunk
8
 
 
 
9
 
10
  @dataclass(frozen=True)
11
  class ScoredChunk:
@@ -14,9 +19,16 @@ class ScoredChunk:
14
 
15
 
16
  class CorpusStore:
17
- """In-memory vector store with cosine similarity.
 
 
 
 
 
18
 
19
- Uses chromadb if available, else falls back to in-memory list.
 
 
20
  """
21
 
22
  def __init__(self, corpora_dir: Path, corpus_name: str) -> None:
@@ -25,21 +37,62 @@ class CorpusStore:
25
  self._use_chroma = False
26
  self._chroma_client = None
27
  self._collection = None
28
- # Fallback: in-memory list of (chunk, embedding)
 
29
  self._items: list[tuple[Chunk, list[float]]] = []
 
 
 
 
30
  self._try_init_chroma()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  def _try_init_chroma(self) -> None:
33
  try:
34
  import chromadb # type: ignore[import-untyped]
35
 
36
- self._dir.mkdir(parents=True, exist_ok=True)
37
  self._chroma_client = chromadb.PersistentClient(path=str(self._dir / self._corpus))
38
  self._collection = self._chroma_client.get_or_create_collection(self._corpus)
39
  self._use_chroma = True
40
  except ImportError:
41
  pass
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def add(self, chunks: list[Chunk], embeddings: list[list[float]]) -> None:
44
  """Add chunks with their embeddings."""
45
  if self._use_chroma and self._collection is not None:
@@ -52,10 +105,31 @@ class CorpusStore:
52
  documents=documents,
53
  metadatas=metadatas,
54
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  else:
56
  for chunk, emb in zip(chunks, embeddings, strict=False):
57
  self._items.append((chunk, emb))
58
 
 
 
 
 
59
  def query(self, embedding: list[float], k: int = 5) -> list[ScoredChunk]:
60
  """Return top-k chunks by cosine similarity."""
61
  if self._use_chroma and self._collection is not None:
@@ -70,39 +144,89 @@ class CorpusStore:
70
  scored: list[ScoredChunk] = []
71
  docs = results.get("documents", [[]])[0]
72
  metas = results.get("metadatas", [[]])[0]
73
- # chromadb distances are L2 by default; convert to similarity
74
  distances = results.get("distances", [[]])[0]
75
  for doc, meta, dist in zip(docs, metas, distances, strict=False):
76
  score = 1.0 / (1.0 + dist)
77
  scored.append(ScoredChunk(chunk=Chunk(text=doc, metadata=meta), score=score))
78
  return scored
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  if not self._items:
80
  return []
81
- scored_items = [
82
  (chunk, self._cosine_similarity(embedding, emb)) for chunk, emb in self._items
83
  ]
84
- scored_items.sort(key=lambda x: x[1], reverse=True)
85
- return [ScoredChunk(chunk=chunk, score=score) for chunk, score in scored_items[:k]]
86
 
87
  def has_doc(self, doc_cid: str) -> bool:
88
  """True if any chunk with this doc_cid exists."""
89
  if self._use_chroma and self._collection is not None:
90
  results = self._collection.get(where={"doc_cid": doc_cid}, limit=1, include=[])
91
  return len(results.get("ids", [])) > 0
 
 
 
 
 
92
  return any(c.metadata.get("doc_cid") == doc_cid for c, _ in self._items)
93
 
94
  def count(self) -> int:
95
  if self._use_chroma and self._collection is not None:
96
  return self._collection.count()
 
 
 
97
  return len(self._items)
98
 
99
  def clear(self) -> None:
100
  if self._use_chroma and self._collection is not None and self._chroma_client is not None:
101
  self._chroma_client.delete_collection(self._corpus)
102
  self._collection = self._chroma_client.get_or_create_collection(self._corpus)
 
 
 
103
  else:
104
  self._items.clear()
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
107
  dot = sum(x * y for x, y in zip(a, b, strict=False))
108
  na = sum(x**2 for x in a) ** 0.5
@@ -114,15 +238,15 @@ def list_corpora(corpora_dir: Path) -> list[str]:
114
  """List corpus names found under corpora_dir."""
115
  if not corpora_dir.exists():
116
  return []
117
- return sorted(p.name for p in corpora_dir.iterdir() if p.is_dir())
118
 
119
 
120
  def corpus_info(corpora_dir: Path, corpus: str) -> dict:
121
- """Return {corpus, exists, count_chunks}."""
122
- corpus_path = corpora_dir / corpus
123
- exists = corpus_path.exists()
124
- count = 0
125
  if exists:
126
  store = CorpusStore(corpora_dir, corpus)
127
- count = store.count()
128
- return {"corpus": corpus, "exists": exists, "count_chunks": count}
 
1
  from __future__ import annotations
2
 
3
+ import json
4
+ import logging
5
+ import sqlite3
6
  import uuid
7
  from dataclasses import dataclass
8
  from pathlib import Path
9
 
10
  from hearthnet.services.rag.chunker import Chunk
11
 
12
+ _log = logging.getLogger(__name__)
13
+
14
 
15
  @dataclass(frozen=True)
16
  class ScoredChunk:
 
19
 
20
 
21
  class CorpusStore:
22
+ """Persistent vector store chromadb if available, SQLite otherwise.
23
+
24
+ Backend selection (in order of preference):
25
+ 1. chromadb PersistentClient — if chromadb is installed
26
+ 2. SQLite (one .db file per corpus) — always available, survives restart
27
+ 3. in-memory list — last resort if SQLite also fails
28
 
29
+ The active backend is logged at WARNING level so it is visible in Space logs.
30
+ ``self._dir.mkdir()`` runs unconditionally so the corpora directory always
31
+ exists regardless of which backend wins.
32
  """
33
 
34
  def __init__(self, corpora_dir: Path, corpus_name: str) -> None:
 
37
  self._use_chroma = False
38
  self._chroma_client = None
39
  self._collection = None
40
+ self._db: sqlite3.Connection | None = None
41
+ # Pure in-memory fallback (only used when SQLite init also fails)
42
  self._items: list[tuple[Chunk, list[float]]] = []
43
+
44
+ # Always create the directory — independent of which backend is chosen.
45
+ self._dir.mkdir(parents=True, exist_ok=True)
46
+
47
  self._try_init_chroma()
48
+ if not self._use_chroma:
49
+ self._init_sqlite()
50
+
51
+ if self._use_chroma:
52
+ backend = "chroma"
53
+ elif self._db is not None:
54
+ backend = "sqlite"
55
+ else:
56
+ backend = "in-memory/ephemeral"
57
+ _log.warning("RAG vector store: using %s backend for corpus '%s'", backend, corpus_name)
58
+
59
+ # ------------------------------------------------------------------
60
+ # Backend initialisation
61
+ # ------------------------------------------------------------------
62
 
63
  def _try_init_chroma(self) -> None:
64
  try:
65
  import chromadb # type: ignore[import-untyped]
66
 
 
67
  self._chroma_client = chromadb.PersistentClient(path=str(self._dir / self._corpus))
68
  self._collection = self._chroma_client.get_or_create_collection(self._corpus)
69
  self._use_chroma = True
70
  except ImportError:
71
  pass
72
 
73
+ def _init_sqlite(self) -> None:
74
+ db_path = self._dir / f"{self._corpus}.db"
75
+ try:
76
+ self._db = sqlite3.connect(str(db_path), check_same_thread=False)
77
+ self._db.execute("""
78
+ CREATE TABLE IF NOT EXISTS chunks (
79
+ id TEXT PRIMARY KEY,
80
+ doc_cid TEXT,
81
+ chunk_text TEXT NOT NULL,
82
+ metadata_json TEXT NOT NULL DEFAULT '{}',
83
+ embedding_json TEXT NOT NULL
84
+ )
85
+ """)
86
+ self._db.execute("CREATE INDEX IF NOT EXISTS idx_doc_cid ON chunks(doc_cid)")
87
+ self._db.commit()
88
+ except Exception as exc:
89
+ _log.warning("RAG SQLite init failed, using in-memory fallback: %s", exc)
90
+ self._db = None
91
+
92
+ # ------------------------------------------------------------------
93
+ # Write path
94
+ # ------------------------------------------------------------------
95
+
96
  def add(self, chunks: list[Chunk], embeddings: list[list[float]]) -> None:
97
  """Add chunks with their embeddings."""
98
  if self._use_chroma and self._collection is not None:
 
105
  documents=documents,
106
  metadatas=metadatas,
107
  )
108
+ elif self._db is not None:
109
+ rows = [
110
+ (
111
+ str(uuid.uuid4()),
112
+ chunk.metadata.get("doc_cid"),
113
+ chunk.text,
114
+ json.dumps(dict(chunk.metadata)),
115
+ json.dumps(emb),
116
+ )
117
+ for chunk, emb in zip(chunks, embeddings, strict=False)
118
+ ]
119
+ self._db.executemany(
120
+ "INSERT INTO chunks(id, doc_cid, chunk_text, metadata_json, embedding_json)"
121
+ " VALUES (?,?,?,?,?)",
122
+ rows,
123
+ )
124
+ self._db.commit()
125
  else:
126
  for chunk, emb in zip(chunks, embeddings, strict=False):
127
  self._items.append((chunk, emb))
128
 
129
+ # ------------------------------------------------------------------
130
+ # Read path
131
+ # ------------------------------------------------------------------
132
+
133
  def query(self, embedding: list[float], k: int = 5) -> list[ScoredChunk]:
134
  """Return top-k chunks by cosine similarity."""
135
  if self._use_chroma and self._collection is not None:
 
144
  scored: list[ScoredChunk] = []
145
  docs = results.get("documents", [[]])[0]
146
  metas = results.get("metadatas", [[]])[0]
147
+ # chromadb distances are L2; convert to similarity score
148
  distances = results.get("distances", [[]])[0]
149
  for doc, meta, dist in zip(docs, metas, distances, strict=False):
150
  score = 1.0 / (1.0 + dist)
151
  scored.append(ScoredChunk(chunk=Chunk(text=doc, metadata=meta), score=score))
152
  return scored
153
+
154
+ # SQLite: load all rows, cosine-rank in Python
155
+ if self._db is not None:
156
+ rows = self._db.execute(
157
+ "SELECT chunk_text, metadata_json, embedding_json FROM chunks"
158
+ ).fetchall()
159
+ if not rows:
160
+ return []
161
+ scored_items: list[tuple[Chunk, float]] = []
162
+ for chunk_text, meta_json, emb_json in rows:
163
+ try:
164
+ meta = json.loads(meta_json)
165
+ emb = json.loads(emb_json)
166
+ score = self._cosine_similarity(embedding, emb)
167
+ scored_items.append((Chunk(text=chunk_text, metadata=meta), score))
168
+ except Exception:
169
+ continue
170
+ scored_items.sort(key=lambda x: x[1], reverse=True)
171
+ return [ScoredChunk(chunk=c, score=s) for c, s in scored_items[:k]]
172
+
173
+ # Pure in-memory fallback
174
  if not self._items:
175
  return []
176
+ mem_scored = [
177
  (chunk, self._cosine_similarity(embedding, emb)) for chunk, emb in self._items
178
  ]
179
+ mem_scored.sort(key=lambda x: x[1], reverse=True)
180
+ return [ScoredChunk(chunk=chunk, score=score) for chunk, score in mem_scored[:k]]
181
 
182
  def has_doc(self, doc_cid: str) -> bool:
183
  """True if any chunk with this doc_cid exists."""
184
  if self._use_chroma and self._collection is not None:
185
  results = self._collection.get(where={"doc_cid": doc_cid}, limit=1, include=[])
186
  return len(results.get("ids", [])) > 0
187
+ if self._db is not None:
188
+ row = self._db.execute(
189
+ "SELECT 1 FROM chunks WHERE doc_cid = ? LIMIT 1", (doc_cid,)
190
+ ).fetchone()
191
+ return row is not None
192
  return any(c.metadata.get("doc_cid") == doc_cid for c, _ in self._items)
193
 
194
  def count(self) -> int:
195
  if self._use_chroma and self._collection is not None:
196
  return self._collection.count()
197
+ if self._db is not None:
198
+ row = self._db.execute("SELECT COUNT(*) FROM chunks").fetchone()
199
+ return row[0] if row else 0
200
  return len(self._items)
201
 
202
  def clear(self) -> None:
203
  if self._use_chroma and self._collection is not None and self._chroma_client is not None:
204
  self._chroma_client.delete_collection(self._corpus)
205
  self._collection = self._chroma_client.get_or_create_collection(self._corpus)
206
+ elif self._db is not None:
207
+ self._db.execute("DELETE FROM chunks")
208
+ self._db.commit()
209
  else:
210
  self._items.clear()
211
 
212
+ def corpus_info(self) -> dict:
213
+ """Return backend metadata — exposed via Settings tab and node manifest."""
214
+ if self._use_chroma:
215
+ backend = "chroma"
216
+ persistent = True
217
+ elif self._db is not None:
218
+ backend = "sqlite"
219
+ persistent = True
220
+ else:
221
+ backend = "in-memory"
222
+ persistent = False
223
+ return {
224
+ "backend": backend,
225
+ "persistent": persistent,
226
+ "chunks": self.count(),
227
+ "corpus": self._corpus,
228
+ }
229
+
230
  def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
231
  dot = sum(x * y for x, y in zip(a, b, strict=False))
232
  na = sum(x**2 for x in a) ** 0.5
 
238
  """List corpus names found under corpora_dir."""
239
  if not corpora_dir.exists():
240
  return []
241
+ return sorted(p.name for p in corpora_dir.iterdir() if p.is_dir() or p.suffix == ".db")
242
 
243
 
244
  def corpus_info(corpora_dir: Path, corpus: str) -> dict:
245
+ """Return {corpus, exists, count_chunks, backend, persistent}."""
246
+ corpus_dir = corpora_dir / corpus
247
+ db_path = corpora_dir / f"{corpus}.db"
248
+ exists = corpus_dir.exists() or db_path.exists()
249
  if exists:
250
  store = CorpusStore(corpora_dir, corpus)
251
+ return store.corpus_info()
252
+ return {"corpus": corpus, "exists": False, "count_chunks": 0, "backend": "none", "persistent": False}
hearthnet/transport/server.py CHANGED
@@ -22,10 +22,9 @@ from __future__ import annotations
22
  import asyncio
23
  from collections.abc import Callable
24
  from datetime import datetime, timezone as _tz
25
- UTC = _tz.utc
26
  from typing import Any
27
 
28
- UTC = UTC
29
 
30
  try:
31
  import uvicorn
 
22
  import asyncio
23
  from collections.abc import Callable
24
  from datetime import datetime, timezone as _tz
 
25
  from typing import Any
26
 
27
+ UTC = _tz.utc
28
 
29
  try:
30
  import uvicorn
hearthnet/ui/app.py CHANGED
@@ -235,7 +235,8 @@ class UiApp:
235
  with gr.Tab("Emergency"):
236
  build_emergency_tab(self._bus, self._state_bus)
237
  with gr.Tab("Settings"):
238
- build_settings_tab(self._config, self._meta, bus=self._bus)
 
239
  with gr.Tab("Getting Started"):
240
  build_getting_started_tab()
241
 
 
235
  with gr.Tab("Emergency"):
236
  build_emergency_tab(self._bus, self._state_bus)
237
  with gr.Tab("Settings"):
238
+ _rag_svc = getattr(self._node, "_rag_service", None)
239
+ build_settings_tab(self._config, self._meta, bus=self._bus, rag_service=_rag_svc)
240
  with gr.Tab("Getting Started"):
241
  build_getting_started_tab()
242
 
hearthnet/ui/tabs/settings.py CHANGED
@@ -41,7 +41,7 @@ def _qr_svg(data: str) -> str:
41
  )
42
 
43
 
44
- def build_settings_tab(config=None, meta: dict | None = None, bus=None):
45
  import gradio as gr
46
 
47
  meta = meta or {}
@@ -118,6 +118,26 @@ See the **Mesh** tab for a visual graph.
118
 
119
  refresh_peers_btn.click(get_peers, outputs=peers_out)
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  # --- Join the Mesh (QR + invite) ----------------------------------
122
  with gr.Accordion("📱 Join This Mesh — Connecting Nodes & Meshes", open=False):
123
  gr.Markdown("""
 
41
  )
42
 
43
 
44
+ def build_settings_tab(config=None, meta: dict | None = None, bus=None, rag_service=None):
45
  import gradio as gr
46
 
47
  meta = meta or {}
 
118
 
119
  refresh_peers_btn.click(get_peers, outputs=peers_out)
120
 
121
+ # --- RAG corpus status -------------------------------------------
122
+ with gr.Accordion("📚 RAG Knowledge Base", open=True):
123
+ gr.Markdown("""
124
+ Shows the active vector store backend and how many document chunks are indexed.
125
+ **sqlite** = persists across restarts. **chroma** = best quality. **in-memory** = wiped on restart.
126
+ """)
127
+ rag_status_out = gr.JSON(label="Corpus status", value={})
128
+ refresh_rag_btn = gr.Button("🔄 Refresh Corpus Stats", size="sm")
129
+
130
+ def get_rag_status():
131
+ if rag_service is None:
132
+ return {"status": "no rag_service wired"}
133
+ try:
134
+ store = rag_service._store
135
+ return store.corpus_info()
136
+ except Exception as exc:
137
+ return {"error": str(exc)}
138
+
139
+ refresh_rag_btn.click(get_rag_status, outputs=rag_status_out)
140
+
141
  # --- Join the Mesh (QR + invite) ----------------------------------
142
  with gr.Accordion("📱 Join This Mesh — Connecting Nodes & Meshes", open=False):
143
  gr.Markdown("""
requirements.txt CHANGED
@@ -10,3 +10,5 @@ transformers>=4.45.0
10
  qrcode[svg]>=7.4
11
  sentence-transformers>=3.0.0
12
  httpx>=0.27.0
 
 
 
10
  qrcode[svg]>=7.4
11
  sentence-transformers>=3.0.0
12
  httpx>=0.27.0
13
+ blake3>=0.4.0
14
+ click>=8.1
scripts/check_deps_sync.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """scripts/check_deps_sync.py — assert requirements.txt covers pyproject.toml deps.
2
+
3
+ Run in CI: python scripts/check_deps_sync.py
4
+ Exit 0 = in sync. Exit 1 = missing packages listed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ ROOT = Path(__file__).parent.parent
14
+
15
+
16
+ def _pkg_name(spec: str) -> str:
17
+ """Strip version constraints and extras, return normalised package name."""
18
+ name = re.split(r"[>=<!;\[]", spec.strip())[0].strip()
19
+ return name.lower().replace("-", "_").replace(".", "_")
20
+
21
+
22
+ def _pyproject_deps() -> list[str]:
23
+ text = (ROOT / "pyproject.toml").read_text()
24
+ in_deps = False
25
+ deps: list[str] = []
26
+ for line in text.splitlines():
27
+ if line.strip() == "dependencies = [":
28
+ in_deps = True
29
+ continue
30
+ if in_deps:
31
+ if line.strip() == "]":
32
+ break
33
+ dep = line.strip().strip('",').strip()
34
+ if dep:
35
+ deps.append(dep)
36
+ return deps
37
+
38
+
39
+ def _requirements_names() -> set[str]:
40
+ lines = (ROOT / "requirements.txt").read_text().splitlines()
41
+ names: set[str] = set()
42
+ for line in lines:
43
+ line = line.strip()
44
+ if not line or line.startswith("#") or line.startswith("-r"):
45
+ continue
46
+ names.add(_pkg_name(line))
47
+ return names
48
+
49
+
50
+ def main() -> int:
51
+ pyproject_deps = _pyproject_deps()
52
+ req_names = _requirements_names()
53
+ missing: list[str] = []
54
+ for dep in pyproject_deps:
55
+ if _pkg_name(dep) not in req_names:
56
+ missing.append(dep)
57
+ if missing:
58
+ print("ERROR: pyproject.toml deps missing from requirements.txt:")
59
+ for m in missing:
60
+ print(f" {m}")
61
+ return 1
62
+ print(f"OK: all {len(pyproject_deps)} pyproject.toml deps present in requirements.txt")
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ sys.exit(main())
tests/test_federated_rag.py CHANGED
@@ -535,7 +535,7 @@ class TestFederatedIntegration:
535
 
536
  with tempfile.TemporaryDirectory() as tmp:
537
  blob_store = BlobStore(Path(tmp) / "blobs")
538
- svc = RagService(corpus="test", blob_store=blob_store)
539
 
540
  req = MagicMock(spec=RouteRequest)
541
  req.body = {
@@ -546,6 +546,9 @@ class TestFederatedIntegration:
546
  }
547
  }
548
  result = run(svc.handle_ingest(req))
 
 
 
549
 
550
  assert result["output"]["chunks_indexed"] >= 1
551
  assert result["output"]["was_duplicate"] is False
 
535
 
536
  with tempfile.TemporaryDirectory() as tmp:
537
  blob_store = BlobStore(Path(tmp) / "blobs")
538
+ svc = RagService(corpus="test", blob_store=blob_store, corpora_dir=Path(tmp) / "corpora")
539
 
540
  req = MagicMock(spec=RouteRequest)
541
  req.body = {
 
546
  }
547
  }
548
  result = run(svc.handle_ingest(req))
549
+ # Close SQLite before tempdir cleanup (Windows file-lock)
550
+ if getattr(svc._store, "_db", None) is not None:
551
+ svc._store._db.close()
552
 
553
  assert result["output"]["chunks_indexed"] >= 1
554
  assert result["output"]["was_duplicate"] is False
tests/test_improvements_batch.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the improvements batch (items 1–15).
2
+
3
+ Covers:
4
+ - CorpusStore SQLite persistence (item 1)
5
+ - search_corpus bound to rag.federated_query (item 3)
6
+ - corpus param plumbing into body["params"] (item 4)
7
+ - _extract_json_object brace-matching parser (item 7)
8
+ - Bus failover when sole provider is quarantined (item 8)
9
+ - schema_hash prefix is "sha256:" not "blake3:" (item 10)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Item 1 — CorpusStore SQLite persistence
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ def test_corpus_store_sqlite_persists(tmp_path: Path) -> None:
27
+ """Chunks written to CorpusStore survive a process-restart simulation."""
28
+ from hearthnet.services.rag.chunker import Chunk
29
+ from hearthnet.services.rag.store import CorpusStore
30
+
31
+ # Write
32
+ store1 = CorpusStore(tmp_path, "test_corpus")
33
+ chunks = [Chunk(text="hello world", metadata={"doc_cid": "doc1", "title": "Test"})]
34
+ store1.add(chunks, [[0.1, 0.2, 0.3]])
35
+ assert store1.count() == 1
36
+
37
+ # "Restart" — new instance, same path
38
+ store2 = CorpusStore(tmp_path, "test_corpus")
39
+ if store2._db is not None or store2._use_chroma:
40
+ assert store2.count() == 1, "Chunks should survive re-open"
41
+
42
+
43
+ def test_corpus_store_sqlite_has_doc(tmp_path: Path) -> None:
44
+ from hearthnet.services.rag.chunker import Chunk
45
+ from hearthnet.services.rag.store import CorpusStore
46
+
47
+ store = CorpusStore(tmp_path, "test_corpus2")
48
+ chunks = [Chunk(text="water safety", metadata={"doc_cid": "water.001", "title": "Water"})]
49
+ store.add(chunks, [[0.5, 0.5]])
50
+ assert store.has_doc("water.001")
51
+ assert not store.has_doc("unknown.001")
52
+
53
+
54
+ def test_corpus_store_corpus_info(tmp_path: Path) -> None:
55
+ from hearthnet.services.rag.store import CorpusStore
56
+
57
+ store = CorpusStore(tmp_path, "info_test")
58
+ info = store.corpus_info()
59
+ assert "backend" in info
60
+ assert "persistent" in info
61
+ assert "chunks" in info
62
+ assert info["backend"] in ("chroma", "sqlite", "in-memory")
63
+
64
+
65
+ def test_corpus_store_query_after_sqlite_persist(tmp_path: Path) -> None:
66
+ from hearthnet.services.rag.chunker import Chunk
67
+ from hearthnet.services.rag.store import CorpusStore
68
+
69
+ store1 = CorpusStore(tmp_path, "query_test")
70
+ store1.add(
71
+ [Chunk(text="CPR steps", metadata={"doc_cid": "cpr.001"})],
72
+ [[1.0, 0.0, 0.0]],
73
+ )
74
+
75
+ store2 = CorpusStore(tmp_path, "query_test")
76
+ results = store2.query([1.0, 0.0, 0.0], k=3)
77
+ if store2._db is not None or store2._use_chroma:
78
+ assert len(results) >= 1
79
+ assert results[0].chunk.text == "CPR steps"
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Item 3 — search_corpus bound capability is rag.federated_query
84
+ # ---------------------------------------------------------------------------
85
+
86
+
87
+ def test_search_corpus_uses_federated_query() -> None:
88
+ from hearthnet.services.llm.tools import default_tool_set
89
+
90
+ executor = default_tool_set(bus=None)
91
+ search_tool = executor._tools.get("search_corpus")
92
+ assert search_tool is not None, "search_corpus tool must exist"
93
+ assert search_tool.bound_capability == "rag.federated_query", (
94
+ f"Expected rag.federated_query, got {search_tool.bound_capability!r}"
95
+ )
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Item 4 — corpus param plumbing into body["params"]
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_search_corpus_corpus_param_reaches_bus() -> None:
105
+ """When search_corpus is called with corpus='docs', the bus body must have
106
+ params={'corpus': 'docs'} so the router's _corpus_matches predicate sees it."""
107
+ from hearthnet.bus.capability import RouteRequest
108
+ from hearthnet.services.llm.tools import ToolCall, ToolDefinition, ToolExecutor
109
+
110
+ captured: list[dict] = []
111
+
112
+ class _FakeBus:
113
+ async def call(self, capability, version, body):
114
+ captured.append(body)
115
+ return {"output": {"chunks": []}}
116
+
117
+ tool = ToolDefinition(
118
+ name="search_corpus",
119
+ description="test",
120
+ parameters_schema={"type": "object", "properties": {"query": {}, "corpus": {}}},
121
+ bound_capability="rag.federated_query",
122
+ bound_version=(1, 0),
123
+ )
124
+ executor = ToolExecutor(bus=_FakeBus(), tools=[tool])
125
+ call = ToolCall(id="t1", name="search_corpus", arguments={"query": "water", "corpus": "docs"})
126
+ await executor.execute(call)
127
+
128
+ assert captured, "Bus should have been called"
129
+ body = captured[0]
130
+ assert body.get("params", {}).get("corpus") == "docs", (
131
+ "corpus must be in body['params'] for the router predicate"
132
+ )
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Item 7 — _extract_json_object brace-matching parser
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def test_extract_json_object_simple() -> None:
141
+ from hearthnet.services.llm.tools import _extract_json_object
142
+
143
+ text = 'action: {"tool": "search", "query": "hello"}'
144
+ start = text.index("{")
145
+ result = _extract_json_object(text, start)
146
+ assert result == '{"tool": "search", "query": "hello"}'
147
+
148
+
149
+ def test_extract_json_object_nested() -> None:
150
+ from hearthnet.services.llm.tools import _extract_json_object
151
+
152
+ text = 'action: {"tool": "search", "tags": ["a", "b"], "opts": {"k": 3}}'
153
+ start = text.index("{")
154
+ result = _extract_json_object(text, start)
155
+ import json
156
+ parsed = json.loads(result)
157
+ assert parsed["tool"] == "search"
158
+ assert parsed["opts"]["k"] == 3
159
+ assert parsed["tags"] == ["a", "b"]
160
+
161
+
162
+ def test_extract_json_object_brace_in_string() -> None:
163
+ from hearthnet.services.llm.tools import _extract_json_object
164
+
165
+ # Braces inside a string value must not be counted
166
+ text = 'action: {"tool": "x", "q": "use {braces} here"}'
167
+ start = text.index("{")
168
+ result = _extract_json_object(text, start)
169
+ import json
170
+ parsed = json.loads(result)
171
+ assert parsed["q"] == "use {braces} here"
172
+
173
+
174
+ def test_extract_json_object_no_match() -> None:
175
+ from hearthnet.services.llm.tools import _extract_json_object
176
+
177
+ assert _extract_json_object("no braces here", 0) is None
178
+ assert _extract_json_object("{unclosed", 0) is None
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Item 8 — bus failover when sole local provider is quarantined
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_bus_failover_when_sole_local_provider_quarantined() -> None:
188
+ """When the only matching local entry is quarantined, handle_call must
189
+ succeed by routing to a remote alternative rather than raising not_found."""
190
+ from hearthnet.bus import CapabilityBus, InMemoryTransport
191
+ from hearthnet.bus.capability import CapabilityDescriptor, CapabilityEntry, RouteRequest
192
+
193
+ transport = InMemoryTransport()
194
+ bus_a = CapabilityBus("node-a", "community-test", transport=transport)
195
+ bus_b = CapabilityBus("node-b", "community-test", transport=transport)
196
+
197
+ async def good_handler(req: RouteRequest) -> dict:
198
+ return {"output": "from_b"}
199
+
200
+ desc = CapabilityDescriptor(name="test.cap", version=(1, 0), max_concurrent=4)
201
+ bus_b.register_capability(desc, good_handler)
202
+
203
+ # Add node-b as remote entry directly in node-a's registry
204
+ remote_entry = CapabilityEntry(
205
+ node_id="node-b",
206
+ descriptor=desc,
207
+ is_local=False,
208
+ handler=None,
209
+ last_seen=time.monotonic(),
210
+ )
211
+ bus_a.registry._entries[("node-b", "test.cap", (1, 0))] = remote_entry
212
+
213
+ # Register a quarantined local entry on bus_a
214
+ async def broken_handler(req: RouteRequest) -> dict:
215
+ return {"error": "broken"}
216
+
217
+ bus_a.registry.register_local(desc, broken_handler)
218
+ for e in list(bus_a.registry.all_local()):
219
+ if e.descriptor.name == "test.cap":
220
+ e.quarantined_until = time.monotonic() + 3600
221
+
222
+ req = RouteRequest(
223
+ capability="test.cap",
224
+ version_req=(1, 0),
225
+ body={},
226
+ caller="node-a",
227
+ trace_id="test",
228
+ deadline_ms=0,
229
+ )
230
+ result = await bus_a.handle_call(req)
231
+ assert result.get("output") == "from_b", f"Expected from_b, got {result!r}"
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Item 10 — schema_hash prefix is "sha256:" not "blake3:"
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def test_schema_hash_prefix_is_sha256() -> None:
240
+ from hearthnet.bus.capability import CapabilityDescriptor
241
+
242
+ desc = CapabilityDescriptor(name="test.cap", version=(1, 0))
243
+ h = desc.schema_hash()
244
+ assert h.startswith("sha256:"), f"Expected 'sha256:' prefix, got: {h!r}"
245
+ assert not h.startswith("blake3:"), "blake3: prefix was a mislabel — must use sha256:"