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
+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 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)