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
+37
View File
@@ -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").