@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
423 lines (422 loc) • 20.9 kB
JavaScript
/**
* Agent Routes
* Endpoints for agent execution and streaming
*/
import { SpanStatusCode } from "@opentelemetry/api";
import { ProviderFactory } from "../../factories/providerFactory.js";
import { withSpan } from "../../telemetry/withSpan.js";
import { tracers } from "../../telemetry/tracers.js";
import { logger } from "../../utils/logger.js";
/**
* Resolve `request.tools` (an array of tool NAMES shared by the execute and
* stream endpoints) into a `toolFilter` whitelist. The field was previously
* accepted and silently ignored, so entries are validated against registered
* names and the whole field fails open (undefined — keep all tools) when
* nothing matches: existing clients sending unknown names must not lose
* their tool set.
*/
async function resolveRequestToolFilter(ctx, requestedTools) {
if (!Array.isArray(requestedTools) || requestedTools.length === 0) {
return undefined;
}
const registered = new Set((await ctx.neurolink.getAllAvailableTools()).map((t) => t.name));
const known = requestedTools.filter((name) => typeof name === "string" && registered.has(name));
if (known.length > 0) {
return known;
}
logger.warn("[agentRoutes] Ignoring request.tools — no entries match registered tool names (legacy fail-open)", { requested: requestedTools });
return undefined;
}
import { createStreamRedactor } from "../utils/redaction.js";
import { AgentExecuteRequestSchema, createErrorResponse as createError, EmbedManyRequestSchema, EmbedRequestSchema, SkillCreateRequestSchema, SkillUpdateRequestSchema, validateRequest, } from "../utils/validation.js";
/**
* Resolve the skills manager from the server's NeuroLink instance, or a
* 503 error response when skills are not configured on this server.
*/
function resolveSkillsManager(ctx) {
const manager = ctx.neurolink.getSkillsManager();
if (!manager) {
return createError("SKILLS_UNAVAILABLE", "Skills are not enabled on this server — construct NeuroLink with a `skills` config.", undefined, ctx.requestId);
}
return manager;
}
/**
* Resolve the skills manager for a *mutating* route, or an error response when
* skills aren't configured (503) or mutations are disabled (allowMutations is
* not true). The LLM tools are already registration-gated; this applies the
* same switch to the REST create/update/delete endpoints.
*/
function resolveMutableSkillsManager(ctx) {
const manager = resolveSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
if (!manager.mutationsAllowed) {
return createError("SKILLS_MUTATIONS_DISABLED", "Skill mutations are disabled on this server \u2014 construct NeuroLink with `skills.allowMutations: true` to enable create/update/delete.", undefined, ctx.requestId);
}
return manager;
}
/**
* Create agent routes
*/
export function createAgentRoutes(basePath = "/api") {
return {
prefix: `${basePath}/agent`,
routes: [
{
method: "POST",
path: `${basePath}/agent/execute`,
handler: async (ctx) => {
// Validate request body
const validation = validateRequest(AgentExecuteRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const request = validation.data;
return withSpan({
name: "neurolink.http.execute",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/execute",
"ai.provider": request.provider || "default",
"ai.model": request.model || "default",
},
}, async () => {
// Normalize input
const input = typeof request.input === "string"
? { text: request.input }
: request.input;
const requestToolFilter = await resolveRequestToolFilter(ctx, request.tools);
const result = await ctx.neurolink.generate({
input,
provider: request.provider,
model: request.model,
systemPrompt: request.systemPrompt,
temperature: request.temperature,
maxTokens: request.maxTokens,
...(requestToolFilter ? { toolFilter: requestToolFilter } : {}),
context: {
// When an authenticated user context exists (set by auth middleware),
// always use its IDs to prevent caller-supplied impersonation.
sessionId: ctx.user
? ctx.session?.id
: (ctx.session?.id ?? request.sessionId),
userId: ctx.user ? ctx.user.id : request.userId,
userEmail: ctx.user?.email,
userRoles: ctx.user?.roles,
requestId: ctx.requestId,
},
});
// Map tool calls from SDK format to API format
const toolCalls = result.toolCalls?.map((tc) => ({
name: tc.toolName,
arguments: tc.args,
}));
return {
content: result.content || "",
provider: result.provider || request.provider || "unknown",
model: result.model || request.model || "unknown",
usage: result.usage,
toolCalls,
};
}); // end withSpan
},
description: "Execute agent with prompt",
tags: ["agent"],
},
{
method: "POST",
path: `${basePath}/agent/stream`,
streaming: { enabled: true, contentType: "text/event-stream" },
handler: async (ctx) => {
// Validate request body
const validation = validateRequest(AgentExecuteRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const request = validation.data;
// Normalize input
const input = typeof request.input === "string"
? { text: request.input }
: request.input;
const streamToolFilter = await resolveRequestToolFilter(ctx, request.tools);
const result = await ctx.neurolink.stream({
input,
provider: request.provider,
model: request.model,
systemPrompt: request.systemPrompt,
temperature: request.temperature,
maxTokens: request.maxTokens,
...(streamToolFilter ? { toolFilter: streamToolFilter } : {}),
context: {
// When an authenticated user context exists (set by auth middleware),
// always use its IDs to prevent caller-supplied impersonation.
sessionId: ctx.user
? ctx.session?.id
: (ctx.session?.id ?? request.sessionId),
userId: ctx.user ? ctx.user.id : request.userId,
userEmail: ctx.user?.email,
userRoles: ctx.user?.roles,
requestId: ctx.requestId,
},
});
// Create redactor (no-op if redaction is not enabled)
const redactor = createStreamRedactor(ctx.redaction);
// Wrap stream with a span that stays open for the full consumption
// lifetime, not just the generator creation.
async function* redactedStream() {
const streamSpan = tracers.http.startSpan("neurolink.http.stream", {
attributes: {
"http.route": "/api/agent/stream",
"ai.provider": request.provider || "default",
"ai.model": request.model || "default",
},
});
try {
for await (const chunk of result.stream) {
yield redactor(chunk);
}
streamSpan.setStatus({ code: SpanStatusCode.OK });
}
catch (err) {
streamSpan.recordException(err instanceof Error ? err : new Error(String(err)));
streamSpan.setStatus({
code: SpanStatusCode.ERROR,
message: err instanceof Error ? err.message : String(err),
});
throw err;
}
finally {
streamSpan.end();
}
}
return redactedStream();
},
description: "Stream agent response",
tags: ["agent", "streaming"],
},
{
method: "GET",
path: `${basePath}/agent/providers`,
handler: async () => {
// Get available providers dynamically from ProviderFactory
const providers = ProviderFactory.getAvailableProviders();
return {
providers,
total: providers.length,
};
},
description: "List available AI providers",
tags: ["agent", "providers"],
},
{
method: "POST",
path: `${basePath}/agent/embed`,
handler: async (ctx) => {
const validation = validateRequest(EmbedRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const request = validation.data;
try {
const providerName = request.provider || "openai";
return await withSpan({
name: "neurolink.http.embed",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/embed",
"ai.provider": providerName,
"ai.model": request.model || "default",
},
}, async () => {
const provider = await ProviderFactory.createProvider(providerName, request.model);
const embedding = await provider.embed(request.text, request.model);
return {
embedding,
provider: providerName,
model: request.model || "default",
dimension: embedding.length,
};
});
}
catch (error) {
return createError("EXECUTION_FAILED", error instanceof Error
? error.message
: "Embedding generation failed", undefined, ctx.requestId);
}
},
description: "Generate embedding for a single text",
tags: ["agent", "embeddings"],
},
{
method: "POST",
path: `${basePath}/agent/embed-many`,
handler: async (ctx) => {
const validation = validateRequest(EmbedManyRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const request = validation.data;
try {
const providerName = request.provider || "openai";
return await withSpan({
name: "neurolink.http.embedMany",
tracer: tracers.http,
attributes: {
"http.route": "/api/agent/embed-many",
"ai.provider": providerName,
"ai.model": request.model || "default",
"ai.embed.count": request.texts.length,
},
}, async () => {
const provider = await ProviderFactory.createProvider(providerName, request.model);
const embeddings = await provider.embedMany(request.texts, request.model);
return {
embeddings,
provider: providerName,
model: request.model || "default",
count: embeddings.length,
dimension: embeddings[0]?.length ?? 0,
};
});
}
catch (error) {
return createError("EXECUTION_FAILED", error instanceof Error
? error.message
: "Batch embedding generation failed", undefined, ctx.requestId);
}
},
description: "Generate embeddings for multiple texts in a batch",
tags: ["agent", "embeddings"],
},
{
method: "GET",
path: `${basePath}/agent/skills`,
handler: async (ctx) => {
const manager = resolveSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
try {
const skills = await manager.list(ctx.query.scopeId);
return { skills, count: skills.length };
}
catch (error) {
return createError("EXECUTION_FAILED", error instanceof Error ? error.message : "Skills listing failed", undefined, ctx.requestId);
}
},
description: "List active skills (index only — no instructions). Optional ?scopeId= filter.",
tags: ["agent", "skills"],
},
{
method: "GET",
path: `${basePath}/agent/skills/:id`,
handler: async (ctx) => {
const manager = resolveSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
try {
const skill = await manager.get(ctx.params.id);
if (!skill) {
return createError("NOT_FOUND", `Skill "${ctx.params.id}" not found`, undefined, ctx.requestId);
}
return skill;
}
catch (error) {
return createError("EXECUTION_FAILED", error instanceof Error ? error.message : "Skill lookup failed", undefined, ctx.requestId);
}
},
description: "Fetch one skill (by id or exact name) with instructions",
tags: ["agent", "skills"],
},
{
method: "POST",
path: `${basePath}/agent/skills`,
handler: async (ctx) => {
const manager = resolveMutableSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
const validation = validateRequest(SkillCreateRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const { requestedBy, ...skill } = validation.data;
try {
const result = await manager.requestMutation({
type: "create",
skill,
// Authenticated identity wins over caller-supplied attribution.
...(ctx.user?.id || requestedBy
? { requestedBy: ctx.user?.id ?? requestedBy }
: {}),
});
return result;
}
catch (error) {
return createError("EXECUTION_FAILED", error instanceof Error ? error.message : "Skill create failed", undefined, ctx.requestId);
}
},
description: "Create a skill (routed through the host's onMutationRequest gate when configured)",
tags: ["agent", "skills"],
},
{
method: "PATCH",
path: `${basePath}/agent/skills/:id`,
handler: async (ctx) => {
const manager = resolveMutableSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
const validation = validateRequest(SkillUpdateRequestSchema, ctx.body, ctx.requestId);
if (!validation.success) {
return validation.error;
}
const { requestedBy, ...patch } = validation.data;
try {
const result = await manager.requestMutation({
type: "update",
skillId: ctx.params.id,
patch,
...(ctx.user?.id || requestedBy
? { requestedBy: ctx.user?.id ?? requestedBy }
: {}),
});
return result;
}
catch (error) {
const message = error instanceof Error ? error.message : "Skill update failed";
return createError(message.includes("not found") ? "NOT_FOUND" : "EXECUTION_FAILED", message, undefined, ctx.requestId);
}
},
description: "Update a skill (patch semantics; version is bumped)",
tags: ["agent", "skills"],
},
{
method: "DELETE",
path: `${basePath}/agent/skills/:id`,
handler: async (ctx) => {
const manager = resolveMutableSkillsManager(ctx);
if ("error" in manager) {
return manager;
}
try {
const result = await manager.requestMutation({
type: "delete",
skillId: ctx.params.id,
...(ctx.user?.id ? { requestedBy: ctx.user.id } : {}),
});
return result;
}
catch (error) {
const message = error instanceof Error ? error.message : "Skill delete failed";
return createError(message.includes("not found") ? "NOT_FOUND" : "EXECUTION_FAILED", message, undefined, ctx.requestId);
}
},
description: "Soft-delete (deprecate) a skill",
tags: ["agent", "skills"],
},
],
};
}