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:
2026-06-17 19:04:34 +00:00
parent ce65755d9c
commit a5477ae15c
4 changed files with 219 additions and 1 deletions
+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})"