Skip to content

Commit 9130bf5

Browse files
committed
docs: fix signature drift, add Parallel + SessionStore + Pool patterns
Audit pass of every ``docs/documentation/*.md`` against the current source tree, grounded in the Explore-agent report. Fixes: configuration.md * ``LLMAgent`` settings table — added ``skills``, ``llm``; corrected default ``max_tool_hops = 3`` (was ``max_tool_calls = 8`` — fictitious name + wrong default); removed invented ``parallel_tool_dispatch`` and ``stream_events`` kwargs that don't exist on the class. * ``Compactor`` settings table — corrected to ``trigger_tokens=4000`` / ``keep_last_turns=4`` (were ``max_context_tokens=3500`` / ``keep_last=6`` / fictitious ``target_tokens=1500``). * ``create_stt`` and ``create_tts`` examples — removed non-existent ``backend`` kwarg from ``create_stt`` and non-existent ``speed`` kwarg from ``create_tts``; added a note that ``model_size="sherpa"`` routes to the Vietnamese backend. * Custom STT / TTS examples — replaced invented ``display_name = "..."`` and ``agent.bind_stt(...)`` with the real ``_backend_name`` attribute pattern + a note that STT/TTS instances live in the pipeline, not on the agent. * Custom LLM example — replaced the non-existent ``chat_stream`` signature with the actual ``complete(messages, *, tools, tool_choice, stream, stop_event, grammar)`` contract matching what ``LLMAgent._drive`` calls. Added a note about the back-compat ``TypeError`` fallback the loop uses. * VectorMemoryStore example — replaced ``LLM(..., embedding=True)`` (the EdgeVox ``LLM`` facade doesn't accept ``embedding=`` today) with the canonical ``llama_cpp.Llama(embedding=True)`` + ``llama_embed`` pattern. Same fix in ``memory.md`` and the ``memory_vec`` module docstring. * Custom workflow example — corrected to the Agent Protocol that actually exists: ``name``, ``run(task, ctx)``, ``run_stream(task, ctx)``. ``description`` was never a Protocol requirement. desktop.md * On-disk state list — updated to reflect the SQLiteMemoryStore migration (``memory.db`` + WAL sidecars) with a note on the transparent ``memory.json`` → ``memory.db`` migration and the new ``game.json`` board persistence. memory.md * Reordered the MemoryStore table so ``SQLiteMemoryStore`` is marked as the recommended default (was listed second under ``JSONMemoryStore`` despite being the default for RookApp and what configuration.md recommends). * Added a ``SessionStore`` subsection covering ``JSONSessionStore`` vs. ``SQLiteSessionStore`` — both shipped in ``__init__.py`` but previously undocumented. agents.md * Added a ``Parallel`` workflow section with a real morning-briefing example + a pointer to the Pool pattern in multiagent.md. ``Parallel`` was in the table but had no usage example anywhere. multiagent.md * Added a "Pool + Parallel" subsection showing ``pool.make_ctx()`` threading a shared bus + blackboard through a ``Parallel`` workflow. Users hit this pattern whenever they want a bounded fleet of specialists running concurrently. memory_vec.py (code) * Relaxed ``llama_embed``'s type hint from ``LLM`` → ``Any`` since both the EdgeVox facade and a raw ``llama_cpp.Llama`` are accepted by the unwrap logic; updated the docstring to say so. Full suite green: 1008 passed, 4 skipped, 209 deselected. Ruff + ruff-format clean on all touched files.
1 parent fb59a55 commit 9130bf5

11 files changed

Lines changed: 357 additions & 62 deletions

File tree

docs/documentation/agents.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,26 @@ Workflows compose agents. Every workflow implements the `Agent` protocol so nest
404404
| `Supervisor.build()` | Same wire-shape as `Router` but forces `required_first_hop` — every turn must dispatch to a worker | "Every message goes to a worker, no chit-chat fallback" |
405405
| `Orchestrator` | Lead LLM emits a JSON plan; `spawn_subagent` runs each subtask with scoped tools; lead synthesises | Anthropic-style plan → fan-out → synthesise |
406406

407+
### Parallel — fan-out to N specialists, one reduced reply
408+
409+
```python
410+
from edgevox.agents import LLMAgent, Parallel
411+
412+
lookup = LLMAgent(name="lookup", description="Search notes", instructions="...", tools=[search_notes])
413+
weather = LLMAgent(name="weather", description="Weather lookup", instructions="...", tools=[get_weather])
414+
calendar = LLMAgent(name="calendar", description="Calendar lookup", instructions="...", tools=[next_event])
415+
416+
morning_briefing = Parallel(
417+
name="morning",
418+
agents=[lookup, weather, calendar],
419+
reduce=lambda results: "\n\n".join(r.reply for r in results if r.reply),
420+
)
421+
422+
result = morning_briefing.run("what should I know this morning?")
423+
```
424+
425+
Each sub-agent runs on its own worker with its own `Session`, so their tool histories don't cross-contaminate. The shared `LLM` serialises inference via its internal lock — the speedup comes from overlapping non-LLM work (tool dispatch, skill worker threads, I/O waits). Pair with the [Blackboard](/documentation/multiagent#blackboard) pattern when you want an agent pool to self-select rather than be explicitly enumerated.
426+
407427
### Router + handoff — the voice-optimized multi-agent pattern
408428

409429
Handoff-as-return-value (OpenAI Agents SDK style) saves one LLM hop per delegation vs. smolagents' "sub-agent-as-tool":

docs/documentation/configuration.md

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,30 @@ pip install 'edgevox[memory-vec]'
120120
```
121121

122122
```python
123-
from edgevox.llm import LLM
123+
from llama_cpp import Llama
124124
from edgevox.agents import VectorMemoryStore, llama_embed
125125

126-
llm = LLM(model_path="nomic-embed-text-v1.5.Q4_K_M.gguf", embedding=True)
127-
store = VectorMemoryStore("./vec.db", embed_fn=llama_embed(llm))
126+
# Any embedding-enabled GGUF works; nomic-embed-text is a good small default.
127+
embedder = Llama(
128+
model_path="nomic-embed-text-v1.5.Q4_K_M.gguf",
129+
embedding=True,
130+
n_ctx=2048,
131+
verbose=False,
132+
)
133+
134+
store = VectorMemoryStore("./vec.db", embed_fn=llama_embed(embedder))
135+
store.add_fact("user.allergies", "peanuts, shellfish")
128136
hits = store.search_facts("what's safe to cook?", k=5)
137+
for fact, distance in hits:
138+
print(f"{distance:.3f} {fact.key}: {fact.value}")
129139
```
130140

141+
``llama_embed`` accepts either the framework's ``LLM`` (if it was built
142+
with an embedding-capable backend) or a raw ``llama_cpp.Llama``
143+
instance. For most users the latter is simpler — spinning up a second,
144+
dedicated embedding model keeps it from fighting the main LLM for
145+
sampling time.
146+
131147
**Disable memory entirely:** don't register `MemoryInjectionHook` on the agent. The agent runs fine without a memory store; each turn just starts fresh.
132148

133149
**Expose memory to the LLM itself** (memory-as-tools):
@@ -339,20 +355,23 @@ Every category above is a **Protocol** — a typed shape the framework calls. Yo
339355

340356
```python
341357
from edgevox.stt import BaseSTT
358+
import numpy as np
342359

343360
class MySTT(BaseSTT):
344-
display_name = "MyCustomSTT"
361+
_backend_name = "mystt" # feeds the default ``display_name`` property
345362

346-
def transcribe(self, audio, language: str = "en") -> str:
363+
def transcribe(self, audio: np.ndarray, language: str = "en") -> str:
347364
# audio is a float32 numpy array @ 16 kHz
348365
return self._my_model(audio)
349-
350-
# Use it:
351-
from edgevox.agents import LLMAgent
352-
agent = LLMAgent(...)
353-
agent.bind_stt(MySTT()) # or inject via your own pipeline
354366
```
355367

368+
Drop into a pipeline by passing your instance wherever the default
369+
`create_stt(...)` result would go — ``PipelineConfig(stt=MySTT(), ...)``
370+
for the streaming pipeline, or the `stt=` kwarg of whatever higher-level
371+
factory you're using. STT isn't attached to the `LLMAgent` directly — it
372+
lives one layer up, producing the text that the agent's `run(...)`
373+
consumes.
374+
356375
### Custom TTS backend
357376

358377
```python
@@ -361,7 +380,7 @@ import numpy as np
361380

362381
class MyTTS(BaseTTS):
363382
sample_rate = 24_000
364-
display_name = "MyCustomTTS"
383+
_backend_name = "mytts"
365384

366385
def synthesize(self, text: str) -> np.ndarray:
367386
return self._model.run(text)
@@ -372,6 +391,8 @@ class MyTTS(BaseTTS):
372391
yield self._model.run(sentence)
373392
```
374393

394+
Same injection story as STT: the pipeline owns the TTS instance, not the agent.
395+
375396
### Custom memory store
376397

377398
Implement `MemoryStore` (see [`memory.md`](/documentation/memory) for the full method list):
@@ -442,37 +463,64 @@ Priority guide: Safety=100, Business=50, Observability=0. The built-ins follow t
442463

443464
### Custom workflow
444465

445-
Subclass `Agent` — the same Protocol `LLMAgent` implements:
466+
Implement the `Agent` Protocol — `name: str`, `run(task, ctx) -> AgentResult`, `run_stream(task, ctx) -> Iterator[str]`. `LLMAgent` and every shipped workflow already do this, so your class composes with them.
446467

447468
```python
448-
from edgevox.agents import Agent, AgentContext, AgentResult
469+
from collections.abc import Iterator
470+
471+
from edgevox.agents import AgentContext, AgentResult
472+
from edgevox.agents.base import Agent
449473

450474
class RoundRobin:
451-
name = "round-robin"
452-
description = "Alternate between agents on successive calls"
475+
"""Alternate between sub-agents on successive calls."""
453476

454-
def __init__(self, agents: list[Agent]):
477+
def __init__(self, name: str, agents: list[Agent]):
478+
self.name = name
455479
self._agents = agents
456480
self._idx = 0
457481

458-
def run(self, user_input: str, ctx: AgentContext) -> AgentResult:
482+
def run(self, task: str, ctx: AgentContext) -> AgentResult:
459483
a = self._agents[self._idx % len(self._agents)]
460484
self._idx += 1
461-
return a.run(user_input, ctx)
485+
return a.run(task, ctx)
486+
487+
def run_stream(self, task: str, ctx: AgentContext) -> Iterator[str]:
488+
a = self._agents[self._idx % len(self._agents)]
489+
self._idx += 1
490+
yield from a.run_stream(task, ctx)
462491
```
463492

464493
Anywhere the framework takes an `Agent` (workflow child, handoff target, background worker) you can pass this.
465494

466495
### Custom LLM backend
467496

468-
Match the surface `LLMAgent` calls: `chat_stream(messages, *, stop_event=None, **kwargs)` yielding tokens, plus `count_tokens(text) -> int`. Wrap Anthropic / OpenAI / Ollama / vLLM / your-own-thing, then:
497+
Match the surface `LLMAgent` calls — a single `complete(...)` method that returns an OpenAI-shaped response dict:
469498

470499
```python
500+
class MyLLM:
501+
def complete(
502+
self,
503+
messages: list[dict],
504+
*,
505+
tools: list[dict] | None = None,
506+
tool_choice: str | dict = "auto",
507+
stream: bool = False,
508+
stop_event: threading.Event | None = None,
509+
grammar: object | None = None,
510+
) -> dict:
511+
# Must return {"choices": [{"message": {"content": str,
512+
# "tool_calls": list | None}}]}
513+
...
514+
515+
def count_tokens(self, text: str) -> int:
516+
# Only used by TokenBudgetHook / Compactor when passed ``ctx.llm``.
517+
return len(text) // 4 # stub
518+
471519
agent = LLMAgent(...)
472520
agent.bind_llm(MyLLM())
473521
```
474522

475-
The agent loop only cares about the two-method contract — no assumptions about what's under the hood.
523+
`stop_event` is how barge-in halts generation mid-decode — your backend should poll it in the sampling loop. `grammar` is optional (llama-cpp GBNF or equivalent); backends that can't grammar-constrain can ignore it. The agent loop falls back gracefully for older shims via a `TypeError` catch — so it's safe to implement only the subset you support.
476524

477525
---
478526

@@ -485,15 +533,17 @@ Every knob, by component. Defaults are what you get from a bare `LLMAgent(...)`
485533
| arg | default | meaning |
486534
|---|---|---|
487535
| `name` | required | human-facing identifier |
488-
| `description` | required | advertised in agent handoffs |
536+
| `description` | required | advertised to handoff targets + workflows |
489537
| `instructions` | required | system prompt |
490-
| `tools` | `None` | list of `@tool` callables or `Tool` objects |
491-
| `hooks` | `[]` | list of hook objects |
492-
| `max_tool_calls` | `8` | hops per turn before abort |
538+
| `tools` | `None` | list of `@tool` callables, `Tool` objects, or a `ToolRegistry` |
539+
| `skills` | `None` | list of `Skill` objects (cancellable long-running tasks) |
540+
| `llm` | `None` | pre-bound `LLM` instance; otherwise set via `agent.bind_llm(...)` |
541+
| `handoffs` | `None` | agents this one can hand off to |
542+
| `hooks` | `None` | list of hook objects |
543+
| `max_tool_hops` | `3` | tool-call hops per turn before abort |
493544
| `tool_choice_policy` | `"auto"` | `"auto"` / `"required_first_hop"` / `"required_always"` |
494-
| `parallel_tool_dispatch` | `False` | fan out independent tool calls via thread pool |
495-
| `handoffs` | `[]` | agents this one can hand off to |
496-
| `stream_events` | `True` | emit per-token / per-event signals via `ctx.on_event` |
545+
546+
Parallel tool-call dispatch happens automatically inside `_drive` when the LLM emits multiple `tool_calls` in a single response — no flag needed. Agent events are always published via `ctx.on_event` / the bus; subscribers attach via `ctx.bus.subscribe(...)`.
497547

498548
### `MemoryStore` implementations
499549

@@ -532,22 +582,23 @@ All three honour `max_facts` / `max_episodes` on `render_for_prompt`.
532582

533583
| knob | default | meaning |
534584
|---|---|---|
535-
| `max_context_tokens` | `3500` | trigger summarisation above |
536-
| `keep_last` | `6` | never summarise the most-recent N turns |
537-
| `target_tokens` | `1500` | summary size budget |
585+
| `trigger_tokens` | `4000` | summarise when the session crosses this count |
586+
| `keep_last_turns` | `4` | never summarise the most-recent N user/assistant turns |
538587

539588
### STT
540589

541590
Per language, resolved via `edgevox.core.config.get_lang(code)`. Override per-call:
542591

543592
```python
544-
create_stt(language="en", model_size="large-v3", device="cuda", backend="whisper")
593+
create_stt(language="en", model_size="large-v3", device="cuda")
594+
# model_size="sherpa" routes to the Sherpa-ONNX Vietnamese backend
545595
```
546596

547597
### TTS
548598

549599
```python
550-
create_tts(language="en", voice="af_heart", backend="kokoro", speed=1.0)
600+
create_tts(language="en", voice="af_heart", backend="kokoro")
601+
# backend one of "kokoro" / "piper" / "supertonic" / "pythaitts" (or None for language default)
551602
```
552603

553604
### RookApp (desktop)

docs/documentation/desktop.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,11 @@ chips, persona label, and face highlight:
128128
Everything is stored under Qt's per-user `AppDataLocation` (falls back to
129129
`~/.rookapp` on bare headless CI):
130130

131-
- `memory.json``JSONMemoryStore` for long-term facts
131+
- `memory.db``SQLiteMemoryStore` in WAL mode for long-term facts, crash-safe atomic writes. Older installs that wrote `memory.json` are migrated transparently on first launch; the legacy file is renamed to `memory.json.migrated` and left in place as a backup.
132132
- `notes.md``NotesFile` scratchpad the `NotesInjectorHook` reads
133-
- `sessions/…``JSONSessionStore` chat history, restored on next launch
134-
- `QSettings` — platform-native registry/plist/INI for preferences
133+
- `sessions.json``JSONSessionStore` chat history, restored on next launch
134+
- `game.json` — board + move history (FEN + SAN), so a crashed match resumes exactly where it was
135+
- `QSettings` — platform-native registry/plist/INI for UI preferences (piece set, board theme, audio devices)
135136

136137
**New game** wipes all four so commentary from a previous game can't leak
137138
into a fresh board.

docs/documentation/memory.md

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,23 @@ A `Protocol`; three implementations ship:
3535

3636
| Class | Backing | Use case |
3737
|---|---|---|
38-
| `JSONMemoryStore` | debounced JSON file | default; simple, human-readable, thread-safe |
39-
| `SQLiteMemoryStore` | stdlib `sqlite3` + WAL mode | crash-safe (immediate atomic writes), multi-process-safe, queryable |
40-
| `VectorMemoryStore` | `sqlite-vec` extension + injectable `embed_fn` | semantic retrieval over facts `store.search_facts("what's safe to cook?", k=3)` |
38+
| **`SQLiteMemoryStore`** *(recommended default)* | stdlib `sqlite3` + WAL mode | crash-safe atomic writes, multi-process-safe, indexed `facts_as_of(t)` queries |
39+
| `JSONMemoryStore` | debounced JSON file | prototyping, human-readable inspection |
40+
| `VectorMemoryStore` | `sqlite-vec` extension + injectable `embed_fn` | semantic retrieval — `store.search_facts("what's safe to cook?", k=3)`; opt in via `pip install 'edgevox[memory-vec]'` |
4141

4242
All three share the same bi-temporal semantics and render-for-prompt layout, so swapping stores doesn't change what the LLM sees. `VectorMemoryStore` is in the `[memory-vec]` extra; the embedding model is user-supplied via `embed_fn=...` (e.g. `llama_embed(llm)` to reuse a `llama-cpp` instance loaded with `embedding=True`).
4343

4444
```python
45-
from edgevox.llm.llamacpp import LLM
45+
from llama_cpp import Llama
4646
from edgevox.agents import VectorMemoryStore, llama_embed
4747

48-
llm = LLM(model_path="nomic-embed-text-v1.5.Q4_K_M.gguf", embedding=True)
49-
store = VectorMemoryStore("./vec.db", embed_fn=llama_embed(llm))
48+
embedder = Llama(
49+
model_path="nomic-embed-text-v1.5.Q4_K_M.gguf",
50+
embedding=True,
51+
n_ctx=2048,
52+
verbose=False,
53+
)
54+
store = VectorMemoryStore("./vec.db", embed_fn=llama_embed(embedder))
5055
store.add_fact("user.allergies", "peanuts, shellfish")
5156
store.add_fact("kitchen.fridge.contents", "milk, eggs, cheese")
5257
for fact, distance in store.search_facts("what's safe to cook?", k=3):
@@ -55,6 +60,26 @@ for fact, distance in store.search_facts("what's safe to cook?", k=3):
5560

5661
Write your own backend (Redis, Mongo, remote HTTP, …) by implementing the `MemoryStore` Protocol — the four built-in hooks that consume a store (`MemoryInjectionHook`, `NotesInjectorHook`, `PersistSessionHook`, `ContextCompactionHook`) read through the Protocol, never the concrete class.
5762

63+
### `SessionStore` — per-conversation history
64+
65+
Distinct from the per-user `MemoryStore`: a `SessionStore` persists an entire `Session` (messages, tool-call history, state dict) keyed by session-id so a user can resume a conversation after a restart. Two implementations ship:
66+
67+
| Class | Backing | Use case |
68+
|---|---|---|
69+
| `JSONSessionStore` | one JSON file per session | default, human-readable, fine through ~500 turns / 100 sessions |
70+
| `SQLiteSessionStore` | stdlib `sqlite3` with a single `sessions` table | multi-user services, thousands of sessions, indexed lookup by `updated_at` |
71+
72+
Both implement the same three-method `SessionStore` Protocol (`load(id) / save(session) / delete(id)`), so `PersistSessionHook` reads through the Protocol:
73+
74+
```python
75+
from edgevox.agents import PersistSessionHook, SQLiteSessionStore
76+
77+
sessions = SQLiteSessionStore("./sessions.db")
78+
agent = LLMAgent(..., hooks=[PersistSessionHook(session_store=sessions, session_id="user-42")])
79+
```
80+
81+
Swap the store without changing the agent code — the JSONSessionStore → SQLite migration is a one-line change the same way JSON → SQLite memory is.
82+
5883
### Data model
5984

6085
- **`Fact(key, value, scope, source)`** — durable key/value with scope (`"global"`, `"user"`, `"env:kitchen"`, …).

docs/documentation/multiagent.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ result = pool.run("router", "What's the temperature?")
8282

8383
`pool.make_ctx(**overrides)` builds an `AgentContext` pre-wired with the shared bus + blackboard so every agent sees consistent shared state.
8484

85+
### Pool + Parallel — fan-out across the pool
86+
87+
`AgentPool` and the [`Parallel` workflow](/documentation/agents#parallel-fan-out-to-n-specialists-one-reduced-reply) compose cleanly:
88+
89+
```python
90+
from edgevox.agents import AgentPool, LLMAgent, Parallel
91+
92+
pool = AgentPool()
93+
pool.register(lookup_agent)
94+
pool.register(weather_agent)
95+
pool.register(calendar_agent)
96+
97+
morning = Parallel(
98+
name="morning",
99+
agents=[pool.get("lookup"), pool.get("weather"), pool.get("calendar")],
100+
)
101+
result = morning.run("morning briefing?", ctx=pool.make_ctx())
102+
```
103+
104+
`pool.make_ctx()` ensures every sub-agent sees the same bus + blackboard, so a background agent listening on `*` will observe the children's events. Combine with `bb.post_request(...)` when you want dynamic membership — the pool's agents pick up requests by capability rather than being enumerated up front.
105+
85106
## Debouncing triggers
86107

87108
`debounce_trigger(trigger, interval_s=…)` wraps any trigger so it fires at most once per interval — useful for sub-second sensor streams:

0 commit comments

Comments
 (0)