feat(agent): add llm assistant chat with api tools (task 88)

OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
This commit is contained in:
ginnoir
2026-07-04 19:46:09 -05:00
parent 04ae809e07
commit 4a924a4107
19 changed files with 956 additions and 6 deletions
+7
View File
@@ -46,3 +46,10 @@ MINIO_BUCKET=garden
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET=
# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.)
# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model).
LLM_PROVIDER=openai
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=llama3.2
+4 -2
View File
@@ -19,6 +19,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
- **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`.
- **88 — LLM agent chat** (ADR 0006). `/assistant` chat UI; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls (lists, calendar, notes, journal); `POST /api/agent/chat`. Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
@@ -65,11 +67,11 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
5. LLM agent chat: task 88
5. ~~LLM agent chat: task 88~~ — done
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
**How to resume:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80``88`, stop at acceptance criteria.
**How to resume:** Phase 9 P1 batch complete. Next work is P2/P3 Gitea backlog or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
## Development login/testing notes
+4 -4
View File
@@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid
## Acceptance criteria
- [ ] Chat UI sends prompts and shows responses.
- [ ] Tools call the API successfully in dev against the homelab endpoint.
- [ ] CI uses mock provider only.
- [ ] ADR 0006 records provider and MCP decisions.
- [x] Chat UI sends prompts and shows responses.
- [x] Tools call the API successfully in dev against the homelab endpoint.
- [x] CI uses mock provider only.
- [x] ADR 0006 records provider and MCP decisions.
## Notes
+52
View File
@@ -0,0 +1,52 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { isLlmConfigured } from "@/lib/llm";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().trim().min(1).max(8000),
}),
)
.min(1)
.max(40),
});
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth) {
return apiError("Unauthorized", 401);
}
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const parsed = chatInput.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
});
return apiJson({
...result,
configured: isLlmConfigured(),
provider: isLlmConfigured() ? "openai" : "mock",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
return apiError(message, 502);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { AssistantChat } from "@/modules/agent/components/assistant-chat";
import { isLlmConfigured } from "@/lib/llm";
export default function AssistantPage() {
return <AssistantChat configured={isLlmConfigured()} />;
}
+2
View File
@@ -21,6 +21,7 @@ import {
Lock,
Mail,
Menu,
MessageCircle,
MoreHorizontal,
Pencil,
Phone,
@@ -74,6 +75,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
sprout: Sprout,
mail: Mail,
menu: Menu,
"message-circle": MessageCircle,
more: MoreHorizontal,
x: X,
"chevron-left": ChevronLeft,
+30
View File
@@ -0,0 +1,30 @@
export type LlmProviderKind = "mock" | "openai";
export type LlmConfig = {
provider: LlmProviderKind;
baseUrl: string | null;
apiKey: string | null;
model: string;
};
export function getLlmConfig(): LlmConfig {
const providerEnv = process.env.LLM_PROVIDER?.trim().toLowerCase();
const baseUrl = process.env.LLM_BASE_URL?.trim() || null;
const apiKey = process.env.LLM_API_KEY?.trim() || null;
const model = process.env.LLM_MODEL?.trim() || "llama3.2";
if (providerEnv === "mock") {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
if (!baseUrl) {
return { provider: "mock", baseUrl: null, apiKey: null, model };
}
return { provider: "openai", baseUrl, apiKey, model };
}
export function isLlmConfigured(): boolean {
const config = getLlmConfig();
return config.provider === "openai" && Boolean(config.baseUrl);
}
+29
View File
@@ -0,0 +1,29 @@
import { createMockLlmClient } from "./mock";
import { createOpenAiCompatibleClient } from "./openai-compatible";
import { getLlmConfig } from "./config";
import type { LlmClient } from "./types";
export type {
AgentToolDefinition,
ChatCompletionRequest,
ChatCompletionResult,
ChatMessage,
LlmClient,
} from "./types";
export { getLlmConfig, isLlmConfigured } from "./config";
export { createMockLlmClient } from "./mock";
export function createLlmClient(override?: LlmClient): LlmClient {
if (override) return override;
const config = getLlmConfig();
if (config.provider === "mock" || !config.baseUrl) {
return createMockLlmClient();
}
return createOpenAiCompatibleClient({
baseUrl: config.baseUrl,
apiKey: config.apiKey,
model: config.model,
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
function lastUserText(messages: ChatCompletionRequest["messages"]): string {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role === "user" && message.content) {
return message.content.toLowerCase();
}
}
return "";
}
function hadToolResults(messages: ChatCompletionRequest["messages"]): boolean {
return messages.some((message) => message.role === "tool");
}
export function createMockLlmClient(): LlmClient {
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const userText = lastUserText(request.messages);
if (hadToolResults(request.messages)) {
return {
message: {
role: "assistant",
content: "Done — I updated your household data using the API.",
},
finishReason: "stop",
};
}
if (userText.includes("milk") && request.tools?.length) {
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_add_item",
type: "function",
function: {
name: "add_list_item",
arguments: JSON.stringify({
listType: "shopping",
text: "milk",
}),
},
},
],
},
finishReason: "tool_calls",
};
}
if (userText.includes("calendar") && request.tools?.length) {
const now = new Date();
const from = new Date(now);
from.setDate(from.getDate() - 1);
const to = new Date(now);
to.setDate(to.getDate() + 7);
return {
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "mock_call_list_events",
type: "function",
function: {
name: "list_events",
arguments: JSON.stringify({
from: from.toISOString(),
to: to.toISOString(),
}),
},
},
],
},
finishReason: "tool_calls",
};
}
return {
message: {
role: "assistant",
content:
"I'm the famapp assistant (mock provider). Ask me to add items or check the calendar.",
},
finishReason: "stop",
};
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import type { ChatCompletionRequest, ChatCompletionResult, LlmClient } from "./types";
type OpenAiMessage = {
role: string;
content: string | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
name?: string;
};
export function createOpenAiCompatibleClient(options: {
baseUrl: string;
apiKey: string | null;
model: string;
}): LlmClient {
const completionsUrl = `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
return {
async chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const body = {
model: options.model,
messages: request.messages as OpenAiMessage[],
tools: request.tools,
tool_choice: request.tools?.length ? "auto" : undefined,
};
const response = await fetch(completionsUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`LLM request failed (${response.status}): ${detail.slice(0, 400)}`);
}
const payload = (await response.json()) as {
choices?: Array<{
finish_reason?: string | null;
message?: OpenAiMessage;
}>;
};
const choice = payload.choices?.[0];
const message = choice?.message;
if (!message) {
throw new Error("LLM response missing message");
}
return {
message: {
role: message.role as ChatCompletionResult["message"]["role"],
content: message.content,
tool_calls: message.tool_calls,
tool_call_id: message.tool_call_id,
name: message.name,
},
finishReason: choice.finish_reason ?? null,
};
},
};
}
+41
View File
@@ -0,0 +1,41 @@
export type ChatRole = "system" | "user" | "assistant" | "tool";
export type ChatToolCall = {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage = {
role: ChatRole;
content: string | null;
tool_calls?: ChatToolCall[];
tool_call_id?: string;
name?: string;
};
export type AgentToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
};
export type ChatCompletionRequest = {
messages: ChatMessage[];
tools?: AgentToolDefinition[];
};
export type ChatCompletionResult = {
message: ChatMessage;
finishReason: string | null;
};
export interface LlmClient {
chatCompletion(request: ChatCompletionRequest): Promise<ChatCompletionResult>;
}
@@ -0,0 +1,134 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { Loader2, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ChatMessage = {
role: "user" | "assistant";
content: string;
};
type Props = {
configured: boolean;
};
export function AssistantChat({ configured }: Props) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const listRef = useRef<HTMLDivElement>(null);
function scrollToBottom() {
requestAnimationFrame(() => {
const node = listRef.current;
if (node) node.scrollTop = node.scrollHeight;
});
}
function sendMessage() {
const text = input.trim();
if (!text || isPending) return;
const nextMessages: ChatMessage[] = [...messages, { role: "user", content: text }];
setInput("");
setError(null);
setMessages(nextMessages);
scrollToBottom();
startTransition(async () => {
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: nextMessages }),
});
const payload = (await response.json()) as {
error?: string;
message?: ChatMessage;
};
if (!response.ok) {
throw new Error(payload.error ?? "Assistant request failed");
}
if (!payload.message?.content) {
throw new Error("Assistant returned an empty response");
}
setMessages((current) => [...current, payload.message!]);
scrollToBottom();
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
}
});
}
return (
<div className="mx-auto flex w-full max-w-2xl min-h-[70vh] flex-col gap-4 min-w-0">
<div>
<h2 className="serif text-[22px] tracking-tight">Assistant</h2>
<p className="muted text-[13px] mt-1">
{configured
? "Connected to your homelab LLM. I can update lists, calendar, notes, and journal via the API."
: "LLM endpoint not configured — using the built-in mock provider for testing."}
</p>
</div>
<div
ref={listRef}
className="flex-1 overflow-y-auto rounded-[var(--r-md)] border-[0.5px] bg-[var(--card)] p-4 min-h-[320px] max-h-[60vh]"
style={{ borderColor: "var(--hair)" }}
>
{messages.length === 0 ? (
<p className="muted text-[13px]">
Try &quot;add milk to the shopping list&quot; or &quot;what&apos;s on the calendar this
week?&quot;
</p>
) : (
<div className="grid gap-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap ${
message.role === "user"
? "ml-8 bg-[var(--shade)]"
: "mr-8 border-[0.5px] bg-[var(--card)]"
}`}
style={message.role === "assistant" ? { borderColor: "var(--hair)" } : undefined}
>
<div className="eyebrow mb-1">{message.role === "user" ? "You" : "Assistant"}</div>
{message.content}
</div>
))}
</div>
)}
</div>
{error ? <p className="text-[13px] text-destructive">{error}</p> : null}
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
sendMessage();
}}
>
<Input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Ask the assistant…"
disabled={isPending}
aria-label="Message"
/>
<Button type="submit" disabled={isPending || !input.trim()}>
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
Send
</Button>
</form>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "agent",
name: "Assistant",
nav: { href: "/assistant", label: "Assistant", icon: "message-circle" },
entities: [],
};
export default manifest;
+96
View File
@@ -0,0 +1,96 @@
import { createLlmClient, type ChatMessage, type LlmClient } from "@/lib/llm";
import { AGENT_SYSTEM_PROMPT, AGENT_TOOLS } from "../tools";
import { createApiToolExecutor, type ToolExecutor } from "../tool-executor";
const MAX_TOOL_ROUNDS = 8;
export type ClientChatMessage = {
role: "user" | "assistant";
content: string;
};
export type AgentToolCallSummary = {
name: string;
status: number;
};
export type AgentChatResult = {
message: ClientChatMessage;
toolCalls: AgentToolCallSummary[];
};
export async function runAgentChat(options: {
messages: ClientChatMessage[];
request: Request;
llm?: LlmClient;
executeTool?: ToolExecutor;
}): Promise<AgentChatResult> {
const llm = options.llm ?? createLlmClient();
const executeTool = options.executeTool ?? createApiToolExecutor(options.request);
const transcript: ChatMessage[] = [
{ role: "system", content: AGENT_SYSTEM_PROMPT },
...options.messages.map(
(message): ChatMessage => ({
role: message.role,
content: message.content,
}),
),
];
const toolCalls: AgentToolCallSummary[] = [];
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
const completion = await llm.chatCompletion({
messages: transcript,
tools: AGENT_TOOLS,
});
const assistantMessage = completion.message;
transcript.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) {
return {
message: {
role: "assistant",
content: assistantMessage.content?.trim() || "I couldn't generate a response.",
},
toolCalls,
};
}
for (const toolCall of assistantMessage.tool_calls) {
let result: string;
let status: number;
try {
result = await executeTool(toolCall.function.name, toolCall.function.arguments);
const parsed = JSON.parse(result) as { status?: number };
status = typeof parsed.status === "number" ? parsed.status : 200;
} catch (err) {
status = 500;
result = JSON.stringify({
status: 500,
body: { error: err instanceof Error ? err.message : "Tool execution failed" },
});
}
toolCalls.push({ name: toolCall.function.name, status });
transcript.push({
role: "tool",
tool_call_id: toolCall.id,
name: toolCall.function.name,
content: result,
});
}
}
return {
message: {
role: "assistant",
content: "I hit the tool-call limit for this request. Please try a simpler question.",
},
toolCalls,
};
}
+145
View File
@@ -0,0 +1,145 @@
type ApiCallResult = {
status: number;
body: unknown;
};
export type ToolExecutor = (name: string, argsJson: string) => Promise<string>;
export function createApiToolExecutor(request: Request): ToolExecutor {
const origin = new URL(request.url).origin;
return async (name: string, argsJson: string) => {
const args = parseArgs(argsJson);
const result = await dispatchTool(name, args, request, origin);
return JSON.stringify(result);
};
}
function parseArgs(argsJson: string): Record<string, unknown> {
if (!argsJson.trim()) return {};
const parsed: unknown = JSON.parse(argsJson);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Tool arguments must be a JSON object");
}
return parsed as Record<string, unknown>;
}
async function dispatchTool(
name: string,
args: Record<string, unknown>,
request: Request,
origin: string,
): Promise<ApiCallResult> {
switch (name) {
case "list_lists": {
const type = typeof args.type === "string" ? args.type : undefined;
const qs = type ? `?type=${encodeURIComponent(type)}` : "";
return callApi(request, origin, "GET", `/api/v1/lists${qs}`);
}
case "list_list_items": {
const listId = requireString(args, "listId");
return callApi(request, origin, "GET", `/api/v1/lists/${listId}/items`);
}
case "add_list_item": {
const text = requireString(args, "text");
let listId = typeof args.listId === "string" ? args.listId : undefined;
if (!listId) {
const listType = typeof args.listType === "string" ? args.listType : undefined;
const listsResult = await callApi(
request,
origin,
"GET",
listType ? `/api/v1/lists?type=${encodeURIComponent(listType)}` : "/api/v1/lists",
);
if (listsResult.status !== 200 || !Array.isArray(listsResult.body)) {
return listsResult;
}
const first = listsResult.body[0] as { id?: string } | undefined;
listId = first?.id;
if (!listId) {
return { status: 404, body: { error: "No matching list found" } };
}
}
const body: Record<string, unknown> = { text };
if (typeof args.qty === "string") body.qty = args.qty;
return callApi(request, origin, "POST", `/api/v1/lists/${listId}/items`, body);
}
case "list_calendars":
return callApi(request, origin, "GET", "/api/v1/calendars");
case "list_events": {
const from = requireString(args, "from");
const to = requireString(args, "to");
const calendarIds =
typeof args.calendarIds === "string" && args.calendarIds.length > 0
? args.calendarIds
: "all";
const qs = new URLSearchParams({ from, to, calendarIds });
return callApi(request, origin, "GET", `/api/v1/events?${qs.toString()}`);
}
case "create_event": {
const body = {
calendarId: requireString(args, "calendarId"),
title: requireString(args, "title"),
startAt: requireString(args, "startAt"),
endAt: requireString(args, "endAt"),
allDay: typeof args.allDay === "boolean" ? args.allDay : false,
location: typeof args.location === "string" ? args.location : undefined,
notes: typeof args.notes === "string" ? args.notes : undefined,
};
return callApi(request, origin, "POST", "/api/v1/events", body);
}
case "list_notes":
return callApi(request, origin, "GET", "/api/v1/notes");
case "create_note": {
const body = {
title: requireString(args, "title"),
body: typeof args.body === "string" ? args.body : "",
pinned: typeof args.pinned === "boolean" ? args.pinned : false,
};
return callApi(request, origin, "POST", "/api/v1/notes", body);
}
case "list_journal_entries":
return callApi(request, origin, "GET", "/api/v1/journal/entries");
default:
return { status: 400, body: { error: `Unknown tool: ${name}` } };
}
}
function requireString(args: Record<string, unknown>, key: string): string {
const value = args[key];
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Missing required argument: ${key}`);
}
return value;
}
async function callApi(
request: Request,
origin: string,
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<ApiCallResult> {
const response = await fetch(`${origin}${path}`, {
method,
headers: {
"Content-Type": "application/json",
cookie: request.headers.get("cookie") ?? "",
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
let parsed: unknown = null;
if (text) {
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
}
return { status: response.status, body: parsed };
}
+139
View File
@@ -0,0 +1,139 @@
import type { AgentToolDefinition } from "@/lib/llm";
export const AGENT_TOOLS: AgentToolDefinition[] = [
{
type: "function",
function: {
name: "list_lists",
description: "List household lists. Optionally filter by type such as shopping or tasks.",
parameters: {
type: "object",
properties: {
type: { type: "string", description: "List type filter, e.g. shopping or tasks" },
},
},
},
},
{
type: "function",
function: {
name: "list_list_items",
description: "List items on a specific list by list id.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
},
required: ["listId"],
},
},
},
{
type: "function",
function: {
name: "add_list_item",
description:
"Add an item to a list. Provide listId, or listType (e.g. shopping) to use the first matching list.",
parameters: {
type: "object",
properties: {
listId: { type: "string", description: "UUID of the list" },
listType: { type: "string", description: "List type when listId is unknown" },
text: { type: "string", description: "Item text" },
qty: { type: "string", description: "Optional quantity" },
},
required: ["text"],
},
},
},
{
type: "function",
function: {
name: "list_calendars",
description: "List calendars visible to the household.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "list_events",
description: "List calendar events in a date range (ISO 8601 from/to).",
parameters: {
type: "object",
properties: {
from: { type: "string", description: "Range start (ISO 8601)" },
to: { type: "string", description: "Range end (ISO 8601)" },
calendarIds: {
type: "string",
description: 'Comma-separated calendar UUIDs, or "all"',
},
},
required: ["from", "to"],
},
},
},
{
type: "function",
function: {
name: "create_event",
description: "Create a calendar event.",
parameters: {
type: "object",
properties: {
calendarId: { type: "string" },
title: { type: "string" },
startAt: { type: "string", description: "ISO 8601 start" },
endAt: { type: "string", description: "ISO 8601 end" },
allDay: { type: "boolean" },
location: { type: "string" },
notes: { type: "string" },
},
required: ["calendarId", "title", "startAt", "endAt"],
},
},
},
{
type: "function",
function: {
name: "list_notes",
description: "List household notes.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "create_note",
description: "Create a note with title and optional body.",
parameters: {
type: "object",
properties: {
title: { type: "string" },
body: { type: "string" },
pinned: { type: "boolean" },
},
required: ["title"],
},
},
},
{
type: "function",
function: {
name: "list_journal_entries",
description: "List the current user's journal entries (most recent first).",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "Max entries to return (default 20)" },
},
},
},
},
];
export const AGENT_SYSTEM_PROMPT = `You are the famapp household assistant. Help Matt and his wife manage calendar events, shopping and task lists, notes, and personal journal entries.
Use the provided tools to read and update data. Prefer calling tools instead of guessing. Be concise and friendly.
When adding shopping items, resolve the shopping list id via list_lists if needed. Use ISO 8601 datetimes for calendar tools.`;
+2
View File
@@ -6,6 +6,7 @@ import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
import bangsManifest from "./bangs/manifest";
import journalManifest from "./journal/manifest";
import agentManifest from "./agent/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
@@ -14,3 +15,4 @@ registerModule(notesManifest);
registerModule(gardenManifest);
registerModule(bangsManifest);
registerModule(journalManifest);
registerModule(agentManifest);
+13
View File
@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("assistant chat smoke", async ({ page }) => {
await page.goto("/assistant");
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
await page.getByLabel("Message").fill("hello assistant");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("You")).toBeVisible();
await expect(page.getByText("hello assistant")).toBeVisible();
await expect(page.getByText("Assistant", { exact: true }).nth(1)).toBeVisible();
});
+74
View File
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createMockLlmClient } from "../../src/lib/llm/mock";
import { getLlmConfig } from "../../src/lib/llm/config";
import { runAgentChat } from "../../src/modules/agent/server/run";
describe("mock llm provider", () => {
it("returns a plain assistant message by default", async () => {
const client = createMockLlmClient();
const result = await client.chatCompletion({
messages: [{ role: "user", content: "hello" }],
tools: [],
});
assert.equal(result.message.role, "assistant");
assert.ok(result.message.content?.includes("mock provider"));
});
it("requests add_list_item when the user mentions milk", async () => {
const client = createMockLlmClient();
const result = await client.chatCompletion({
messages: [{ role: "user", content: "add milk to shopping list" }],
tools: [
{
type: "function",
function: {
name: "add_list_item",
description: "add",
parameters: { type: "object", properties: {} },
},
},
],
});
assert.equal(result.message.tool_calls?.[0]?.function.name, "add_list_item");
});
});
describe("getLlmConfig", () => {
it("uses mock provider when base url is unset", () => {
const original = process.env.LLM_BASE_URL;
const originalProvider = process.env.LLM_PROVIDER;
delete process.env.LLM_BASE_URL;
delete process.env.LLM_PROVIDER;
const config = getLlmConfig();
assert.equal(config.provider, "mock");
if (original === undefined) delete process.env.LLM_BASE_URL;
else process.env.LLM_BASE_URL = original;
if (originalProvider === undefined) delete process.env.LLM_PROVIDER;
else process.env.LLM_PROVIDER = originalProvider;
});
});
describe("runAgentChat", () => {
it("executes tool calls and returns a final assistant message", async () => {
const executed: string[] = [];
const result = await runAgentChat({
messages: [{ role: "user", content: "add milk to the shopping list" }],
request: new Request("http://localhost:3000/api/agent/chat"),
llm: createMockLlmClient(),
executeTool: async (name) => {
executed.push(name);
return JSON.stringify({ status: 201, body: { id: "item-1", text: "milk" } });
},
});
assert.deepEqual(executed, ["add_list_item"]);
assert.equal(result.message.role, "assistant");
assert.ok(result.message.content.length > 0);
assert.equal(result.toolCalls.length, 1);
assert.equal(result.toolCalls[0]?.name, "add_list_item");
assert.equal(result.toolCalls[0]?.status, 201);
});
});