@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
433 lines (432 loc) • 16.6 kB
JavaScript
/**
* Agent - Core agent implementation for NeuroLink
*
* An Agent wraps a NeuroLink instance with specialized behavior, instructions,
* and tool restrictions. Agents can be composed into networks for multi-agent
* orchestration using the agents-as-tools pattern.
*/
import { EventEmitter } from "events";
import { ErrorFactory } from "../utils/errorHandling.js";
import { logger } from "../utils/logger.js";
/**
* Agent - Wraps a NeuroLink instance with specialized behavior
*
* Features:
* - Custom instructions and persona
* - Tool restrictions per agent (via toolFilter on generate/stream)
* - Input/output schema validation
* - Streaming support
* - Execution metrics tracking
*
* @example
* ```typescript
* const agent = new Agent({
* id: 'researcher',
* name: 'Research Agent',
* description: 'Searches and analyzes information',
* instructions: 'You are a research assistant...',
* tools: ['websearchGrounding', 'readFile'],
* }, neurolink);
*
* const result = await agent.execute('Find information about quantum computing');
* ```
*/
export class Agent {
id;
name;
description;
instructions;
provider;
model;
tools;
inputSchema;
outputSchema;
maxSteps;
temperature;
canDelegate;
metadata;
neurolink;
emitter;
executionCount = 0;
lastExecutionTime;
totalExecutionTime = 0;
constructor(definition, neurolink) {
// Validate required fields
if (!definition.id || typeof definition.id !== "string") {
throw ErrorFactory.invalidConfiguration("AgentDefinition.id", "Agent definition must have a valid id");
}
if (!definition.name || typeof definition.name !== "string") {
throw ErrorFactory.invalidConfiguration("AgentDefinition.name", "Agent definition must have a valid name");
}
if (!definition.description || typeof definition.description !== "string") {
throw ErrorFactory.invalidConfiguration("AgentDefinition.description", "Agent definition must have a valid description");
}
if (!definition.instructions ||
typeof definition.instructions !== "string") {
throw ErrorFactory.invalidConfiguration("AgentDefinition.instructions", "Agent definition must have valid instructions");
}
this.id = definition.id;
this.name = definition.name;
this.description = definition.description;
this.instructions = definition.instructions;
this.provider = definition.provider;
this.model = definition.model;
this.tools = definition.tools;
this.inputSchema = definition.inputSchema;
this.outputSchema = definition.outputSchema;
this.maxSteps = definition.maxSteps ?? 10;
this.temperature = definition.temperature ?? 0.7;
this.canDelegate = definition.canDelegate ?? false;
this.metadata = definition.metadata;
this.neurolink = neurolink;
this.emitter = new EventEmitter();
logger.debug(`[Agent:${this.id}] Created agent: ${this.name}`, {
tools: this.tools?.length || 0,
maxSteps: this.maxSteps,
canDelegate: this.canDelegate,
});
}
/**
* Execute the agent with given input
*
* @param input - Text input or structured data
* @param options - Execution options
* @returns Agent result with content and metadata
*/
async execute(input, options) {
const startTime = Date.now();
this.executionCount++;
const traceId = options?.traceId ?? `agent-${this.id}-${Date.now()}`;
logger.debug(`[Agent:${this.id}] Starting execution`, {
traceId,
input: typeof input === "string" ? input.slice(0, 100) : "structured",
executionCount: this.executionCount,
});
this.emitter.emit("agent:start", {
agentId: this.id,
traceId,
timestamp: startTime,
});
try {
// No ghost runs: an already-aborted parent stops the agent before any
// model call is made. Typed so callers can branch on the ABORT
// category instead of parsing free text.
if (options?.abortSignal?.aborted) {
throw ErrorFactory.aborted(new Error("Agent execution aborted before start"));
}
// Validate input if schema provided
if (this.inputSchema && typeof input !== "string") {
const validation = this.inputSchema.safeParse(input);
if (!validation.success) {
throw new Error(`Input validation failed: ${validation.error.message}`);
}
}
// Build the prompt with agent context
const prompt = this.buildPrompt(input, options?.context);
// Build generation options
const generateOptions = this.buildGenerateOptions(prompt, options, traceId);
// Execute via NeuroLink
const result = await this.neurolink.generate(generateOptions);
const duration = Date.now() - startTime;
this.lastExecutionTime = duration;
this.totalExecutionTime += duration;
// Parse output if schema provided
let parsedOutput;
if (this.outputSchema && result.content) {
try {
const parsed = JSON.parse(result.content);
const validation = this.outputSchema.safeParse(parsed);
if (validation.success) {
parsedOutput = validation.data;
}
else {
logger.warn(`[Agent:${this.id}] Output schema validation failed`, {
error: validation.error.message,
});
}
}
catch {
logger.warn(`[Agent:${this.id}] Failed to parse output as JSON`);
}
}
logger.debug(`[Agent:${this.id}] Execution completed`, {
traceId,
duration,
contentLength: result.content?.length || 0,
toolsUsed: result.toolsUsed?.length || 0,
});
const agentResult = {
content: result.content || "",
object: parsedOutput,
usage: result.usage,
toolsUsed: result.toolsUsed,
// Real per-call records straight from the tool loop (N2)
toolExecutions: result.toolExecutions,
stopReason: result.stopReason,
duration,
status: "success",
agentId: this.id,
};
this.emitter.emit("agent:complete", {
agentId: this.id,
traceId,
duration,
result: agentResult,
});
return agentResult;
}
catch (error) {
const duration = Date.now() - startTime;
this.lastExecutionTime = duration;
logger.error(`[Agent:${this.id}] Execution failed`, {
traceId,
error: error instanceof Error ? error.message : String(error),
duration,
});
this.emitter.emit("agent:error", {
agentId: this.id,
traceId,
error,
duration,
});
return {
content: "",
error: error instanceof Error ? error.message : String(error),
duration,
status: "error",
agentId: this.id,
};
}
}
/**
* Stream execution results
*
* @param input - Text input or structured data
* @param options - Execution options
* @yields Agent stream chunks
*/
async *stream(input, options) {
const startTime = Date.now();
const traceId = options?.traceId ?? `agent-${this.id}-${Date.now()}`;
this.emitter.emit("agent:start", {
agentId: this.id,
traceId,
timestamp: startTime,
});
yield {
type: "agent-start",
agentId: this.id,
timestamp: startTime,
traceId,
};
try {
// Mirror execute(): an already-aborted parent never starts the stream.
if (options?.abortSignal?.aborted) {
throw ErrorFactory.aborted(new Error("Agent stream aborted before start"));
}
// Validate input if schema provided
if (this.inputSchema && typeof input !== "string") {
const validation = this.inputSchema.safeParse(input);
if (!validation.success) {
throw new Error(`Input validation failed: ${validation.error.message}`);
}
}
const prompt = this.buildPrompt(input, options?.context);
const streamOptions = this.buildStreamOptions(prompt, options, traceId);
// Execute via NeuroLink
const streamResult = await this.neurolink.stream(streamOptions);
let fullContent = "";
for await (const chunk of streamResult.stream) {
// Handle different chunk types from the stream
if ("content" in chunk && typeof chunk.content === "string") {
fullContent += chunk.content;
yield {
type: "agent-text",
agentId: this.id,
content: chunk.content,
isPartial: true,
timestamp: Date.now(),
traceId,
};
}
// Handle tool calls if present
if ("toolCall" in chunk && chunk.toolCall) {
const toolCall = chunk.toolCall;
yield {
type: "agent-tool-call",
agentId: this.id,
toolName: toolCall.toolName || "unknown",
args: toolCall.args,
toolCallId: toolCall.toolCallId || `tool-${Date.now()}`,
timestamp: Date.now(),
traceId,
};
}
// Handle tool results if present
if ("toolResult" in chunk && chunk.toolResult) {
const toolResult = chunk.toolResult;
yield {
type: "agent-tool-result",
agentId: this.id,
toolName: toolResult.toolName || "unknown",
toolCallId: toolResult.toolCallId || `tool-${Date.now()}`,
result: toolResult.result,
success: toolResult.success ?? true,
timestamp: Date.now(),
traceId,
};
}
}
const duration = Date.now() - startTime;
this.lastExecutionTime = duration;
this.executionCount++;
this.totalExecutionTime += duration;
this.emitter.emit("agent:complete", {
agentId: this.id,
traceId,
duration,
content: fullContent,
});
yield {
type: "agent-complete",
agentId: this.id,
content: fullContent,
usage: streamResult.usage,
duration,
timestamp: Date.now(),
traceId,
};
}
catch (error) {
this.emitter.emit("agent:error", {
agentId: this.id,
traceId,
error,
});
yield {
type: "agent-error",
agentId: this.id,
error: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
traceId,
};
}
}
/**
* Get agent status
*/
getStatus() {
return {
id: this.id,
name: this.name,
executionCount: this.executionCount,
lastExecutionTime: this.lastExecutionTime,
available: true,
};
}
/**
* Get average execution time
*/
getAverageExecutionTime() {
if (this.executionCount === 0) {
return 0;
}
return this.totalExecutionTime / this.executionCount;
}
/**
* Subscribe to agent events
*/
on(event, handler) {
this.emitter.on(event, handler);
}
/**
* Unsubscribe from agent events
*/
off(event, handler) {
this.emitter.off(event, handler);
}
/**
* Build prompt from input and context
*/
buildPrompt(input, context) {
let prompt = typeof input === "string" ? input : JSON.stringify(input);
if (context && Object.keys(context).length > 0) {
prompt = `Context: ${JSON.stringify(context)}\n\nTask: ${prompt}`;
}
return prompt;
}
/**
* Build generation options for NeuroLink.generate()
*
* Uses toolFilter to delegate tool restriction to BaseProvider.applyToolFiltering()
* rather than pre-filtering tools manually.
*/
buildGenerateOptions(prompt, options, traceId) {
return {
input: { text: prompt },
provider: this.provider,
model: this.model,
temperature: this.temperature,
systemPrompt: this.instructions,
// toolFilter delegates to BaseProvider.applyToolFiltering() natively
...(this.tools && this.tools.length > 0 && { toolFilter: this.tools }),
maxSteps: options?.maxSteps ?? this.maxSteps,
requestId: traceId,
// Turn budget + cancellation, forwarded verbatim to generate() — the
// machinery (wrap-up nudge, stall watchdog, honest stopReason) lives
// in the tool loop; the agent layer just passes it through.
...(options?.abortSignal && { abortSignal: options.abortSignal }),
...(options?.timeout !== undefined && { timeout: options.timeout }),
...(options?.turnTimeoutMs !== undefined && {
turnTimeoutMs: options.turnTimeoutMs,
}),
...(options?.wrapupTimeLeadMs !== undefined && {
wrapupTimeLeadMs: options.wrapupTimeLeadMs,
}),
...(options?.stallTimeoutMs !== undefined && {
stallTimeoutMs: options.stallTimeoutMs,
}),
context: {
agentId: this.id,
agentName: this.name,
...options?.context,
},
};
}
/**
* Build stream options for NeuroLink.stream()
*
* Uses toolFilter to delegate tool restriction to BaseProvider.applyToolFiltering()
* rather than pre-filtering tools manually.
*/
buildStreamOptions(prompt, options, traceId) {
return {
input: { text: prompt },
provider: this.provider,
model: this.model,
temperature: this.temperature,
systemPrompt: this.instructions,
// toolFilter delegates to BaseProvider.applyToolFiltering() natively
...(this.tools && this.tools.length > 0 && { toolFilter: this.tools }),
maxSteps: options?.maxSteps ?? this.maxSteps,
// Turn budget + cancellation, forwarded verbatim to stream()
...(options?.abortSignal && { abortSignal: options.abortSignal }),
...(options?.timeout !== undefined && { timeout: options.timeout }),
...(options?.turnTimeoutMs !== undefined && {
turnTimeoutMs: options.turnTimeoutMs,
}),
...(options?.wrapupTimeLeadMs !== undefined && {
wrapupTimeLeadMs: options.wrapupTimeLeadMs,
}),
...(options?.stallTimeoutMs !== undefined && {
stallTimeoutMs: options.stallTimeoutMs,
}),
context: {
agentId: this.id,
agentName: this.name,
traceId,
...options?.context,
},
};
}
}