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 __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
@@ -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
@@ -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