feat: tool use — Lyra's first real actions (journal_write, note)
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) <noreply@anthropic.com>
This commit is contained in:
+22
-1
@@ -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)
|
||||
|
||||
+37
@@ -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").
|
||||
|
||||
|
||||
+105
@@ -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})"
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user