fix(testkit): macOS compat + fix test 8c ordering (#24)
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { anthropic } from "@workspace/integrations-anthropic-ai";
|
||||
import { makeLogger } from "./logger.js";
|
||||
|
||||
const logger = makeLogger("agent");
|
||||
|
||||
export interface EvalResult {
|
||||
accepted: boolean;
|
||||
@@ -18,17 +20,79 @@ export interface AgentConfig {
|
||||
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.";
|
||||
|
||||
// ── 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<string, unknown>): Promise<{
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
usage: { input_tokens: number; output_tokens: number };
|
||||
}>;
|
||||
stream(params: Record<string, unknown>): 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<AnthropicLike> {
|
||||
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";
|
||||
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<EvalResult> {
|
||||
const message = await anthropic.messages.create({
|
||||
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.
|
||||
@@ -45,10 +109,10 @@ Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted"
|
||||
|
||||
let parsed: { accepted: boolean; reason: string };
|
||||
try {
|
||||
const raw = block.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
|
||||
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}`);
|
||||
throw new Error(`Failed to parse eval JSON: ${block.text!}`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -60,7 +124,13 @@ Respond ONLY with valid JSON: {"accepted": true, "reason": "..."} or {"accepted"
|
||||
}
|
||||
|
||||
async executeWork(requestText: string): Promise<WorkResult> {
|
||||
const message = await anthropic.messages.create({
|
||||
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.
|
||||
@@ -74,11 +144,61 @@ Fulfill it thoroughly and helpfully. Be concise yet complete.`,
|
||||
}
|
||||
|
||||
return {
|
||||
result: block.text,
|
||||
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<WorkResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
export const agentService = new AgentService();
|
||||
|
||||
Reference in New Issue
Block a user