Skip to content

Latest commit

 

History

History
723 lines (567 loc) · 25.9 KB

File metadata and controls

723 lines (567 loc) · 25.9 KB

@aeye/core

Core primitives for building AI agents, tools, and prompts with TypeScript. Provides a type-safe, composable framework for creating sophisticated AI applications with structured inputs/outputs, tool calling, and context management.

Features

  • 🎯 Type-Safe Components - Prompts, Tools, and Agents with full TypeScript support
  • 🔧 Tool Calling - Native support for function/tool calling with schema validation
  • 📝 Template-Based Prompts - Handlebars templates for dynamic prompt generation
  • ✅ Schema Validation - Zod integration for structured inputs and outputs
  • 🌊 Streaming Support - First-class streaming for real-time AI responses
  • 🔄 Composable Architecture - Tools can use other tools, prompts can use tools, agents orchestrate everything
  • 📊 Context Management - Type-safe context threading and automatic token window management
  • 🎛️ Flexible Execution - Sequential, parallel, or immediate tool execution modes

Installation

npm install @aeye/core zod handlebars

Note: @aeye/core defines the primitives — you need an executor/streamer to run prompts. These are provided by @aeye/ai via ai.buildCoreContext(), or you can supply your own execute / stream functions.

Core Concepts

Components

All AI primitives implement the Component interface:

  • Prompt - Generates AI responses with optional tool usage and structured outputs
  • Tool - Extends AI capabilities with custom functions and external integrations
  • Agent - Orchestrates complex workflows combining prompts and tools

Context & Metadata

  • Context (TContext) - Application-specific data threaded through operations (user, db, etc.)
  • Metadata (TMetadata) - Execution settings for AI requests (model, temperature, etc.)

Quick Start

Basic Prompt

import { Prompt } from '@aeye/core';
import z from 'zod';

const summarizer = new Prompt({
  name: 'summarize',
  description: 'Summarizes text concisely',
  content: 'Summarize the following text:\n\n{{text}}',

  // Transform raw input into template variables
  input: (input: { text: string }) => ({ text: input.text }),

  // Define output schema (structured JSON)
  schema: z.object({
    summary: z.string().describe('A concise summary'),
    keyPoints: z.array(z.string()).describe('Main points'),
  }),
});

// Execute — ctx must provide execute or stream (from @aeye/ai or custom)
const result = await summarizer.get(
  'result',
  { text: 'Long article text...' },
  {
    execute: yourAIExecutor,  // (request, ctx, metadata?, signal?) => Promise<Response>
    messages: [],
  }
);

console.log(result?.summary);
console.log(result?.keyPoints);

Creating Tools

import { Tool } from '@aeye/core';
import z from 'zod';

const getWeather = new Tool({
  name: 'getWeather',
  description: 'Get current weather for a location',
  instructions: 'Use this tool to get weather data for {{location}}.',

  schema: z.object({
    location: z.string().describe('City name or coordinates'),
    units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
  }),

  call: async (input, _refs, _ctx) => {
    const response = await fetch(
      `https://api.weather.com/v1/${encodeURIComponent(input.location)}`
    );
    const data = await response.json();
    return {
      temperature: data.temp,
      condition: data.condition,
      humidity: data.humidity,
    };
  },
});

Prompts with Tools

import { Prompt } from '@aeye/core';
import z from 'zod';

const travelAdvisor = new Prompt({
  name: 'travelAdvisor',
  description: 'Provides travel advice based on weather',
  content: `You are a travel advisor. Help plan a trip to {{destination}}.

Use the getWeather tool to check current conditions, then provide:
- What to pack
- Recommended activities
- Best times to visit`,

  input: (input: { destination: string }) => ({
    destination: input.destination,
  }),

  tools: [getWeather],

  schema: z.object({
    recommendations: z.array(z.string()),
    packingList: z.array(z.string()),
    weatherNotes: z.string(),
  }),
});

// The AI will automatically call getWeather if needed
const advice = await travelAdvisor.get(
  'result',
  { destination: 'Paris' },
  { execute: yourAIExecutor, messages: [] }
);

console.log(advice?.recommendations);

Streaming Responses

// Stream text content only
for await (const chunk of summarizer.get(
  'streamContent',
  { text: 'Long text...' },
  { stream: yourAIStreamer, messages: [] }
)) {
  process.stdout.write(chunk);
}

// Stream all events (including tool calls)
for await (const event of summarizer.get(
  'stream',
  { text: 'Long text...' },
  { stream: yourAIStreamer, messages: [] }
)) {
  if (event.type === 'textPartial') {
    process.stdout.write(event.content);
  } else if (event.type === 'toolStart') {
    console.log('Tool started:', event.tool.name);
  } else if (event.type === 'toolOutput') {
    console.log('Tool result:', event.result);
  } else if (event.type === 'complete') {
    console.log('Final output:', event.output);
  }
}

Building Agents

import { Agent } from '@aeye/core';
import z from 'zod';

// Assume searchTool, summarizePrompt, and analyzePrompt are defined elsewhere
const researchAgent = new Agent({
  name: 'researcher',
  description: 'Conducts research on a topic',

  refs: [searchTool, summarizePrompt, analyzePrompt] as const,

  call: async (
    input: { topic: string },
    [search, summarize, analyze],
    ctx
  ) => {
    // Step 1: Search for information
    const searchResults = await search.run({ query: input.topic, limit: 5 }, ctx);

    // Step 2: Summarize each result
    const summaries: string[] = [];
    for (const result of searchResults.items) {
      const summary = await summarize.get('result', { text: result.content }, ctx);
      summaries.push(summary?.summary ?? '');
    }

    // Step 3: Synthesize into a final analysis
    const analysis = await analyze.get(
      'result',
      { topic: input.topic, sources: summaries },
      ctx
    );

    return analysis;
  },
});

const research = await researchAgent.run(
  { topic: 'Quantum Computing' },
  { execute: yourAIExecutor, messages: [] }
);

Prompt Modes

The get(mode, input?, ctx?) method supports different execution modes:

Mode Description Returns
'result' Await the final structured output Promise<TOutput | undefined>
'tools' Await all tool call results Promise<PromptToolOutput[] | undefined>
'stream' Stream all events AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined>
'streamTools' Stream tool output events AsyncGenerator<PromptToolOutput<TTools>, TOutput | undefined>
'streamContent' Stream text content only AsyncGenerator<string, TOutput | undefined>
// Get structured result
const result = await prompt.get('result', input, ctx);

// Get tool outputs only
const tools = await prompt.get('tools', input, ctx);

// Stream everything
for await (const event of prompt.get('stream', input, ctx)) {
  // event.type is one of: 'request', 'textPartial', 'text', 'toolStart',
  //   'toolOutput', 'toolError', 'message', 'complete', 'usage', ...
}

// Stream text only
for await (const text of prompt.get('streamContent', input, ctx)) {
  process.stdout.write(text);
}

Tool Execution Modes

Control how tools are executed by the prompt:

const prompt = new Prompt({
  name: 'multi-tool',
  description: 'Uses multiple tools',
  content: 'Analyze this data using the available tools.',
  tools: [tool1, tool2, tool3],

  // 'sequential' | 'parallel' | 'immediate' (default: 'immediate')
  toolExecution: 'parallel',

  // Number of times to retry a failed tool call (default: 2)
  toolRetries: 2,
  // Max tool-calling iterations per request (default: 3)
  toolIterations: 3,
  // Max total successful tool calls before stopping (no strict limit by default)
  toolsMax: 5,
});
  • sequential - Wait for each tool to complete before starting the next
  • parallel - Start all pending tools at once and wait for all to complete
  • immediate - Start each tool as soon as its call is received (default)

Advanced Features

Context-Aware Tools

Tool schemas and applicability can depend on the current context:

interface MyContext {
  userRole: 'admin' | 'viewer';
  isAuthenticated: boolean;
}

const contextTool = new Tool({
  name: 'manageData',
  description: 'Manage data records',
  instructions: 'Use this tool to read or write data.',

  // Schema varies based on the caller's role
  schema: (ctx: Context<MyContext, {}>) => {
    if (ctx.userRole === 'admin') {
      return z.object({ action: z.enum(['read', 'write', 'delete']) });
    }
    return z.object({ action: z.enum(['read']) });
  },

  // Tool is only available to authenticated users
  applicable: (ctx: Context<MyContext, {}>) => ctx.isAuthenticated,

  call: async (input, _refs, ctx) => {
    return { action: input.action, executedBy: ctx.userRole };
  },
});

Custom Validation

Add business-logic validation beyond Zod schema checks:

const placeOrder = new Tool({
  name: 'placeOrder',
  description: 'Place a product order',

  schema: z.object({
    itemId: z.string(),
    quantity: z.number().int().min(1),
  }),

  // Runs after schema parsing succeeds; throw to trigger re-prompting
  validate: async (input, ctx) => {
    const inventory = await checkInventory(input.itemId);
    if (inventory < input.quantity) {
      throw new Error(`Only ${inventory} units of ${input.itemId} are available.`);
    }
  },

  call: async (input, _refs, _ctx) => {
    return { orderId: crypto.randomUUID(), ...input };
  },
});

Event Tracking with withEvents

import { withEvents } from '@aeye/core';

const runner = withEvents({
  onStatus: (instance) => {
    console.log(`${instance.component.name}: ${instance.status}`);
    if (instance.status === 'completed' && instance.completed && instance.running) {
      console.log(`  Took ${instance.completed - instance.running}ms`);
    }
  },

  onPromptEvent: (_instance, event) => {
    if (event.type === 'usage') {
      console.log('Tokens used:', event.usage);
    }
  },
});

const result = await prompt.get(
  'result',
  { text: 'Hello' },
  { execute: yourAIExecutor, messages: [], runner }
);

Token Management

The prompt automatically trims old messages when the context window is full:

const result = await prompt.get(
  'result',
  { text: 'Query' },
  {
    execute: yourAIExecutor,
    messages: conversationHistory,  // Will be trimmed if needed

    // Reserve this many tokens for the model's response
    maxOutputTokens: 2048,

    // Custom per-message token estimator
    estimateUsage: (message) => ({
      text: { input: Math.ceil((message.content as string).length / 4) }
    }),
  }
);

Dynamic Reconfiguration

Use reconfig to adapt the prompt based on runtime statistics:

const adaptivePrompt = new Prompt({
  name: 'adaptive',
  description: 'Adapts based on runtime stats',
  content: 'Solve: {{problem}}',
  schema: z.object({ solution: z.string() }),

  reconfig: (stats, _ctx) => {
    // If many tool errors occurred, switch to one-at-a-time mode
    if (stats.toolCallErrors > 3) {
      return { config: { toolsOneAtATime: true } };
    }
    return {};
  },
});

Model Capabilities

@aeye uses a capability system for automatic model selection. Models advertise their capabilities and the library selects the best match for each request:

Capability Description
chat Basic text completion
streaming Real-time response streaming
image Image generation
vision Image understanding
audio Text-to-speech
hearing Speech-to-text
embedding Text embeddings
tools Function/tool calling
json JSON output mode
structured Structured outputs with schemas
toolsStrict Strict-mode tool input schemas (auto-derived; see Strict Mode)
reasoning Extended chain-of-thought reasoning
zdr Zero data retention

Strict Mode

Each LLM provider that supports strict mode does so with its own JSON Schema dialect. @aeye/core reconciles them under a single FormatDescriptor so a Zod schema authored once works against any of them.

strict on Tool / Prompt

Tool.strict and Prompt.strict are boolean | number:

Value Meaning
true Require strict. @aeye/ai filters selection down to models that declare strict support; the request fails if none qualify.
false Force lenient. Standard JSON Schema; no strict: true on the wire.
number > 0 (default 1) Prefer strict, accept fallback. Higher numbers win when there are more strict-requesting items than the chosen model's per-request budget allows.
omitted Treated as 1.
new Tool({ name: 'must',     /* default 1 */                schema: z.object({...}) }); // best-effort
new Tool({ name: 'critical', strict: true,                  schema: z.object({...}) }); // hard require
new Tool({ name: 'meh',      strict: false,                 schema: z.object({...}) }); // never strict
new Tool({ name: 'top',      strict: 100,                   schema: z.object({...}) }); // high priority

Default 1 keeps "it just works" against unannotated models — strict where the chosen model supports it, lenient elsewhere. Set true only when strict is non-negotiable.

Native format descriptors

Seven descriptors ship out of the box:

Descriptor Family / Strict Notes
OPENAI_STRICT openai / strict Records → array-of-pairs, tuples → numeric-key objects, optional → T | null, closed objects, restricted format whitelist.
ANTHROPIC_STRICT anthropic / strict Closed objects, no recursion, no length / numeric range constraints, per-request slot budgets (20 strict tools / 24 optional params / 16 union types).
GOOGLE_STRICT google / strict prefixItems, $ref: '#' recursion, propertyOrdering emitted, restricted format whitelist.
LENIENT lenient / non-strict No rewrites, all features pass through. Default for unannotated models.
OPENAI_NON_STRICT, ANTHROPIC_NON_STRICT, GOOGLE_NON_STRICT family / non-strict Aliased to LENIENT but tagged with the family for diagnostics.
import { OPENAI_STRICT, ANTHROPIC_STRICT, getDescriptor, toJSONSchema } from '@aeye/core';

const schema = z.object({
  name: z.string(),
  tags: z.record(z.string(), z.string()),
});

// Same Zod, different wire shapes:
toJSONSchema(schema, OPENAI_STRICT);                    // tags → array of {key, value}
toJSONSchema(schema, ANTHROPIC_STRICT);                 // tags → array of {key, value} (open records unsupported)
toJSONSchema(schema, getDescriptor('google', true));    // tags → standard additionalProperties

// Backward-compat overloads still work:
toJSONSchema(schema, true);   // === OPENAI_STRICT
toJSONSchema(schema, false);  // === LENIENT

Registering a custom descriptor

You can ship your own descriptor for a provider or dialect that isn't supported natively. Define it as a FormatDescriptor value and register it with registerDescriptor(descriptor):

import { registerDescriptor, type FormatDescriptor, OPENAI_STRICT } from '@aeye/core';

const MY_PROVIDER_STRICT: FormatDescriptor = {
  ...OPENAI_STRICT,                   // start from a known good baseline
  id: 'my-provider-strict',
  family: 'openai',                   // or 'anthropic' | 'google' | 'lenient'
  // Override only the fields that differ from the baseline:
  recordEncoding: 'open-record',      // your provider supports additionalProperties: <schema>
  tupleEncoding: 'prefix-items',
  supportsRecursion: true,
};

registerDescriptor(MY_PROVIDER_STRICT);

Once registered:

  • Provider code can pass the descriptor to toJSONSchema(schema, MY_PROVIDER_STRICT) and strictify(schema, MY_PROVIDER_STRICT).
  • The Prompt's validation roundtrip (which looks up descriptors by id from the request) will resolve 'my-provider-strict' correctly.
  • A custom Provider implementation can return this descriptor from its getStrictFormat-equivalent helper.

The descriptor's full field list is exported as the FormatDescriptor interface — every flag is documented inline. Common adjustments:

Field Controls
objectAllFieldsRequired Whether every property must appear in required[].
objectClosedByDefault Whether every object emits additionalProperties: false.
recordEncoding 'array-of-pairs' vs 'open-record'.
tupleEncoding 'object-numeric-keys' vs 'prefix-items' vs 'items-union'.
allowAllOf / allowAnyOf / allowOneOf JSON-Schema combinator support.
supportedStringFormats 'all' or a Set<string> whitelist ('date-time', 'email', etc.).
optionalAsNullable Whether optional fields are emitted as T | null (OpenAI strict) vs omitted from required[].
supportsRecursion Whether $ref self-reference works under this dialect.
maxStrictTools / maxStrictOptionalParams / maxStrictUnionTypes Per-request budget caps; undefined means no documented limit.

You don't need to set every field — spread an existing descriptor and override deltas. Tested via analyzeSchema + the schema test suite; see the strict-mode guide in @aeye/docs for end-to-end examples.

API Reference

Prompt

class Prompt<TContext, TMetadata, TName extends string, TInput extends object, TOutput extends object | string, TTools extends Tuple<ToolCompatible<TContext, TMetadata>>, TDecoded extends unknown = TOutput>

TDecoded is the type a custom parse produces from the model's structured output (e.g. a built AST/class instance); it types validate and the decoded output. It defaults to TOutput (the wire type) when no custom parse is supplied — schema always stays the wire type.

Constructor Options (PromptInput):

  • name: string - Unique identifier
  • description: string - Purpose description
  • content: string - Handlebars template for the system/user message
  • input?: (input: TInput, ctx: Context<TContext, TMetadata>) => Record<string, any> - Maps raw input to template variables
  • schema?: ZodType<TOutput> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => ZodType<TOutput> | false) - Output schema; false disables structured output
  • strict?: boolean - Enforce strict Zod schema (default: true)
  • config?: Partial<Request> | ((input: TInput | undefined, ctx: Context<TContext, TMetadata>) => Partial<Request> | false) - AI request overrides
  • tools?: TTools - Tools the prompt may call
  • toolExecution?: 'sequential' | 'parallel' | 'immediate' - Tool execution mode
  • toolRetries?: number - Retry count for failed tools (default: 2)
  • toolIterations?: number - Max tool-calling iterations (default: 3)
  • toolsMax?: number - Max successful tool calls
  • outputRetries?: number - Retry count for invalid structured outputs
  • metadata?: TMetadata - Static metadata for this prompt
  • validate?: (output: TOutput, ctx: Context<TContext, TMetadata>) => void | Promise<void> - Post-parse validation
  • applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean> - Availability check

Methods:

  • get(mode: PromptGetType, input?: TInput, ctx?: Context<TContext, TMetadata>): PromptGet<...> - Execute and return output in the specified mode
  • run(input?: TInput, ctx?: Context<TContext, TMetadata>): AsyncGenerator<PromptEvent<TOutput, TTools>, TOutput | undefined> - Full streaming generator
  • applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean> - Check if prompt is usable

Tool

class Tool<TContext, TMetadata, TName extends string, TParams extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>, TDecoded extends unknown = TParams>

TDecoded is the type a custom parse produces from the raw wire args (e.g. a built class instance); it types call, validate, and metadataFn. It defaults to TParams (the wire type) when no custom parse is supplied — schema always stays the wire type.

Constructor Options (ToolInput):

  • name: string - Unique identifier (shown to the AI model)
  • description: string - What the tool does (shown to the AI model)
  • instructions?: string - Handlebars template for usage instructions
  • schema: ZodType<TParams> | ((ctx: Context<TContext, TMetadata>) => ZodType<TParams> | undefined) - Input parameter schema
  • strict?: boolean - Enforce strict Zod schema (default: true)
  • refs?: TRefs - Referenced components
  • call: (input: TParams, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput - Implementation
  • validate?: (input: TParams, ctx: Context<TContext, TMetadata>) => void | Promise<void> - Post-parse validation
  • applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean> - Availability check

Methods:

  • run(input?: TParams, ctx?: Context<TContext, TMetadata>): TOutput - Execute the tool directly
  • compile(ctx: Context<TContext, TMetadata>): Promise<readonly [string, ToolDefinition] | undefined> - Generate AI tool definition
  • applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean> - Check if tool is usable

Agent

class Agent<TContext, TMetadata, TName extends string, TInput extends object, TOutput, TRefs extends Tuple<ComponentCompatible<TContext, TMetadata>>>

Constructor Options (AgentInput):

  • name: string - Unique identifier
  • description: string - Purpose description
  • refs: TRefs - Components this agent uses
  • call: (input: TInput, refs: TRefs, ctx: Context<TContext, TMetadata>) => TOutput - Implementation
  • applicable?: (ctx: Context<TContext, TMetadata>) => boolean | Promise<boolean> - Availability check

Methods:

  • run(input?: TInput, ctx?: Context<TContext, TMetadata>): TOutput - Execute the agent
  • applicable(ctx?: Context<TContext, TMetadata>): Promise<boolean> - Check if agent is usable

Context Structure

The Context<TContext, TMetadata> type combines your custom context with the core execution context:

// Core fields available in every context
interface CoreContextFields {
  // AI executor for non-streaming requests
  execute?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => Promise<Response>;
  // AI streamer for streaming requests
  stream?: (request: Request, ctx: TContext, metadata?: TMetadata, signal?: AbortSignal) => AsyncGenerator<Chunk, Response>;

  // Conversation history passed to the model
  messages?: Message[];

  // Reserve N tokens for the model's output (used for context trimming)
  maxOutputTokens?: number;
  // Override context window size (used for context trimming)
  contextWindow?: number;

  // Custom per-message usage estimator
  estimateUsage?: (message: Message) => Usage | undefined;

  // Number of output-format retries (default: 2)
  outputRetries?: number;
  // Number of context-trimming retries (default: 1)
  forgetRetries?: number;

  // Abort signal for cancellation
  signal?: AbortSignal;
  // Event runner for tracking component lifecycle
  runner?: Events;
}

Request Configuration

interface Request {
  messages: Message[];
  temperature?: number;       // 0.0–2.0
  topP?: number;              // 0.0–1.0
  maxTokens?: number;
  stop?: string | string[];
  tools?: ToolDefinition[];
  toolChoice?: 'auto' | 'required' | 'none' | { type: 'tool'; name: string };
  responseFormat?: 'text' | 'json' | { type: 'json_schema'; schema: object; name: string; strict?: boolean };
  reason?: { effort?: 'low' | 'medium' | 'high'; maxTokens?: number };
  frequencyPenalty?: number;
  presencePenalty?: number;
  seed?: number;
  // ... more options
}

Best Practices

  1. Type Safety - Use TypeScript generics for context and metadata
  2. Schema Validation - Use Zod for robust input/output validation
  3. Token Management - Supply estimateUsage for accurate context trimming
  4. Tool Organization - Group related tools and use agents to orchestrate them
  5. Testing - Unit test tools and prompts independently
  6. Context Minimization - Pass only necessary data in context
  7. Streaming - Use streaming for better UX with long responses
  8. Validation - Use validate hooks for business-logic checks after schema parsing

Examples

See the src/__tests__ directory for comprehensive examples:

  • prompt-core-features.test.ts - Basic prompt usage
  • prompt-streaming-tool-events.test.ts - Streaming and events
  • tool.test.ts - Tool creation and usage
  • agent.test.ts - Agent orchestration
  • context-propagation.test.ts - Context handling

TypeScript Support

Full type inference across component hierarchies:

import { Prompt, Tool } from '@aeye/core';
import z from 'zod';

// Output type is inferred from the schema
const prompt = new Prompt({
  name: 'example',
  description: 'Example prompt',
  content: 'Say hello to {{name}}.',
  input: (input: { name: string }) => ({ name: input.name }),
  schema: z.object({ greeting: z.string() }),
});

// result is typed as { greeting: string } | undefined
const result = await prompt.get('result', { name: 'World' }, ctx);
console.log(result?.greeting);

// Tool call parameters are type-safe
const mathTool = new Tool({
  name: 'add',
  description: 'Adds two numbers',
  schema: z.object({ a: z.number(), b: z.number() }),
  call: (input, _refs, _ctx) => {
    // input is typed as { a: number; b: number }
    return input.a + input.b;
  },
});

Contributing

Contributions are welcome! See the main @aeye repository for contribution guidelines.

License

GPL-3.0 © ClickerMonkey

Links