From 15ab8ededaab447150c0b932c505d225c375335b Mon Sep 17 00:00:00 2001 From: shad Date: Sun, 14 Jun 2026 14:55:34 +0400 Subject: [PATCH] feat(agent): LLM proxy integration shape (Pi-AI compatible) src/lib/llmClient.ts: browser POSTs to /internal/canvas-llm/chat with {messages, max_tokens}. Mirrors the normalized request shape used by earendil-works/pi's @earendil-works/pi-ai. Returns: - ok:true with content+provider when configured and successful - ok:false, configured:false when proxy 404/503/unreachable - ok:false, configured:true when provider returned an error routeAgentInput now falls back to the LLM with a system prompt that includes the user identity, tool catalogue, and the top-3 vault recall hits when no deterministic tool matches. When the proxy is not configured the deterministic vault-hint reply is used (no regression in the demo tier). --- qa/audit_memory.mjs | 56 ++++++++++++++++++++++++++++++++++++++ src/lib/agentTools.ts | 41 ++++++++++++++++++---------- src/lib/llmClient.ts | 63 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 14 deletions(-) create mode 100644 qa/audit_memory.mjs create mode 100644 src/lib/llmClient.ts diff --git a/qa/audit_memory.mjs b/qa/audit_memory.mjs new file mode 100644 index 0000000..7464eaf --- /dev/null +++ b/qa/audit_memory.mjs @@ -0,0 +1,56 @@ +// Verify the agent memory vault round-trips: signed-in user tells the +// Assistant a fact, then asks the Assistant to recall it. Second persona +// in same tenant must NOT see the first persona's notes (isolation check +// is tenant-scoped not user-scoped since the vault key is per-user). +import { chromium } from "playwright"; + +const URL = "https://canvas.flow-master.ai/"; +const args = ["--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186", "--ignore-certificate-errors"]; +const b = await chromium.launch({ headless: true, args }); + +async function asPersona(personaText, work) { + const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } }); + const p = await ctx.newPage(); + await p.goto(URL, { waitUntil: "networkidle" }); + await p.waitForTimeout(800); + await p.locator(".persona-chip", { hasText: personaText }).click(); + await p.locator(".dev-login-btn").click(); + await p.waitForTimeout(2500); + const enter = p.locator("button", { hasText: /Enter Mission Control/i }).first(); + if (await enter.count()) { await enter.click(); await p.waitForTimeout(1200); } + await p.locator(".tab", { hasText: /^\s*Assistant\s*$/ }).click(); + await p.waitForTimeout(1500); + const result = await work(p); + await ctx.close(); + return result; +} + +async function ask(p, text) { + const before = await p.locator(".agent-turn-agent .agent-turn-body").count(); + await p.locator(".agent-composer textarea").fill(text); + await p.locator(".agent-composer button", { hasText: /Send/i }).click(); + await p.waitForFunction((n) => document.querySelectorAll(".agent-turn-agent .agent-turn-body").length > n, before, { timeout: 8000 }).catch(() => {}); + await p.waitForTimeout(600); + return (await p.locator(".agent-turn-agent .agent-turn-body").last().textContent()) || ""; +} + +const r1 = await asPersona(/Mariana/, async (p) => { + const a = await ask(p, "remember that the laptop budget cap is 1500 pounds for store managers"); + console.log("[CEO remember]:", a.slice(0, 120)); + await p.waitForTimeout(2000); + const b = await ask(p, "recall about laptop budget"); + console.log("[CEO recall]:", b.slice(0, 200)); + return { remember: a, recall: b }; +}); + +const r2 = await asPersona(/Aisha/, async (p) => { + const a = await ask(p, "recall about laptop budget"); + console.log("[HR recall]:", a.slice(0, 200)); + return { recall: a }; +}); + +console.log(); +console.log("CEO remembered:", r1.remember.includes("I'll remember") ? "YES" : "NO"); +console.log("CEO recalled own note:", r1.recall.includes("1500") || r1.recall.includes("laptop") ? "YES" : "NO"); +console.log("HR vault isolated from CEO:", !r2.recall.includes("1500") ? "YES" : "NO (leak)"); +await b.close(); diff --git a/src/lib/agentTools.ts b/src/lib/agentTools.ts index 3045a65..e2b9e1d 100644 --- a/src/lib/agentTools.ts +++ b/src/lib/agentTools.ts @@ -3,6 +3,7 @@ import { wizardApi } from "./wizardApi"; import { chatApi } from "./chatApi"; import { curatedPublishedFlows, fetchStartableFlows } from "./flowCuration"; import { agentMemory, type Memory } from "./agentMemory"; +import { llmClient, type LlmMessage } from "./llmClient"; export interface ToolResult { ok: boolean; @@ -181,11 +182,24 @@ const TOOLS: ToolDef[] = [ }, ]; +async function llmFallback(trimmed: string, ctx: ToolContext, hints: Memory[]): Promise { + const memBlock = hints.length ? `\nYour vault has related notes:\n${hints.map((h) => `- ${h.content}`).join("\n")}` : ""; + const system: LlmMessage = { + role: "system", + content: + `You are the FlowMaster Command Assistant inside canvas.flow-master.ai for ${ctx.userEmail}. ` + + `Speak briefly (3 sentences max). You can refer to these tools by name when suggesting actions: ${TOOLS.map((t) => t.name).join(", ")}. ` + + `If the user asks something operational, recommend a specific tool. ${memBlock}`, + }; + const user: LlmMessage = { role: "user", content: trimmed }; + const reply = await llmClient.chat({ messages: [system, user], max_tokens: 320 }); + if (reply.ok) return { ok: true, display: reply.content }; + return null; +} + export async function routeAgentInput(text: string, ctx: ToolContext): Promise { const trimmed = text.trim(); if (!trimmed) return { ok: false, display: "Tell me what to do." }; - // Best-effort: every turn becomes an episodic memory in the vault. We never - // block the response on it so a vault outage doesn't break the assistant. agentMemory.remember(ctx.userEmail, trimmed, ["turn"]).catch(() => {}); for (const tool of TOOLS) { if (tool.matcher.test(trimmed)) { @@ -196,18 +210,17 @@ export async function routeAgentInput(text: string, ctx: ToolContext): Promise 0) { - const lines = hints.map((h) => `• ${h.content}`).join("\n"); - return { - ok: true, - display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`, - }; - } - } catch { /* ignore */ } + let hints: Memory[] = []; + try { hints = await agentMemory.recall(ctx.userEmail, trimmed, 3); } catch { /* ignore */ } + const llmReply = await llmFallback(trimmed, ctx, hints); + if (llmReply) return llmReply; + if (hints.length > 0) { + const lines = hints.map((h) => `• ${h.content}`).join("\n"); + return { + ok: true, + display: `I don't have a command that matches that yet, but your vault has related notes:\n${lines}\n\nTry: 'open mission', 'list processes', 'remember that ...', 'recall about ...', 'create a new process'.`, + }; + } return { ok: false, display: diff --git a/src/lib/llmClient.ts b/src/lib/llmClient.ts new file mode 100644 index 0000000..c0ff34f --- /dev/null +++ b/src/lib/llmClient.ts @@ -0,0 +1,63 @@ +/** + * Browser-side LLM client. Speaks to a backend proxy that brokers to the + * configured provider (Anthropic, OpenAI, etc.) using the same request + * shape as pi-ai's normalized completions. When the proxy is unreachable + * or unconfigured the client returns { configured: false } and the caller + * is expected to fall back to a deterministic path. + */ + +import { api } from "./api"; + +export interface LlmMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface LlmRequest { + messages: LlmMessage[]; + max_tokens?: number; +} + +export interface LlmReply { + ok: true; + content: string; + provider?: string; +} + +export interface LlmUnconfigured { + ok: false; + configured: false; + reason: string; +} + +export interface LlmError { + ok: false; + configured: true; + error: string; +} + +export type LlmResponse = LlmReply | LlmUnconfigured | LlmError; + +export const llmClient = { + async chat(req: LlmRequest, signal?: AbortSignal): Promise { + try { + const res = await fetch(`${api.config.baseUrl}/internal/canvas-llm/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}`, + }, + body: JSON.stringify({ messages: req.messages, max_tokens: req.max_tokens ?? 512 }), + signal, + }); + if (res.status === 404) return { ok: false, configured: false, reason: "LLM proxy not wired" }; + if (res.status === 503) return { ok: false, configured: false, reason: "LLM provider not configured" }; + if (!res.ok) return { ok: false, configured: true, error: `Provider error ${res.status}` }; + const body = await res.json(); + if (typeof body?.content !== "string") return { ok: false, configured: true, error: "Provider returned unparseable body" }; + return { ok: true, content: body.content, provider: body.provider }; + } catch (e) { + return { ok: false, configured: false, reason: `LLM proxy unreachable: ${(e as Error).message}` }; + } + }, +};