@nebutra/agents 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Public API contract for @nebutra/agents.
3
+ *
4
+ * Guards the surface area that was consolidated from `@nebutra/ai-sdk`
5
+ * (generateText / streamText / embed / embedMany / configure / models / createModel)
6
+ * plus the pre-existing agent orchestration primitives. Any deletion or
7
+ * rename to these exports is a breaking change.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import * as agents from "../index";
12
+
13
+ describe("@nebutra/agents public API contract", () => {
14
+ // ─── Agent orchestration primitives ──────────────────────────────────────
15
+ it("exports BaseAgent class", () => {
16
+ expect(agents.BaseAgent).toBeDefined();
17
+ expect(typeof agents.BaseAgent).toBe("function");
18
+ });
19
+
20
+ it("exports AgentOrchestrator class", () => {
21
+ expect(agents.AgentOrchestrator).toBeDefined();
22
+ expect(typeof agents.AgentOrchestrator).toBe("function");
23
+ });
24
+
25
+ it("exports AgentRouter class", () => {
26
+ expect(agents.AgentRouter).toBeDefined();
27
+ expect(typeof agents.AgentRouter).toBe("function");
28
+ });
29
+
30
+ // ─── Memory functions ────────────────────────────────────────────────────
31
+ it("exports memory functions", () => {
32
+ expect(typeof agents.clearMemory).toBe("function");
33
+ expect(typeof agents.getMemory).toBe("function");
34
+ expect(typeof agents.saveMemory).toBe("function");
35
+ });
36
+
37
+ // ─── Tenant / context ────────────────────────────────────────────────────
38
+ it("exports tenant utilities", () => {
39
+ expect(typeof agents.checkAgentQuota).toBe("function");
40
+ expect(typeof agents.createAgentContext).toBe("function");
41
+ });
42
+
43
+ // ─── Tools ───────────────────────────────────────────────────────────────
44
+ it("exports built-in tools", () => {
45
+ expect(agents.BUILT_IN_TOOLS).toBeDefined();
46
+ expect(agents.databaseQueryTool).toBeDefined();
47
+ expect(agents.knowledgeBaseTool).toBeDefined();
48
+ expect(agents.webSearchTool).toBeDefined();
49
+ });
50
+
51
+ // ─── Vercel AI SDK helpers (absorbed from @nebutra/ai-sdk) ───────────────
52
+ it("exports configure() and getConfig()", () => {
53
+ expect(typeof agents.configure).toBe("function");
54
+ expect(typeof agents.getConfig).toBe("function");
55
+ });
56
+
57
+ it("exports generateText() and streamText()", () => {
58
+ expect(typeof agents.generateText).toBe("function");
59
+ expect(typeof agents.streamText).toBe("function");
60
+ });
61
+
62
+ it("exports embed() and embedMany()", () => {
63
+ expect(typeof agents.embed).toBe("function");
64
+ expect(typeof agents.embedMany).toBe("function");
65
+ });
66
+
67
+ it("exports createModel() and createEmbeddingModel()", () => {
68
+ expect(typeof agents.createModel).toBe("function");
69
+ expect(typeof agents.createEmbeddingModel).toBe("function");
70
+ });
71
+
72
+ it("exports models preset map with expected keys", () => {
73
+ expect(agents.models).toBeDefined();
74
+ expect(agents.models.flagship).toBeTypeOf("string");
75
+ expect(agents.models.fast).toBeTypeOf("string");
76
+ expect(agents.models.embedding).toBeTypeOf("string");
77
+ });
78
+
79
+ it("exports resolveModel() and returns presets correctly", () => {
80
+ expect(typeof agents.resolveModel).toBe("function");
81
+ expect(agents.resolveModel("flagship")).toBe(agents.models.flagship);
82
+ expect(agents.resolveModel("custom/passthrough")).toBe("custom/passthrough");
83
+ });
84
+
85
+ it("configure() accepts a provider selection and getConfig() reflects it", () => {
86
+ agents.configure({ provider: "openai", defaultModel: "gpt-4o" });
87
+ const cfg = agents.getConfig();
88
+ expect(cfg.provider).toBe("openai");
89
+ expect(cfg.defaultModel).toBe("gpt-4o");
90
+ });
91
+
92
+ it("NebutraAIConfigSchema validates input config", () => {
93
+ expect(agents.NebutraAIConfigSchema).toBeDefined();
94
+ const parsed = agents.NebutraAIConfigSchema.parse({});
95
+ expect(parsed.provider).toBe("openrouter"); // default
96
+ expect(parsed.defaultModel).toBeTypeOf("string");
97
+ });
98
+
99
+ it("AgentOrchestrator can be instantiated with empty agents", () => {
100
+ const orch = new agents.AgentOrchestrator({ agents: [] });
101
+ expect(orch).toBeInstanceOf(agents.AgentOrchestrator);
102
+ });
103
+
104
+ it("BaseAgent can be instantiated with an AgentConfig", () => {
105
+ const agent = new agents.BaseAgent({
106
+ id: "test",
107
+ name: "Test Agent",
108
+ description: "Contract test",
109
+ model: "openai/gpt-4",
110
+ instructions: "You are a test agent.",
111
+ });
112
+ expect(agent.config.id).toBe("test");
113
+ });
114
+ });
package/src/agent.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * BaseAgent — abstract agent with memory, usage tracking, and tenant scoping.
3
+ *
4
+ * Concrete agents (VercelAIAgent, LangChainAgent, etc.) extend this class
5
+ * and implement the `execute()` method for their specific SDK.
6
+ */
7
+
8
+ import { logger } from "@nebutra/logger";
9
+ import { getMemory, saveMemory } from "./memory";
10
+ import type {
11
+ AgentConfig,
12
+ AgentContext,
13
+ AgentMessage,
14
+ AgentResponse,
15
+ AgentUsageEvent,
16
+ } from "./types";
17
+
18
+ export class BaseAgent {
19
+ public readonly config: AgentConfig;
20
+
21
+ constructor(config: AgentConfig) {
22
+ this.config = config;
23
+ }
24
+
25
+ /**
26
+ * Run the agent with full lifecycle:
27
+ * 1. Load long-term memory (if configured)
28
+ * 2. Trim to short-term window
29
+ * 3. Delegate to provider-specific `execute()`
30
+ * 4. Track usage for billing
31
+ * 5. Persist new messages to memory
32
+ */
33
+ async run(messages: readonly AgentMessage[], context: AgentContext): Promise<AgentResponse> {
34
+ const startTime = Date.now();
35
+
36
+ // Load long-term memory when enabled
37
+ const memory = this.config.memory?.longTerm?.enabled
38
+ ? await getMemory(context.tenantId, context.conversationId)
39
+ : [];
40
+
41
+ // Merge and trim to short-term window
42
+ const maxMessages = this.config.memory?.shortTerm?.maxMessages ?? 50;
43
+ const allMessages = [...memory, ...messages].slice(-maxMessages);
44
+
45
+ // Provider-specific execution
46
+ const response = await this.execute(allMessages, context);
47
+
48
+ const durationMs = Date.now() - startTime;
49
+
50
+ // Build usage event for billing / metering
51
+ const usage: AgentUsageEvent = {
52
+ tenantId: context.tenantId,
53
+ userId: context.userId,
54
+ agentId: this.config.id,
55
+ model: this.config.model,
56
+ promptTokens: response.usage.promptTokens,
57
+ completionTokens: response.usage.completionTokens,
58
+ totalTokens: response.usage.totalTokens,
59
+ durationMs,
60
+ timestamp: new Date(),
61
+ };
62
+
63
+ logger.info("Agent execution completed", {
64
+ agentId: this.config.id,
65
+ tenantId: context.tenantId,
66
+ tokens: usage.totalTokens,
67
+ durationMs,
68
+ });
69
+
70
+ // Persist to long-term memory
71
+ if (this.config.memory?.longTerm?.enabled) {
72
+ await saveMemory(context.tenantId, context.conversationId, response.messages);
73
+ }
74
+
75
+ // Await usage emission so billing deduction is tracked before returning
76
+ await this.emitUsage(usage);
77
+
78
+ return response;
79
+ }
80
+
81
+ /**
82
+ * Provider-specific execution. Subclasses MUST override this.
83
+ */
84
+ protected async execute(
85
+ _messages: readonly AgentMessage[],
86
+ _context: AgentContext,
87
+ ): Promise<AgentResponse> {
88
+ throw new Error("execute() must be implemented by provider adapter");
89
+ }
90
+
91
+ /**
92
+ * Emit a usage event for downstream billing / metering.
93
+ */
94
+ private async emitUsage(event: AgentUsageEvent): Promise<void> {
95
+ try {
96
+ const { deductCredits } = await import("@nebutra/billing/credits");
97
+
98
+ // Basic credit consumption: 1 credit per 10k total tokens
99
+ const totalTokens = event.totalTokens || 0;
100
+ if (totalTokens === 0) return;
101
+
102
+ const creditCost = Math.max(1, Math.ceil(totalTokens / 10000));
103
+
104
+ await deductCredits({
105
+ organizationId: event.tenantId,
106
+ amount: creditCost,
107
+ description: `Agent execution: ${event.model}`,
108
+ });
109
+ } catch (err) {
110
+ logger.error("Failed to emit usage or deduct credits for agent execution", {
111
+ tenantId: event.tenantId,
112
+ agentId: event.agentId,
113
+ error: err,
114
+ });
115
+ }
116
+ }
117
+ }
package/src/context.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * User context primitives — Memory-as-context layer for AI conversations.
3
+ *
4
+ * Inspired by Perplexity / ChatGPT "custom instructions" pattern but
5
+ * implemented as pure helpers: this package does **not** read the database
6
+ * (keeps `@nebutra/agents` data-layer-agnostic). The caller fetches the
7
+ * profile and passes the structured object in.
8
+ *
9
+ * Usage pattern from a Next.js route:
10
+ *
11
+ * ```ts
12
+ * const profile = await db.userProfile.findUnique({ where: { userId } });
13
+ * const system = buildPersonalizedSystemPrompt(BASE_PROMPT, profile);
14
+ * const result = await streamText(messages, { system, model: "fast" });
15
+ * ```
16
+ */
17
+
18
+ export interface UserContext {
19
+ /** What the assistant should call the user. */
20
+ nickname?: string | null;
21
+ /** Job title / role — gives the model audience context. */
22
+ occupation?: string | null;
23
+ /** Free-form bio (interests, location, work focus, etc.). */
24
+ bio?: string | null;
25
+ /** Verbatim instructions that override default tone/format. */
26
+ customInstructions?: string | null;
27
+ }
28
+
29
+ const MAX_BIO_CHARS = 2000;
30
+ const MAX_INSTRUCTIONS_CHARS = 3000;
31
+ const MAX_OCCUPATION_CHARS = 120;
32
+ const MAX_NICKNAME_CHARS = 80;
33
+
34
+ /**
35
+ * Truthy check that treats empty strings as "no context".
36
+ */
37
+ function present(value: string | null | undefined): value is string {
38
+ return typeof value === "string" && value.trim().length > 0;
39
+ }
40
+
41
+ /**
42
+ * Truncate to a hard byte budget — defensive against runaway DB rows.
43
+ */
44
+ function clamp(value: string, max: number): string {
45
+ return value.length > max ? `${value.slice(0, max)}…` : value;
46
+ }
47
+
48
+ /**
49
+ * Renders a `UserContext` as a compact block to inject into a system prompt.
50
+ *
51
+ * Output is null when no field is set, so callers can skip the entire
52
+ * "About the user" preamble and avoid wasted tokens.
53
+ */
54
+ export function renderUserContextBlock(context: UserContext | null | undefined): string | null {
55
+ if (!context) return null;
56
+
57
+ const lines: string[] = [];
58
+
59
+ if (present(context.nickname)) {
60
+ lines.push(`- Preferred name: ${clamp(context.nickname.trim(), MAX_NICKNAME_CHARS)}`);
61
+ }
62
+ if (present(context.occupation)) {
63
+ lines.push(`- Role: ${clamp(context.occupation.trim(), MAX_OCCUPATION_CHARS)}`);
64
+ }
65
+ if (present(context.bio)) {
66
+ lines.push(`- About them: ${clamp(context.bio.trim(), MAX_BIO_CHARS)}`);
67
+ }
68
+
69
+ let block = "";
70
+ if (lines.length > 0) {
71
+ block += `About the user:\n${lines.join("\n")}`;
72
+ }
73
+
74
+ if (present(context.customInstructions)) {
75
+ if (block) block += "\n\n";
76
+ block += `The user's custom instructions (these take precedence over defaults):\n${clamp(
77
+ context.customInstructions.trim(),
78
+ MAX_INSTRUCTIONS_CHARS,
79
+ )}`;
80
+ }
81
+
82
+ return block.length > 0 ? block : null;
83
+ }
84
+
85
+ /**
86
+ * Builds the final system prompt by prepending a personalization block to the
87
+ * base prompt. Pure — no side effects, safe to call per-request.
88
+ *
89
+ * @param basePrompt - the assistant's role-defining base prompt
90
+ * @param context - structured user context (null/undefined disables personalization)
91
+ */
92
+ export function buildPersonalizedSystemPrompt(
93
+ basePrompt: string,
94
+ context: UserContext | null | undefined,
95
+ ): string {
96
+ const block = renderUserContextBlock(context);
97
+ if (!block) return basePrompt;
98
+ return `${block}\n\n---\n\n${basePrompt}`;
99
+ }
package/src/env.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Environment validation for `@nebutra/agents`.
3
+ *
4
+ * All variables are OPTIONAL — the package must work with zero new env config.
5
+ * Provider keys (OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.)
6
+ * are validated lazily by the provider resolver, not here.
7
+ */
8
+
9
+ import { z } from "zod";
10
+
11
+ /** Comma-separated provider chain. Order = priority. */
12
+ const FallbackProviderName = z.enum(["openrouter", "anthropic", "openai"]);
13
+ export type FallbackProviderName = z.infer<typeof FallbackProviderName>;
14
+
15
+ const FallbackChain = z
16
+ .string()
17
+ .transform((raw) =>
18
+ raw
19
+ .split(",")
20
+ .map((s) => s.trim())
21
+ .filter(Boolean),
22
+ )
23
+ .pipe(z.array(FallbackProviderName).min(1));
24
+
25
+ export const AgentsEnvSchema = z.object({
26
+ // ── Anthropic (direct) ─────────────────────────────────────────────────
27
+ ANTHROPIC_API_KEY: z.string().optional(),
28
+
29
+ // ── Langfuse (LLM tracing — optional) ─────────────────────────────────
30
+ LANGFUSE_PUBLIC_KEY: z.string().optional(),
31
+ LANGFUSE_SECRET_KEY: z.string().optional(),
32
+ LANGFUSE_HOST: z.string().url().default("https://cloud.langfuse.com"),
33
+
34
+ // ── Multi-provider fallback chain ─────────────────────────────────────
35
+ /**
36
+ * Comma-separated chain of providers tried in order on retryable failures.
37
+ * Default: "openrouter,anthropic,openai" — OpenRouter first (multi-model),
38
+ * then direct Anthropic (prompt caching), then direct OpenAI as last resort.
39
+ */
40
+ LLM_FALLBACK_CHAIN: FallbackChain.default((): FallbackProviderName[] => [
41
+ "openrouter",
42
+ "anthropic",
43
+ "openai",
44
+ ]),
45
+
46
+ /**
47
+ * Comma-separated chain of providers tried for EMBEDDINGS, in order.
48
+ * Default: "openrouter,openai" — Anthropic does not currently expose
49
+ * embedding models, so it is excluded by default. If unset, falls back
50
+ * to LLM_FALLBACK_CHAIN with embedding-incompatible providers filtered out.
51
+ */
52
+ LLM_EMBEDDING_FALLBACK_CHAIN: FallbackChain.default((): FallbackProviderName[] => [
53
+ "openrouter",
54
+ "openai",
55
+ ]),
56
+ });
57
+
58
+ export type AgentsEnv = z.infer<typeof AgentsEnvSchema>;
59
+
60
+ /** Lazy parsed env — re-read once on first call. */
61
+ let _cached: AgentsEnv | undefined;
62
+
63
+ /** Returns the validated env (cached). Safe to call from any runtime. */
64
+ export function getAgentsEnv(): AgentsEnv {
65
+ if (_cached) return _cached;
66
+ _cached = AgentsEnvSchema.parse(globalThis.process?.env ?? {});
67
+ return _cached;
68
+ }
69
+
70
+ /** Test helper — clears cache so updated process.env is picked up. */
71
+ export function _resetAgentsEnvCache(): void {
72
+ _cached = undefined;
73
+ }
74
+
75
+ /** True iff Langfuse credentials are present. */
76
+ export function isLangfuseConfigured(): boolean {
77
+ const env = getAgentsEnv();
78
+ return Boolean(env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY);
79
+ }