npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@isdk/tool-func

v2.0.0

Published

A manager for creating, registering, and executing reusable tool functions with rich metadata.

Readme

@isdk/tool-func

A powerful TypeScript framework for creating, managing, and executing modular tool functions. It's perfect for building AI agent tools, backend services, and extensible plugin systems with a clean, decoupled architecture.

✨ Core Features

  • 📦 Modular & Reusable Tools: Define functions as ToolFunc instances with rich metadata, maintaining a clear separation between static management and instance logic.
  • 🌐 Global Registry: The static registry (ToolFunc.items) allows any part of your application to access and run registered functions by name.
  • 🏛️ Hierarchical Registries & Polymorphism: Supports isolating registries via the prototype chain (isolateRegistry). Enables plugin systems to "shadow" parent tools and provides intelligent late-binding to ensure dependency polymorphism and sibling stability.
  • 🔗 Dependency Management: Declare dependencies on other ToolFuncs using the depends property. Supports local aliasing, and dependencies are automatically registered.
  • 🔢 Reference Counted Registration: Allows multiple modules to share the same tool. Automatically manages dependency lifecycles, only physically unregistering a tool when all references are released.
  • ⚖️ Controlled Overrides: Explicitly supports allowOverride mode to safely update tool implementations (e.g., hot-reloading) while maintaining dependency chain integrity.
  • 🧩 Execution Context & Concurrency Isolation: Achieves concurrency safety with minimal memory overhead using prototype-chain-based "Shadow Instances". Safely access environmental data (like traceId, signal) via this.ctx and propagate context using the tool.with(ctx) chainable API.
  • 🔄 Async & Cancellable Tasks: Transparently integrates cancellation capabilities via makeToolFuncCancelable. Automatically injects an aborter on every call, supports timeouts and AbortSignal linkage, and returns a task handle for external lifecycle control.
  • 🌊 Streaming Responses: Easily create streaming outputs using the stream property. Process stream events seamlessly with createCallbacksTransformer, featuring unified cleanup hooks and zero-copy optimization.
  • 🚀 Lifecycle Hooks: Install makeToolFuncLifecycle to turn the setup and dispose hook functions into a symmetric, registry-driven lifecycle (terse API surface, with async setup, readiness gating, and explicit teardown).
  • 🧬 Flexible Argument Normalization: Supports smart pattern recognition (strings, functions, objects) and deep merging during construction and registration to easily compose tool metadata.
  • 🔀 Dual-Mode Parameter Support: Supports both semantically clear object parameters (run) and fixed-order positional parameters (runWithPos).
  • 🏷️ Aliases & Tags: Assign multiple names (alias) or tags to functions for flexibility and grouping.

📦 Installation

npm install @isdk/tool-func

🚀 Basic Usage

1. Define a Tool

Create a ToolFunc instance to define your tool's metadata and implementation.

import { ToolFunc } from '@isdk/tool-func';

const getUser = new ToolFunc({
  name: 'getUser',
  description: 'Retrieves a user by ID.',
  params: { id: { type: 'string', required: true } },
  func: (params) => ({ id: params.id, name: 'John Doe' }),
});

2. Register the Tool

Register the tool to make it available in the global registry.

getUser.register();

3. Run the Tool

Execute the tool from anywhere in your application using the static run method.

async function main() {
  const user = await ToolFunc.run('getUser', { id: '123' });
  console.log(user); // Outputs: { id: '123', name: 'John Doe' }
}

main();

🌟 Advanced Usage

Dependency Management

Declare dependencies on other tools, and they will be registered automatically.

const welcomeUser = new ToolFunc({
  name: 'welcomeUser',
  description: 'Generates a welcome message.',
  params: { userId: 'string' },
  depends: {
    // `getUser` will be auto-registered when `welcomeUser` is registered.
    userFetcher: getUser,
  },
  func: function(params) {
    // `this` is the ToolFunc instance, so we can use `runAsSync` to run dependencies
    const user = this.runAsSync('userFetcher', { id: params.userId });
    return `Hello, ${user.name}!`;
  },
});

welcomeUser.register();

const message = await ToolFunc.run('welcomeUser', { userId: '456' });
console.log(message); // "Hello, John Doe!"

💡 Pro Tip: Local Dependency Aliasing In runAsSync or runAs, the framework prioritizes matching keys in the depends map (e.g., userFetcher). This allows you to define "local names" for dependencies that are only valid within the current tool, without polluting the global registry.

Lifecycle Hooks: Full Lifecycle Lifecycle with setup and dispose

A plain ToolFunc stores the setup and dispose hook functions but never calls them; they are configuration fields. Install the lifecycle ability once on the registry class you actually use, const Tools = makeToolFuncLifecycle(ToolFunc), and the hooks become a symmetric, registry-driven lifecycle:

  • register() runs setup (idempotently, once per registration lifetime, after the instance is placed in the registry). It can mutate the options object it receives; the keys it touches are replayed back onto the instance through the same initialize/assign pipeline.
  • scope is a compile-time input: the func is compiled against the tool's scope (its keys become closure variables, a this key becomes the function's this), and those values are captured while it compiles. A scope that setup provides — through this.scope or the options object — is therefore applied before the func is compiled, exactly as it was when setup still ran in the constructor.
  • dispose is the exact inverse — its teardown runs when the tool is physically removed (refCount hits zero, or force is used); its returned promise is tracked and any rejection is logged.
  • Sync initiates, async awaits: unregister() and Tools.clear() are synchronous entry points, so they only start a teardown. unregisterAsync() and Tools.clearAsync() await it to the end — the tool's own dispose and the dependency releases chained behind it. An asynchronous dependency teardown therefore overlaps its holder's under clear(); clearAsync() avoids that, and reports the layer's failures as one AggregateError.
  • Teardown order: a tool is torn down before the dependencies it declares, and those dependencies are released last-declared first. A dependency must outlive everything that uses it, so a dependent's dispose still finds a live dependency to give back what its setup took — stopping a timer before closing the pool it queries. Declare dependencies in dependency order (a dependency before the tool that uses it): that single convention keeps both the acquisition order and the release order (its reverse) correct. Tools.clear() / Tools.clearAsync() release everything the layer owns with that same order, instead of just dropping the tables.
  • Re-arm: a successful dispose re-arms the lifecycle, so every unregister/register cycle runs setup again and is torn down again in turn — dispose is the exact inverse of setup. An instance with a dispose hook is therefore strictly paired: executing it before register() is treated as a bug.
  • cleanup — the call-scoped twin of dispose: setup/dispose are tied to the instance's registration lifetime, cleanup to a single call. Declaring it releases whatever that call acquired, on every terminal path and at most once: a synchronous result or throw, a settled promise (resolve and reject), a returned ReadableStream (once it finishes, fails or is cancelled), and an abort of the call's signal — a cancelled call never unwinds its own stack, so a finally would never run. The body parks the per-call resources on this (this.tx = begin()) and cleanup releases exactly those: declaring the hook also makes each call run on its own isolated shadow instance, so concurrent calls cannot collide. Write it defensively (this.tx?.commit()), since it is invoked for a body that threw before acquiring anything too. A synchronous entry point can only start an async release (its rejection is logged); run()/runWithPos() already return a promise and therefore resolve only after the cleanup settled. When the body and the cleanup fail, the call reports one AggregateError.
  • Async setup: if setup returns a promise, the instance becomes pending. run() / runWithPos() await it automatically; runSync() / runWithPosSync() refuse to run and point at the async entry points (run() / await tool.ready). The static helpers registerAsync(), createAsync(), awaitReady(), and whenAllReady() await setup and (for the first two) the whole dependency tree.

Instance-side surface on every registered tool: ensureSetup()/ensureDispose(), isSetupDone()/isSetupPending(), registerAsync()/unregisterAsync(), plus the ready and disposed promises. The static surface: Tools.registerAsync(...), Tools.unregisterAsync(...), Tools.createAsync(...), Tools.clearAsync(), Tools.awaitReady(name), and Tools.whenAllReady().

import { ToolFunc, makeToolFuncLifecycle } from '@isdk/tool-func';

const Tools = makeToolFuncLifecycle(ToolFunc);

const db = new Tools({
  name: 'db',
  setup() {
    // runs at register() time, not in the constructor; this is still the instance
    this.conn = connect();
  },
  dispose() {
    this.conn.close();
  },
  func() {
    return this.conn.query('select 1');
  },
});

db.register();            // <- setup() runs here
await Tools.run('db');    // setup has already completed by the time func runs
db.unregister();          // <- dispose() runs here

// Async setup: the instance is pending until setup settles.
const slow = new Tools({
  name: 'slow',
  async setup() { this.readyAt = await Promise.resolve('now'); },
  func() { return this.readyAt; },
});
// slow.runSync() would throw; use the async surface instead:
await Tools.registerAsync(slow);       // waits for setup (and dependencies) to settle
await Tools.whenAllReady();            // waits for every tool in the registry to be ready
await Tools.createAsync({ name: 'temp', setup() { /* ... */ } });  // creates + sets up, without registering

// Per-call resources: `cleanup` is the call-scoped twin of `dispose`.
const tx = new Tools({
  name: 'tx',
  func() {
    this.tx = db.begin();   // acquired by the body, whenever (and if) it is needed
    return this.tx.query('select 1');
  },
  cleanup() { return this.tx?.commit(); },  // runs when the call ends, however it ends
});
await tx.run();  // resolves only after cleanup() has settled

⚠️ A bare instance is a no-op for lifecycle. new ToolFunc({ setup(){...}, dispose(){...} }) .register() does nothing with the hooks. If you need lifecycle, always construct from a registry class that has been enhanced with makeToolFuncLifecycle (or a subclass of one).

The Per-Call Scope: cleanup and Its Terminal Paths

setup/dispose are registration scoped — one resource lifetime per registration. cleanup is call scoped — one resource lifetime per run(). Declaring it makes the framework close that scope exactly once, no matter how the call ends:

| Terminal path | When cleanup runs | | --- | --- | | Synchronous return | right after the body returns its value | | Synchronous throw | in the throw path, before the error is rethrown | | Async body (promise) | after it settles — on resolve and on reject; run() resolves only once the release settled | | Returned ReadableStream | when the stream ends, errors or is cancelled by the consumer | | Call signal aborts | as soon as ctx.signal / ctx.aborter.signal fires |

The last two are the ones try/finally cannot express, and the reason this belongs to the framework:

  • A stream's value is produced before it is consumed. finally runs when the body returns the stream — before a single chunk reaches the consumer. The scope stays open until the stream itself closes: the framework pipes it through one identity TransformStream whose onClose is the release.
  • A cancelled call never unwinds its own stack. An AbortSignal only notifies; it does not throw through your frames, so a finally never runs. The scope is closed by the signal instead.

Semantics worth knowing:

  • Exactly once. Every terminal path funnels into the same release, so a stream that is aborted and then cancelled still releases once.
  • this is the call's shadow instance. Declaring cleanup also forces per-call isolation, so the fields the body parks on this are private to that call — concurrent calls cannot collide. Sharing a single resource with nested calls is done through this.ctx, which runAs targets inherit.
  • Write it defensively. cleanup is invoked even for a body that threw before acquiring anything, so guard it (this.tx?.commit()).
  • Nesting order. A nested runAs/runAsSync child releases its own scope when its own call ends — before the parent continues, hence before the parent releases. When the parent awaits the child, the child's asynchronous release has already settled by the time the parent resumes.
  • Sync starts, async waits. As with unregister(), a synchronous entry point can only initiate an asynchronous release (its rejection is logged, never thrown). run()/runWithPos() already return a promise, so they resolve only after the cleanup settled.
  • One error, not two. If the body and the cleanup both fail, the call reports a single AggregateError carrying both.

Registration Lifecycle & Reference Counting

In complex plugin systems, multiple tools might share the same underlying dependency. To safely manage these shared tools, @isdk/tool-func introduces a Reference Counting mechanism.

1. How Reference Counting Works

  • register(): Each time you call register, the reference count for that tool name is incremented. If the tool already exists and override mode is not enabled, it simply increments the count and returns false (indicating no new instance was created).
  • unregister(): Each time you call unregister, the reference count is decremented. The tool is only physically removed from the global registry when its count reaches zero.
  • Forced Unregistration: You can bypass the count and remove a tool immediately using ToolFunc.unregister(name, true) or unregister({ force: true }).

2. Automatic Dependency Lifecycle

When you register a tool with depends, the framework automatically handles the lifecycle of its dependencies:

  • Auto-Registration: Registering a parent tool automatically registers all ToolFunc instance dependencies (incrementing their refCounts).
  • Auto-Unregistration: When a parent tool is completely removed (refCount reaches zero), it automatically triggers unregistration requests for all its dependencies (decrementing their refCounts).

This ensures that as long as at least one parent tool is active, its required child tools will not be accidentally unloaded.

🔧 Internal Convention: Circular Dependency Detection via options._stack (Plugin Developers)

When a tool declares depends, registration recurses into each dependency. To terminate circular chains (A → B → A), the framework threads an internal stack — a Set of the ancestor names currently being registered — through the recursive register calls:

  • Carrying: the stack is passed as options._stack (it may sit either in the first-arg config object or in the second-arg options). It is consumed and removed during normalization (see _extractStack), so it never becomes instance state and is never serialized.
  • Back-edge behavior: if a name is already in the stack, register returns false — the tool is already being registered in the current call chain, so re-entry is skipped.
  • If you override register() / _acquireDependencies(): keep threading the stack via { _stack: stack } in the options object — do not add a third parameter. Only the wrapper object is consumed; the Set itself passes through recursion unchanged.

3. Implementation Overriding

If you need to dynamically update the logic of an already registered tool (e.g., for hot-reloading or plugin replacement), use the allowOverride option:

// Initial registration
ToolFunc.register({ name: 'calc', func: () => 1 });

// Attempt to override (without allowOverride, this only increments the refCount)
ToolFunc.register({
  name: 'calc',
  func: () => 2,
  allowOverride: true // Forcefully replace the existing implementation
});

console.log(ToolFunc.runSync('calc')); // Outputs: 2

⚠️ Note: A warning is issued if the tool being overridden is still held by other references (refCount > 1). Overriding is atomic: if a new tool's alias conflicts with another existing tool, the override fails and the old version is preserved.

Hierarchical Registries and Shadowing

For complex systems with plugin architectures or multi-tenant environments, you might need to isolate certain tools while still inheriting others from a parent registry. @isdk/tool-func supports Hierarchical Registries using JavaScript's prototype chain.

1. Isolate Your Registry

Use ToolFunc.isolateRegistry() to branch the current registry from its parent. This creates a new scope where registrations are local, but parent tools are still visible (and can be shadowed).

class MyPluginTools extends ToolFunc {
  static {
    // Branch the registry: isolate items, aliases, and refCounts
    this.isolateRegistry();
  }
}

// Parent has 'global-tool'
ToolFunc.register('global-tool', { func: () => 'global' });

// MyPluginTools inherits 'global-tool' but can register its own 'local-tool'
MyPluginTools.register('local-tool', { func: () => 'local' });

console.log(MyPluginTools.get('global-tool')); // Returns the global tool
console.log(MyPluginTools.get('local-tool'));  // Returns the local tool
console.log(ToolFunc.get('local-tool'));       // undefined (isolated!)

2. Tool Shadowing (Polymorphism)

When a registry is isolated, you can register a tool with the same name as one in the parent. This "shadows" the parent tool within the current scope.

// Shadowing the parent's 'calc' tool
MyPluginTools.register('calc', { func: () => 'plugin-version' });

console.log(ToolFunc.runSync('calc'));       // Original version
console.log(MyPluginTools.runSync('calc'));  // Plugin version

3. Namespace Protection

If you want to ensure a name is globally unique and prevent accidental shadowing, use allowOverride: false. The registry will check the entire prototype chain and throw an error if the name is already taken.

MyPluginTools.register('global-tool', {
  func: () => 'oops',
  allowOverride: false // Throws error because 'global-tool' exists in parent
});

4. Scoped Unregistration

The unregister method supports a scope option to control how deeply to remove a tool:

  • scope: 'local' (default): Only remove the tool if it's "owned" by the current registry. If you unregister a shadow tool, the parent tool will "re-appear".
  • scope: 'inherited': Search up the chain and remove the first occurrence.
  • scope: 'all': Remove the tool from the current registry and all its parents.

5. Late-Binding Polymorphism & Binding Strategies

In complex plugin systems, a parent tool may depend on other tools. When a child registry "shadows" these dependencies, the system intelligently senses the rootRegistry (entry-point caller) and switches implementations accordingly.

class Parent extends ToolFunc {
  static {
    const depP = new ToolFunc({ name: 'dep', func: () => 'parent-dep' });
    this.register(depP);
    this.register({
      name: 'main',
      depends: { d: depP },
      func: function() { return this.runAsSync('dep'); }
    });
  }
}

class Child extends Parent {
  static {
    this.isolateRegistry();
    // Shadow the dependency
    this.register({ name: 'dep', func: () => 'child-dep' });
  }
}

// Auto mode: Child's shadow is used when called from Child
console.log(Child.runSync('main'));  // Outputs: "child-dep"
console.log(Parent.runSync('main')); // Outputs: "parent-dep" (Stability protection)

You can explicitly control the dependency binding behavior via ctx.binding:

  • 'auto' (Default): Smart Sensing. Switches to late-binding only if the caller is a descendant of the definer and has a shadow. This achieves polymorphism while preserving stability for same-scope calls.
  • 'early': Early Binding (Safety First). Always uses the original instance bound at registration, ignoring any shadows.
  • 'late': Late Binding (Environment First). Forced resolution from the rootRegistry, regardless of lineage.
// Force using parent's original dependency even if child has a shadow
Child.runSync('main', {}, { binding: 'early' }); // Outputs: "parent-dep"

Execution Context and Concurrency Isolation

In production-grade applications, tool functions often don't run in isolation. They need to be aware of and respond to changes in the "execution environment". For example: carrying a traceId in distributed tracing, knowing the current userId in a web service, or responding to an AbortSignal in long-running tasks.

To support these complex requirements without compromising the purity of the tool functions (i.e., "separation of logic and environment"), @isdk/tool-func introduces a context management mechanism based on prototype chain shadow instances.

1. ToolFuncContext Core Interface

The context object is not just a data carrier; it's also a configuration set for controlling tool execution behavior:

  • isolated: boolean (optional). Core implementation, forces an independent execution scope for this call. Even if there are no other properties in ctx, setting this to true will trigger the creation of a shadow instance, ensuring concurrency safety.
  • inheritContext: boolean (optional). Core implementation, controls automatic context propagation. Defaults to true. If set to false, this call will have a brand new context environment that doesn't inherit from the parent.
  • signal: AbortSignal (optional). Recommendation, standard Web API. When an external abort occurs, the tool can catch it via this.ctx.signal.
  • signals: AbortSignal[] (optional). Recommendation, support for passing multiple abort signals. Any signal aborting will trigger the task to stop.
  • aborter: Aborter (optional). Recommendation, custom aborter. After injecting Cancelable ability, it will be automatically injected and managed here.
  • Custom Properties: You can spread any business-related Metadata (like userId, traceId) directly on the context object.

⚠️ Note on non-plain objects: If you pass a ctx with a non-standard prototype (e.g., it's an instance of a class), the framework will shallow copy and "flatten" it via {...ctx} before mounting it to the context prototype chain. This ensures you can access its properties while maintaining the inheritance structure.

2. Accessing Context: static ctx vs instance.ctx

The framework maintains ctx properties at both the class level (static) and object level (instance), with clear responsibilities:

  • Static ToolFunc.ctx: This is a global or proxy-level "default environment". When you use ToolFunc.with(ctx), it returns a class shadow with this property.
  • Instance this.ctx: This is the only legitimate entry point for tool internal logic (func) to access context. it guarantees that you always get data "belonging to this call", regardless of concurrency.

💡 Architectural Trade-off: Why not "flatten" context? We strictly forbid mounting context data directly on this (e.g., this.user). This is because ToolFunc instances have core metadata like name, params, title, etc. If the context happened to have a name field, flattening it would destroy the tool definition and lead to hard-to-debug bugs. this.ctx provides a safe isolated space.

3. Core Mechanism: Shadow Instance and Root Tracking (_origin)

This is the most ingenious design of this framework. To solve concurrency conflicts, we don't use heavy deep cloning, but leverage JavaScript's Prototype Chain.

When you call tool.with({ user: 'Alice' }).run():

  1. Create Shadow: The framework executes Object.create(tool).
  2. Root Tracking: Every shadow instance has a hidden _origin property pointing to the original tool instance. This ensures that even in complex nested shadows, concurrency control state (like semaphores, running task counts) is still managed by the original tool, avoiding "state drift".
  3. Inject Properties: Mount ctx: { user: 'Alice' } on the resulting shadow object.
  4. Logic Execution: The shadow object executes func. At this point, this points to the shadow object, so this.ctx returns Alice; meanwhile, thanks to the prototype chain, this.name still correctly accesses the name defined in the original tool.

Advantages of this design:

  • State Synchronization: Ensures global validity of single-instance concurrency limits (maxTaskConcurrency) and other resource-tracking states via _origin. This prevents state drift by centralizing management on the original tool instance.
  • Extremely Low Memory: Shadow objects are just a very thin layer of properties and don't hold logic copies.
  • Concurrency Safety: Each shadow object is independent. 100 concurrent requests correspond to 100 shadow objects, without interference.
  • Dynamic Inheritance: You can call .with().with() continuously, forming a chain of context inheritance.

4. Dual Forms of Fluent API

We provide a chained calling interface that reads like natural language:

Static Form: ToolFunc.with(ctx)

Used to preset the execution environment at a global level or before getting an instance. It returns a "static proxy class".

// All subsequent calls will carry current user info
const AuthorizedRunner = ToolFunc.with({ token: 'abc-123', role: 'admin' });

// Run any tool, they can all get admin via this.ctx.role
await AuthorizedRunner.run('deleteUser', { id: 789 });
Instance Form: tool.with(ctx)

Used for fine-grained environment configuration for a specific tool. It returns an "execution-time shadow instance".

const uploadTool = ToolFunc.get('uploadFile');

// Set trace ID and abort signal for a single upload task
const controller = new AbortController();
const runner = uploadTool.with({
  traceId: 'T-555',
  signal: controller.signal
});

await runner.run({ id: 789 });

5. Advanced Extension Hooks (for Plugin Developers)

If you are developing AoP (Aspect Oriented Programming) plugins (e.g., auto-logging, permission interception, performance tracking) or need to customize the isolation behavior of tools, you need to deeply understand the following two core internal hooks. They are the foundation of framework extensibility:

  • _shouldIsolate(params, ctx): The "Admission Switch" for shadow instances.

    • Role: Decides whether this call needs to create a brand new shadow instance.
    • ctx parameter: Specifically refers to the "call-time context" explicitly passed by the user when calling run(params, ctx) or runSync(params, ctx).
    • Logic:
      1. If the user passed ctx, it must be isolated to apply these overrides.
      2. If the current instance is already a shadow instance (has its own ctx property) and the user didn't pass a new ctx, it won't re-isolate and will reuse the current one.
      3. If the tool has async features like Cancelable enabled, it must be forced to isolate to ensure aborter isolation.
    • Custom Scenario: You can override this method to force isolation based on specific fields in params (e.g., forceNewScope: true).
  • _prepareContext(params, ctx): The "Processing Factory" for context.

    • Role: Responsible for building the final this.ctx object held by the shadow instance after it's created.
    • Core Logic - Prototype Inheritance:
      1. It first gets the "parent context" (i.e., the existing this.ctx of the current instance).
      2. If inheritContext is true (default), it executes Object.create(parentCtx) to achieve property inheritance.
      3. Automatic Ability Injection: For example, the Cancelable plugin overrides this method to automatically inject an aborter instance here and link it with external signal/signals.
      4. Finally, overlay the ctx explicitly passed by the user onto the top of this new object.
    • Custom Scenario: Plugins (like auto-logging) override this method to automatically inject a logger instance, achieving transparent feature injection for business logic.

    ⚠️ Note: When overriding these methods, be sure to call super._shouldIsolate or super._prepareContext to ensure normal operation of core framework features.

6. Automatic Context Propagation

In tool chain calls (e.g., tool A calling this.runAs('B') in its implementation), context flows automatically:

  • Default Behavior: B automatically inherits all ctx properties of A.
  • Explicit Control: A new ctx can be passed in runAs(params?, ctx?: ToolFuncContext), which will be merged (inherited) as a sub-context into the current call.
  • Positional Argument Support: Since positional argument functions (runWithPos) don't accept a ctx argument, you must use this.with(ctx).runWithPos(...) to ensure correct context injection.

Asynchronous & Cancellable Tasks

When dealing with AI agent requests, big data processing, or complex async workflows, tasks often take a long time. Cancelable Ability allows developers to safely abort a task in the middle of its execution, avoiding invalid computation and resource waste.

1. Core Mechanism: Transparent Context Integration

After giving a tool "Cancelable" ability via makeToolFuncCancelable, the framework automatically participates in the construction of the execution context:

  • Automatic Injection: Every time the tool is called, the framework automatically injects a TaskAbortController (referred to as aborter) into this.ctx of the shadow instance.
  • Environment Isolation: Each concurrent task has an independent aborter, without interference.
  • Signal Linking: If external signal or signals are passed in the context (ctx), the injected aborter will automatically link with these signals. As soon as an external signal aborts, the internal task will be notified immediately.

2. Usage Example

The example below shows how to define a long-running loop task that supports abortion:

import { ToolFunc, makeToolFuncCancelable, AsyncFeatures } from '@isdk/tool-func';

// 1. Give the ToolFunc class cancelable capability
const CancellableToolFunc = makeToolFuncCancelable(ToolFunc);

// 2. Define a specific long-running tool
const myLongTask = new CancellableToolFunc({
  name: 'myLongTask',
  asyncFeatures: AsyncFeatures.Cancelable, // Declare cancelable feature
  func: async function(params) {
    // Get the auto-injected aborter from context
    const aborter = this.ctx.aborter;

    for (let i = 0; i < 100; i++) {
      // Do actual work
      await doSomeWork();

      // Core step: Check for abort status. Throws AbortError if aborted.
      aborter.throwIfAborted();
    }
    return 'Task completed successfully';
  }
});

myLongTask.register();

// 3. Run the task and get the control handle
// Async execution returns a Promise with a .task property
const promise = ToolFunc.run('myLongTask');
const task = promise.task; // Get the task controller for this call

// Simulate discovery that results are no longer needed after 1 second, initiate abort
setTimeout(() => task.abort('Result no longer needed'), 1000);

try {
  await promise;
} catch (err) {
  console.log(err.message); // Outputs: "Result no longer needed"
}

3. Key Points Analysis

  • aborter.throwIfAborted(): This is the recommended way to check. It ensures that when an abort occurs, the business logic exits with a standard AbortError, triggering the correct resource cleanup process.
  • Task Handle: The task object is attached to the Promise returned by ToolFunc.run. This allows callers to control the task lifecycle directly without needing to know context details.
  • Timeout Support: You can pass a timeout parameter (via params or ctx) directly when calling, and the framework will automatically set a timer and trigger aborter.abort() after timeout.

4. Abort Rejections & Unhandled Rejection Safety

An aborted task rejects its TaskPromise with an AbortError (because of a timeout, an external signal, or an explicit task.abort()). When several concurrent tasks share one aborter — for example, multiple run() calls on the same tool.with(ctx) runner, or one external signal / timeout that fans out to a whole group — they all reject at (nearly) the same moment.

The library intentionally does not swallow these rejections: doing so would hide genuine task failures and disable Node's unhandledRejection diagnostics. It is therefore the caller's responsibility to attach a rejection handler to every task promise. Register the handlers before awaiting any of them; otherwise the not-yet-awaited promises can be reported as unhandled rejections (and, under Node's default --unhandled-rejections=throw, can even terminate the process):

const p1 = runner.run({ timeout: 40 })
const p2 = runner.run() // shares the runner's aborter → rejects together with p1

// ❌ p2 may already have rejected while you are still awaiting p1
// await p1
// await p2

// ✅ Attach handlers to both first, then await.
await Promise.allSettled([p1, p2])

// or keep the settled values:
const [r1, r2] = await Promise.all([p1.catch(e => e), p2.catch(e => e)])

Streaming Responses

To create a tool that can stream its output, follow these steps:

  1. Enable Streaming Capability: Set stream: true in the tool's definition. This marks the tool as capable of streaming.
  2. Check for Streaming Request: Inside your func, use the this.isStream(params) method. This checks if the current execution was requested as a stream. By default, it looks for a stream: true parameter in the incoming arguments.
  3. Add a Control Parameter (Optional): If your tool should support both streaming and regular value returns, add a stream: { type: 'boolean' } parameter to your params definition. This allows users to choose the return type (e.g., by passing { stream: true }). If your tool only streams, you don't need this parameter.

The example below demonstrates a flexible tool that can return either a stream or a single value.

import { ToolFunc } from '@isdk/tool-func';

// 1. Define the tool with streaming capability
const streamableTask = new ToolFunc({
  name: 'streamableTask',
  description: 'A task that can return a value or a stream.',
  stream: true, // Mark as stream-capable
  params: {
    // Declare a 'stream' parameter to control the output type
    stream: { type: 'boolean', description: 'Whether to stream the output.' }
  },
  func: function(params) {
    // 2. Check if streaming is requested
    if (this.isStream(params)) {
      // Return a ReadableStream for streaming output
      return new ReadableStream({
        async start(controller) {
          for (let i = 0; i < 5; i++) {
            controller.enqueue(`Chunk ${i}\n`);
            await new Promise(r => setTimeout(r, 100));
          }
          controller.close();
        }
      });
    } else {
      // Return a regular value if not streaming
      return 'Completed in one go';
    }
  }
});

// 3. Register the tool
streamableTask.register();

// 4. Run in both modes
async function main() {
  console.log('--- Running in non-streaming mode ---');
  const result = await ToolFunc.run('streamableTask', { stream: false });
  console.log('Result:', result); // Output: Completed in one go

  console.log('\n--- Running in streaming mode ---');
  const stream = await ToolFunc.run('streamableTask', { stream: true });

  // 5. Consume the stream
  const reader = stream.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      console.log('Stream finished.');
      break;
    }
    process.stdout.write(value); // Output: Chunk 0, Chunk 1, ...
  }
}

main();

Handling Stream Events with createCallbacksTransformer

While ToolFunc allows you to return streams, you often need to process the data within a stream or ensure robust resource cleanup. The createCallbacksTransformer utility creates a TransformStream that makes it easy to hook into a stream's lifecycle events.

Key Features

  • Unified Cleanup: The onClose hook is guaranteed to run exactly once, regardless of how the stream ended (success, error, or cancel). This is the ideal place to release resources like ActiveTaskHandle.
  • Zero-Copy Optimization: If you omit onTransform, the transformer acts as a high-performance "Identity Transform", letting data pass through with minimal overhead.
  • RPC & Cancellation Friendly: Explicitly supports the onCancel hook to detect client disconnections or aborts.

Callback Functions

  • onStart(controller): Called once when the stream is initialized.
  • onTransform(chunk, controller): Called for each chunk. (Omit for zero-copy path).
  • onFinal(controller): Called once the stream is successfully closed (upstream close).
  • onCancel(reason): Called if the reader cancels the stream.
  • onError(err): Called if an error occurs.
  • onClose(status, reason): The recommended cleanup hook. status is 'final', 'error', or 'cancel'.

Example: Processing and Robust Cleanup

import { createCallbacksTransformer } from '@isdk/tool-func';

async function main() {
  // 1. Create a transformer with comprehensive callbacks
  const transformer = createCallbacksTransformer({
    onStart: () => console.log('Stream started!'),
    onTransform: (chunk) => {
      console.log('Received chunk:', chunk);
      return chunk.toUpperCase();
    },
    onFinal: () => console.log('Stream finished normally!'),
    onError: (err) => console.error('Stream error:', err),
    onClose: (status, reason) => {
      console.log(`Resource Cleanup: Stream closed with status [${status}]`);
      if (reason) console.log('Reason/Error:', reason);
      // myTaskHandle.release();
    }
  });

  // 2. Create a source ReadableStream
  const readableStream = new ReadableStream({
    start(controller) {
      controller.enqueue('a');
      controller.enqueue('b');
      controller.close();
    },
  });

  // 3. Pipe the stream through the transformer
  const transformedStream = readableStream.pipeThrough(transformer);

  // 4. Read the results from the transformed stream
  const reader = transformedStream.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log('Processed chunk:', value);
  }
}

main();

This example would output:

Stream started!
Received chunk: a
Processed chunk: A
Received chunk: b
Processed chunk: B
Stream finished normally!
Resource Cleanup: Stream closed with status [final]

Parameter Handling: Object vs. Positional

ToolFunc supports both object-based and positional parameters for flexibility. While both are functional, object parameters are generally recommended for their clarity and self-documenting nature.

Object Parameters (Recommended)

When params is defined as an object, the func receives a single object argument containing all parameters by name. This is the default and most straightforward approach.

const greetUser = new ToolFunc({
  name: 'greetUser',
  description: 'Greets a user by name and age.',
  params: {
    name: { type: 'string', required: true },
    age: { type: 'number' },
  },
  func: (args) => {
    const { name, age } = args;
    return `Hello, ${name}! ${age ? `You are ${age} years old.` : ''}`;
  },
});

greetUser.register();
console.log(await ToolFunc.run('greetUser', { name: 'Alice', age: 30 }));
// Outputs: "Hello, Alice! You are 30 years old."

Positional Parameters

If params is defined as an array of FuncParam objects, the func receives arguments in the order they are defined. This can be useful for functions with a fixed, small number of arguments where order is intuitive.

const addNumbers = new ToolFunc({
  name: 'addNumbers',
  description: 'Adds two numbers.',
  params: [
    { name: 'num1', type: 'number', required: true },
    { name: 'num2', type: 'number', required: true },
  ],
  func: (num1, num2) => num1 + num2,
});

addNumbers.register();
console.log(await ToolFunc.runWithPos('addNumbers', 5, 3)); // Use runWithPos for positional arguments
// Outputs: 8

Recommendation: For most use cases, defining params as an object and accessing arguments by name within your func is cleaner and less error-prone, especially as your function's parameter list grows.

Flexible Argument Normalization

The framework features a "Smart Argument Normalization" system for both the ToolFunc constructor and the ToolFunc.register method. This system uses Pattern Recognition to identify your intent and applies Deep Merging (via defaultsDeep) to combine your inputs.

1. Core Principle: The "Authority" vs. "Defaults"

In all patterns involving two arguments (arg1, arg2), arg1 is the primary authority, and arg2 provides deep default values. This means if both arguments define the same property (like title), the value in arg1 will be preserved.

2. Supported Patterns

The system automatically recognizes the following patterns:

  • (string, options):

    • The first argument is the fixed name.
    • options provides everything else as defaults.
    • const tool = new ToolFunc('myTool', { title: 'Default Title' });
  • (function, options):

    • The first argument is the implementation func.
    • Its name is used as a fallback if no name is provided in options.
    • Metadata Awareness: If the function was enriched via funcWithMeta, its metadata is automatically extracted and used with high priority.
    • const tool = new ToolFunc(function myTask() {}, { description: '...' });
  • (object, options):

    • The first argument is a configuration object or an existing ToolFunc instance.
    • The second argument fills in missing properties recursively.
    • const tool = new ToolFunc({ name: 'task', title: 'Main' }, { title: 'Fallback' }); // title will be 'Main'
  • (string, funcString):

    • The first argument is the fixed name, the second is the implementation as a function-expression string.
    • ToolFunc.register('add', '(a, b) => a + b');
  • (string, funcString, config):

    • Same as above, plus an optional third config object describing params, description, title, etc. Works the same way for the constructor:
    • ToolFunc.register('add', '(a, b) => a + b', { params: [{ name: 'a' }, { name: 'b' }], description: 'Adds two numbers' });
    • const add = new ToolFunc('add', '(a, b) => a + b', { params: [{ name: 'a' }, { name: 'b' }], description: 'Adds two numbers' });

3. String Functions

A func can be provided as a string and is compiled at construction/registration time. This is especially useful when loading tool definitions from persisted data (the framework itself exports func as a string when serializing a tool).

  • Accepted formats — the string must be a function expression: an arrow expression ('(a, b) => a + b'), a function expression ('function(a, b) { return a + b }'), or a named function expression ('function greet(name) { return name; }').
  • Bare expressions are rejected — a string like 'a + b' evaluates to a value instead of a function, so it throws a clear error. Use an arrow form instead.
  • Calling convention — a string func like '(a, b) => ...' is positional, so declare params as an array ([{ name: 'a' }, { name: 'b' }]) to use run/runSync with named params, or call it with runWithPos/runWithPosSync.
  • Name derivation — when no name is configured, the name is derived from a named function expression (e.g. 'function add(a, b) {...}' → add).
  • Scope — the scope option provides closure variables: new ToolFunc({ name: 't', scope: { secret: 42 }, func: '() => secret' }). It also applies to a function value (compiled from its own source, so a method-shorthand source cannot bind a scope and is used as-is), while an empty or absent scope leaves a function value untouched — its lexical closure survives. With the lifecycle ability installed, a setup hook may provide the scope too (see Lifecycle Hooks).

⚠️ Security note: string funcs are compiled with new Function, i.e. arbitrary code execution. Only register strings from trusted sources (e.g. your own persisted data).

4. Deep Merging Benefits

Because it uses defaultsDeep, you can provide partial defaults for nested structures like params, depends, or result schemas.

ToolFunc.register(
  { name: 'complex', params: { id: { type: 'string' } } },
  { params: { apiKey: { type: 'string', required: true } } }
);
// The resulting tool will have BOTH 'id' and 'apiKey' in its params.

🏛️ Core Architecture: Static vs. Instance

A key design principle in ToolFunc is the separation of roles between the static class and its instances:

  • The Static Class as Manager: The static side of ToolFunc (e.g., ToolFunc.register, ToolFunc.run) acts as a global registry and executor. It manages all tool definitions, allowing any part of your application to discover and run tools by name.

  • The Instance as the Tool: An instance (new ToolFunc(...)) represents a single, concrete tool. It holds the actual function logic, its metadata (name, description, parameters), and any internal state.

This separation provides the best of both worlds: the power of object-oriented encapsulation for defining individual tools and the convenience of a globally accessible service for managing and executing them.

🤝 Contributing

If you would like to contribute to the project, please read the CONTRIBUTING.md file for guidelines on how to get started.

📄 License

The project is licensed under the MIT License. See the LICENSE-MIT file for more details.