From a5477ae15c44708fde62276e03a2933f8b2dfc89 Mon Sep 17 00:00:00 2001 From: serversdown Date: Wed, 17 Jun 2026 19:04:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20tool=20use=20=E2=80=94=20Lyra's=20first?= =?UTF-8?q?=20real=20actions=20(journal=5Fwrite,=20note)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit She can now *do* things mid-conversation, not just reply. Adds a tool-calling loop to the chat path and her first two tools; the same mechanism will carry the poker tools (start_session, log_result, get_stats, solver) next. - tools.py: registry of OpenAI-style tool specs + handlers + safe dispatch; journal_write (knowing journaling) and note (tagged notepad, e.g. poker reads) - llm.chat_call(): OpenAI-style call that returns tool_calls (cloud/mi50); local has no tool support and returns plain content - chat.respond(): tool loop — offer tools, run any calls, feed results back, repeat until a text reply (capped at MAX_TOOL_ROUNDS); persists final reply - tests: dispatch + full chat loop (tool call -> result -> reply) Verified live: she invoked `note`, tagged it 'poker', stored a villain read. Co-Authored-By: Claude Opus 4.8 (1M context) --- lyra/chat.py | 23 +++++++++- lyra/llm.py | 37 ++++++++++++++++ lyra/tools.py | 105 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_tools.py | 55 +++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 lyra/tools.py create mode 100644 tests/test_tools.py diff --git a/lyra/chat.py b/lyra/chat.py index 9be18d5..d5454d0 100644 --- a/lyra/chat.py +++ b/lyra/chat.py @@ -11,11 +11,13 @@ After replying, the session is compacted if enough new turns have accumulated. from __future__ import annotations from lyra import clock, config, llm, logbus, memory, persona, self_state, summary +from lyra import tools as toolkit from lyra.llm import Backend, Message RECALL_K = 3 # raw cross-session "sharp detail" hits RECENT_N = 10 # raw turns of the current session SUMMARY_K = 3 # other-session gists +MAX_TOOL_ROUNDS = 5 # cap tool-call iterations per turn 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) - 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)) memory.remember(session_id, "user", user_msg) diff --git a/lyra/llm.py b/lyra/llm.py index e84091e..f9ff419 100644 --- a/lyra/llm.py +++ b/lyra/llm.py @@ -43,6 +43,43 @@ def complete(messages: list[Message], backend: Backend = "local", model: str | N 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]]: """Embed texts using the configured backend (EMBED_BACKEND: "cloud" or "local"). diff --git a/lyra/tools.py b/lyra/tools.py new file mode 100644 index 0000000..8eb507a --- /dev/null +++ b/lyra/tools.py @@ -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})" diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..8868922 --- /dev/null +++ b/tests/test_tools.py @@ -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())