Skip to content

xhoantran/node-agent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@newwave/agent-core

AI agent framework for Node.js/TypeScript. Streaming LLM conversations with tool execution, hooks, state management, and prompt caching.

Built on the Anthropic SDK + Hono for SSE.

Package Structure

src/
  model/        AgentMessage, ContentBlock, ThinkingLevel, MessageRole
  event/        AgentEvent (12 types), TokenUsage
  tool/         AgentTool, AgentToolResult, ToolCallContext
  config/       AgentConfig, AgentLoopConfig, AgentHooks, HookContext
  core/         Agent, AgentLoop, PromptBuilder
  state/        ConversationStore, ConversationStateManager, Redis, DynamoDB
  compaction/   CompactionHook, LlmCompactionStrategy, TokenEstimator
  http/         Hono SSE handler

Quick Start

import { Agent, createAgentConfig, InMemoryConversationStore, userMessage } from "@newwave/agent-core";

const agent = new Agent(
  createAgentConfig({ systemPrompt: "You are a helpful assistant." }),
  new InMemoryConversationStore(),
);

for await (const event of agent.stream({
  agentId: "user-1",
  message: userMessage("Hello!"),
})) {
  if (event.type === "message_update") process.stdout.write(event.delta);
  if (event.type === "agent_end") console.log("\nTokens:", event.usage?.inputTokens, "/", event.usage?.outputTokens);
}

Tools

Define tools with Zod schemas for automatic validation and JSON Schema generation:

import { z } from "zod";
import type { AgentTool } from "@newwave/agent-core";
import { ToolResult } from "@newwave/agent-core";

const searchTool: AgentTool<{ query: string; limit?: number }> = {
  name: "search",
  label: "Search",
  description: "Search the knowledge base for relevant documents",
  schema: z.object({
    query: z.string().describe("The search query"),
    limit: z.number().optional().default(10).describe("Max results"),
  }),
  async execute(ctx) {
    const results = await searchKnowledgeBase(ctx.parameters.query, ctx.parameters.limit);
    return ToolResult.success(JSON.stringify(results));
  },
};

const agent = new Agent(
  createAgentConfig({ tools: [searchTool] }),
  store,
);

Tool Result Types

ToolResult.success("Found 3 results")           // normal result
ToolResult.success("data", { parsed: true })     // with typed details
ToolResult.error("API rate limited")             // error result
ToolResult.terminate("Question sent to user")    // stops the agent loop

excludeFromContext

Tools can opt out of being included in LLM context on subsequent turns:

const askUserTool: AgentTool = {
  name: "ask_user",
  description: "Ask the user a question",
  excludeFromContext: true,  // tool_use/tool_result stripped from future context
  schema: z.object({ question: z.string() }),
  async execute(ctx) {
    return ToolResult.terminate(ctx.parameters.question);
  },
};

Events

Agent.stream() returns an AsyncGenerator<AgentEvent>. Events match the SSE wire format:

Event Key Fields
agent_start
agent_end error?, usage? (TokenUsage)
turn_start turnNumber
turn_end turnNumber
message_start message
message_update delta (text chunk)
message_end message (full AgentMessage)
thinking_update delta (thinking chunk)
tool_execution_start toolUse
tool_execution_update toolUse, update
tool_execution_end toolUse, result
schedule_fired scheduleId, scheduleType

Token Usage

for await (const event of agent.stream(request)) {
  if (event.type === "agent_end" && event.usage) {
    console.log(event.usage.model);        // "claude-sonnet-4-6"
    console.log(event.usage.inputTokens);  // 1234
    console.log(event.usage.outputTokens); // 567
  }
}

Hooks

Customize agent behavior with lifecycle hooks:

import type { AgentHooks } from "@newwave/agent-core";

const hooks: AgentHooks = {
  async transformContext(ctx, messages) {
    // Inject workspace context before LLM sees messages
    return [userMessage("[Context]\n" + await loadContext(ctx.attributes.workspaceId)), ...messages];
  },

  async beforeToolCall(ctx, toolName, toolUse) {
    if (toolName === "delete_data") return { proceed: false, reason: "Blocked by policy" };
    return { proceed: true };
  },

  async afterToolCall(ctx, toolName, toolUse, result) {
    // Modify tool result before LLM sees it
    return result;
  },
};

const agent = new Agent(
  createAgentConfig({
    loopConfig: { maxTurns: 25, toolExecutionMode: "parallel", hooks, maxToolResultsInContext: 0 },
  }),
  store,
);

Use CompositeAgentHooks to chain multiple hooks:

import { CompositeAgentHooks } from "@newwave/agent-core";

const hooks = new CompositeAgentHooks([contextHook, policyHook, loggingHook]);

Hono SSE Handler

import { serve } from "@hono/node-server";
import { createStreamHandler } from "@newwave/agent-core";

const app = createStreamHandler(agent);
serve({ fetch: app.fetch, port: 3000 });

Endpoints:

  • POST /api/stream — SSE stream ({ agentId, conversationId?, message, attributes? })
  • POST /api/steer — inject message into running loop
  • POST /api/follow-up — queue message for after loop completes
  • POST /api/abort — request loop to stop
  • GET /api/status?agentId=...&conversationId=... — conversation status

State Management

Without a ConversationStateManager, the agent is stateless — each stream() call is independent. With one, you get conversation locking, steer, followUp, and abort.

Redis

import Redis from "ioredis";
import { RedisConversationStateManager } from "@newwave/agent-core";

const stateManager = new RedisConversationStateManager(new Redis());
const agent = new Agent(config, store, stateManager);

Key schema: agent:{agentId}:conv:{conversationId}:status|lock|followup|steer

DynamoDB

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDbConversationStateManager } from "@newwave/agent-core";

const stateManager = new DynamoDbConversationStateManager(
  new DynamoDBClient({}),
  "agent_conversation_state",
);
const agent = new Agent(config, store, stateManager);

Table schema:

PK: pk (S) — "agent:{agentId}:conv:{conversationId}"
SK: sk (S) — "status" | "followup:{timestamp}:{id}" | "steer:{timestamp}:{id}"

Behavior

  • stream() acquires a lock — if busy, the message is queued as a follow-up
  • After the inner loop completes, follow-ups are drained (outer loop)
  • Steering messages are appended before each LLM call
  • Abort is checked at each turn start

AgentConfig

createAgentConfig({
  systemPrompt: "You are a helpful assistant.",   // default
  model: "claude-sonnet-4-6",                     // default
  thinkingLevel: ThinkingLevel.OFF,               // OFF | LOW | MEDIUM | HIGH | XHIGH
  maxTokens: 8192,                                // default
  tools: [],
  loopConfig: {
    maxTurns: 25,
    toolExecutionMode: "parallel",                // "parallel" | "sequential"
    hooks: {},
    maxToolResultsInContext: 0,                    // 0 = unlimited
  },
});

Extended Thinking

import { ThinkingLevel } from "@newwave/agent-core";

const agent = new Agent(
  createAgentConfig({ thinkingLevel: ThinkingLevel.HIGH }),
  store,
);

for await (const event of agent.stream(request)) {
  if (event.type === "thinking_update") console.log("[thinking]", event.delta);
  if (event.type === "message_update") console.log(event.delta);
}

Prompt Caching

Enabled by default. The system prompt and last tool definition include cache_control: { type: "ephemeral" }, so Anthropic caches them across turns. Cached reads are 90% cheaper than uncached input tokens.

Compaction

Automatically summarize old messages when context exceeds a token threshold:

import { CompactionHook, LlmCompactionStrategy, SimpleTokenEstimator, defaultCompactionConfig } from "@newwave/agent-core";
import { CompositeAgentHooks } from "@newwave/agent-core";

const compactionHook = new CompactionHook(
  new LlmCompactionStrategy(new SimpleTokenEstimator()),
  defaultCompactionConfig(),
  new SimpleTokenEstimator(),
);

const agent = new Agent(
  createAgentConfig({
    loopConfig: { maxTurns: 25, toolExecutionMode: "parallel", hooks: compactionHook, maxToolResultsInContext: 0 },
  }),
  store,
);

ConversationStore

Implement the interface for your database:

interface ConversationStore {
  appendMessage(agentId: string, conversationId: string, message: AgentMessage): Promise<void>;
  loadMessages(agentId: string, conversationId: string): Promise<AgentMessage[]>;
  replaceMessages(agentId: string, conversationId: string, messages: AgentMessage[]): Promise<void>;
  deleteConversation(agentId: string, conversationId: string): Promise<void>;
  listConversationIds(agentId: string): Promise<string[]>;
}

InMemoryConversationStore is included for development/testing.

Message Reordering

toAnthropicMessages() handles edge cases automatically:

  • User messages interleaved between tool_use and tool_result → deferred after the pair
  • Orphaned tool_use without matching tool_result → stripped (pair matching)
  • excludeFromContext tools → stripped by tool name
  • maxToolResultsInContext → keeps only the last N tool pairs

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors