import { makeLogger } from "./logger.js"; const logger = makeLogger("agent"); export interface EvalResult { accepted: boolean; reason: string; inputTokens: number; outputTokens: number; } export interface WorkResult { result: string; inputTokens: number; outputTokens: number; } export interface AgentConfig { evalModel?: string; workModel?: string; } // ── Stub mode detection ─────────────────────────────────────────────────────── // If Anthropic credentials are absent, all AI calls return canned responses so // the server starts and exercises the full payment/state-machine flow without // a real API key. This mirrors the LNbits stub pattern. const STUB_MODE = !process.env["AI_INTEGRATIONS_ANTHROPIC_API_KEY"] || !process.env["AI_INTEGRATIONS_ANTHROPIC_BASE_URL"]; if (STUB_MODE) { logger.warn("no Anthropic key — running in STUB mode", { component: "agent", stub: true }); } const STUB_EVAL: EvalResult = { accepted: true, reason: "Stub: request accepted for processing.", inputTokens: 0, outputTokens: 0, }; const STUB_RESULT = "Stub response: Timmy is running in stub mode (no Anthropic API key). " + "Configure AI_INTEGRATIONS_ANTHROPIC_API_KEY to enable real AI responses."; const STUB_CHAT_REPLIES = [ "Ah, a visitor! *adjusts hat* The crystal ball sensed your presence. What do you seek?", "By the ancient runes! In stub mode I cannot reach the stars, but my wisdom remains. Ask away!", "The crystal ball glows with your curiosity… configure a Lightning node to unlock true magic!", "Welcome to my workshop, traveler. I am Timmy — wizard, agent, and keeper of lightning sats.", ]; // ── Lazy client ─────────────────────────────────────────────────────────────── // Minimal local interface — avoids importing @anthropic-ai/sdk types directly. // Dynamic import avoids the module-level throw in the integrations client when // env vars are absent (the client.ts guard runs at module evaluation time). interface AnthropicLike { messages: { create(params: Record): Promise<{ content: Array<{ type: string; text?: string }>; usage: { input_tokens: number; output_tokens: number }; }>; stream(params: Record): AsyncIterable<{ type: string; delta?: { type: string; text?: string }; usage?: { output_tokens: number }; message?: { usage: { input_tokens: number } }; }>; }; } let _anthropic: AnthropicLike | null = null; async function getClient(): Promise { if (_anthropic) return _anthropic; // @ts-expect-error -- TS6305: integrations-anthropic-ai exports src directly; project-reference build not required at runtime const mod = (await import("@workspace/integrations-anthropic-ai")) as { anthropic: AnthropicLike }; _anthropic = mod.anthropic; return _anthropic; } // ── AgentService ───────────────────────────────────────────────────────────── export class AgentService { readonly evalModel: string; readonly workModel: string; readonly stubMode: boolean = STUB_MODE; constructor(config?: AgentConfig) { this.evalModel = config?.evalModel ?? process.env["EVAL_MODEL"] ?? "claude-haiku-4-5"; this.workModel = config?.workModel ?? process.env["WORK_MODEL"] ?? "claude-sonnet-4-6"; } async evaluateRequest(requestText: string): Promise { if (STUB_MODE) { // Simulate a short eval delay so state-machine tests are realistic await new Promise((r) => setTimeout(r, 300)); return { ...STUB_EVAL }; } const client = await getClient(); const message = await client.messages.create({ model: this.evalModel, max_tokens: 8192, system: `You are Timmy, an AI agent gatekeeper. Evaluate whether a request is acceptable to act on. ACCEPT if the request is: clear enough to act on, ethical, lawful, and within the capability of a general-purpose AI. REJECT if the request is: harmful, illegal, unethical, incoherent, or spam. Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted": false, "reason": "..."}`, messages: [{ role: "user", content: `Evaluate this request: ${requestText}` }], }); const block = message.content[0]; if (block.type !== "text") { throw new Error("Unexpected non-text response from eval model"); } let parsed: { accepted: boolean; reason: string }; try { const raw = block.text!.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim(); parsed = JSON.parse(raw) as { accepted: boolean; reason: string }; } catch { throw new Error(`Failed to parse eval JSON: ${block.text!}`); } return { accepted: Boolean(parsed.accepted), reason: parsed.reason ?? "", inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } async executeWork(requestText: string): Promise { if (STUB_MODE) { await new Promise((r) => setTimeout(r, 500)); return { result: STUB_RESULT, inputTokens: 0, outputTokens: 0 }; } const client = await getClient(); const message = await client.messages.create({ model: this.workModel, max_tokens: 8192, system: `You are Timmy, a capable AI agent. A user has paid for you to handle their request. Fulfill it thoroughly and helpfully. Be concise yet complete.`, messages: [{ role: "user", content: requestText }], }); const block = message.content[0]; if (block.type !== "text") { throw new Error("Unexpected non-text response from work model"); } return { result: block.text!, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } /** * Streaming variant of executeWork (#3). Calls onChunk for every text delta. * In stub mode, emits the canned response word-by-word to exercise the SSE * path end-to-end without a real Anthropic key. */ async executeWorkStreaming( requestText: string, onChunk: (delta: string) => void, ): Promise { if (STUB_MODE) { const words = STUB_RESULT.split(" "); for (const word of words) { const delta = word + " "; onChunk(delta); await new Promise((r) => setTimeout(r, 40)); } return { result: STUB_RESULT, inputTokens: 0, outputTokens: 0 }; } const client = await getClient(); let fullText = ""; let inputTokens = 0; let outputTokens = 0; const stream = client.messages.stream({ model: this.workModel, max_tokens: 8192, system: `You are Timmy, a capable AI agent. A user has paid for you to handle their request. Fulfill it thoroughly and helpfully. Be concise yet complete.`, messages: [{ role: "user", content: requestText }], }); for await (const event of stream) { if ( event.type === "content_block_delta" && event.delta?.type === "text_delta" ) { const delta = event.delta!.text ?? ""; fullText += delta; onChunk(delta); } else if (event.type === "message_delta" && event.usage) { outputTokens = event.usage!.output_tokens; } else if (event.type === "message_start" && event.message?.usage) { inputTokens = event.message!.usage.input_tokens; } } return { result: fullText, inputTokens, outputTokens }; } /** * Quick free chat reply — called for visitor messages in the Workshop. * Uses the cheaper eval model with a wizard persona and a 150-token limit * so replies are short enough to fit in Timmy's speech bubble. */ async chatReply(userText: string): Promise { if (STUB_MODE) { await new Promise((r) => setTimeout(r, 400)); return STUB_CHAT_REPLIES[Math.floor(Math.random() * STUB_CHAT_REPLIES.length)]!; } const client = await getClient(); const message = await client.messages.create({ model: this.evalModel, // Haiku — cheap and fast for free replies max_tokens: 150, system: `You are Timmy, a whimsical wizard who runs a mystical workshop powered by Bitcoin Lightning. Reply to visitors in 1-2 short, punchy sentences. Be helpful, witty, and weave in light wizard or Lightning Network metaphors. Keep replies under 200 characters.`, messages: [{ role: "user", content: userText }], }); const block = message.content[0]; if (block.type !== "text") return "The crystal ball is cloudy… try again."; return block.text!.slice(0, 250).trim(); } } export const agentService = new AgentService();