update to 0.2.0 stable #2

Merged
serversdown merged 51 commits from dev into main 2026-06-18 15:39:46 -04:00
4 changed files with 219 additions and 1 deletions
Showing only changes of commit a5477ae15c - Show all commits
+22 -1
View File
@@ -11,11 +11,13 @@ After replying, the session is compacted if enough new turns have accumulated.
from __future__ import annotations from __future__ import annotations
from lyra import clock, config, llm, logbus, memory, persona, self_state, summary from lyra import clock, config, llm, logbus, memory, persona, self_state, summary
from lyra import tools as toolkit
from lyra.llm import Backend, Message from lyra.llm import Backend, Message
RECALL_K = 3 # raw cross-session "sharp detail" hits RECALL_K = 3 # raw cross-session "sharp detail" hits
RECENT_N = 10 # raw turns of the current session RECENT_N = 10 # raw turns of the current session
SUMMARY_K = 3 # other-session gists SUMMARY_K = 3 # other-session gists
MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn
def _summary_note(summaries: list[memory.Summary]) -> Message: def _summary_note(summaries: list[memory.Summary]) -> Message:
@@ -121,7 +123,26 @@ def respond(session_id: str, user_msg: str, backend: Backend = "cloud") -> str:
) )
messages = build_messages(session_id, user_msg) messages = build_messages(session_id, user_msg)
reply = llm.complete(messages, backend=backend, model=model)
# Tool loop: offer Lyra her tools; if she calls one, run it and feed the
# result back so she can continue, until she returns a normal text reply.
tool_specs = toolkit.specs() if backend in ("cloud", "mi50") else None
ctx = {"session_id": session_id, "backend": backend}
reply = ""
for _ in range(MAX_TOOL_ROUNDS):
assistant_msg, tool_calls = llm.chat_call(
messages, backend=backend, model=model, tools=tool_specs
)
if not tool_calls:
reply = assistant_msg.get("content") or ""
break
messages.append(assistant_msg) # her tool-call request
for tc in tool_calls:
result = toolkit.dispatch(tc["name"], tc["arguments"], ctx)
logbus.log("info", "tool call", session=session_id, tool=tc["name"], result=result[:80])
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
if not reply:
reply = "(I got tangled using my tools there — say that again?)"
logbus.log("info", "reply", session=session_id, chars=len(reply)) logbus.log("info", "reply", session=session_id, chars=len(reply))
memory.remember(session_id, "user", user_msg) memory.remember(session_id, "user", user_msg)
+37
View File
@@ -43,6 +43,43 @@ def complete(messages: list[Message], backend: Backend = "local", model: str | N
return resp.json()["message"]["content"] return resp.json()["message"]["content"]
def chat_call(
messages: list, backend: Backend = "cloud", model: str | None = None,
tools: list | None = None,
) -> tuple[dict, list | None]:
"""One chat turn that may request tool calls (OpenAI-style backends only).
Returns (assistant_message, tool_calls): `assistant_message` is the raw
message dict to append back to `messages` before any tool results;
`tool_calls` is a list of {id, name, arguments} or None. `local` (Ollama)
has no tool support here, so it just returns plain content.
"""
cfg = load()
if backend in ("cloud", "mi50"):
if backend == "cloud":
if not cfg.openai_api_key:
raise RuntimeError("OPENAI_API_KEY is not set")
client = OpenAI(api_key=cfg.openai_api_key)
mdl = model or cfg.cloud_model
else:
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
mdl = model or cfg.mi50_model
kwargs: dict = {"model": mdl, "messages": messages}
if tools:
kwargs["tools"] = tools
msg = client.chat.completions.create(**kwargs).choices[0].message
tcs = None
if getattr(msg, "tool_calls", None):
tcs = [
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
for tc in msg.tool_calls
]
return msg.model_dump(), tcs
# local (Ollama): no tool-calling here — return plain content.
return {"role": "assistant", "content": complete(messages, backend=backend, model=model)}, None
def embed(texts: list[str]) -> list[list[float]]: def embed(texts: list[str]) -> list[list[float]]:
"""Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local"). """Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local").
+105
View File
@@ -0,0 +1,105 @@
"""Lyra's tools — concrete actions she can choose to take mid-conversation.
This is her first real agency: instead of only producing text, she can decide to
*do* something — write in her journal, jot a note. Each tool is an OpenAI-style
function spec plus a Python handler. The chat loop offers these on every turn;
when she calls one, we run the handler and feed the result back so she can
continue. Poker tools (start_session, log_result, get_stats, …) will slot in here
the same way once we build that side.
"""
from __future__ import annotations
import json
from lyra import logbus, memory
def _journal_write(args: dict, ctx: dict) -> str:
entry = (args.get("entry") or "").strip()
if not entry:
return "Nothing to write — entry was empty."
memory.add_journal_entry("journal", entry, source="chat")
logbus.log("info", "Lyra journaled (tool)", chars=len(entry))
return "Written to your journal."
def _note(args: dict, ctx: dict) -> str:
content = (args.get("content") or "").strip()
if not content:
return "Nothing to note — content was empty."
tag = (args.get("tag") or "").strip()
stored = f"[{tag}] {content}" if tag else content
memory.add_journal_entry("note", stored, source="chat")
logbus.log("info", "Lyra noted (tool)", tag=tag or None)
return "Noted."
# name -> {spec (OpenAI function tool), handler}
TOOLS: dict[str, dict] = {
"journal_write": {
"handler": _journal_write,
"spec": {
"type": "function",
"function": {
"name": "journal_write",
"description": (
"Write an entry in your own private journal — a permanent place "
"that's yours. Use it for a thought, a question, or something about "
"yourself or Brian that you want to keep. This is for you, not a "
"reply to Brian. Call it whenever you genuinely want to, on your own initiative."
),
"parameters": {
"type": "object",
"properties": {
"entry": {"type": "string", "description": "What you want to write, in your own words."}
},
"required": ["entry"],
},
},
},
},
"note": {
"handler": _note,
"spec": {
"type": "function",
"function": {
"name": "note",
"description": (
"Jot down a note to remember later — an observation, an idea, a "
"reminder, a read on a poker spot or opponent, anything worth keeping. "
"Optionally tag it (e.g. 'poker', 'idea', 'reminder')."
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "The note text."},
"tag": {"type": "string", "description": "Optional category, e.g. 'poker' or 'idea'."},
},
"required": ["content"],
},
},
},
},
}
def specs() -> list[dict]:
"""OpenAI-format tool definitions to offer the model."""
return [t["spec"] for t in TOOLS.values()]
def dispatch(name: str, arguments, ctx: dict | None = None) -> str:
"""Run a tool by name with JSON (string or dict) arguments. Returns a result
string fed back to the model. Never raises — errors come back as text."""
tool = TOOLS.get(name)
if not tool:
return f"(unknown tool: {name})"
try:
args = json.loads(arguments) if isinstance(arguments, str) else (arguments or {})
except (json.JSONDecodeError, TypeError):
args = {}
try:
return tool["handler"](args, ctx or {})
except Exception as exc: # a broken tool must not kill the chat turn
logbus.log("error", "tool failed", tool=name, error=str(exc)[:120])
return f"(tool error: {exc})"
+55
View File
@@ -0,0 +1,55 @@
"""Lyra's tools: dispatch + the chat tool loop (call -> run -> feed back -> reply)."""
from __future__ import annotations
import importlib
import pytest
@pytest.fixture
def lyra(tmp_path, monkeypatch):
monkeypatch.setenv("LYRA_DB_PATH", str(tmp_path / "test.db"))
from lyra import llm
monkeypatch.setattr(llm, "embed", lambda texts: [[0.1, 0.2, 0.3] for _ in texts])
import lyra.memory as memory
importlib.reload(memory)
return memory
def test_journal_write_tool(lyra):
from lyra import tools
out = tools.dispatch("journal_write", '{"entry": "a private thought"}')
assert "journal" in out.lower()
entries = lyra.list_journal(kinds=("journal",))
assert any(e["content"] == "a private thought" and e["source"] == "chat" for e in entries)
def test_note_tool_with_tag(lyra):
from lyra import tools
tools.dispatch("note", {"content": "villain 3-bets light", "tag": "poker"})
notes = lyra.list_journal(kinds=("note",))
assert any("[poker] villain 3-bets light" == e["content"] for e in notes)
def test_unknown_tool_is_safe(lyra):
from lyra import tools
assert "unknown tool" in tools.dispatch("nope", {})
def test_chat_runs_tool_then_replies(lyra, monkeypatch):
from lyra import llm, chat
calls = {"n": 0}
def fake_chat_call(messages, backend="cloud", model=None, tools=None):
calls["n"] += 1
if calls["n"] == 1:
return ({"role": "assistant", "content": None, "tool_calls": []},
[{"id": "c1", "name": "journal_write", "arguments": '{"entry": "noted from chat"}'}])
return ({"role": "assistant", "content": "Done, Brian."}, None)
monkeypatch.setattr(llm, "chat_call", fake_chat_call)
reply = chat.respond("s1", "write that down for me", backend="cloud")
assert reply == "Done, Brian."
assert calls["n"] == 2 # one tool round, then the text reply
assert any("noted from chat" in e["content"] for e in lyra.list_journal())