@isdk/tool-func
v2.0.0
Published
A manager for creating, registering, and executing reusable tool functions with rich metadata.
Maintainers
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
ToolFuncinstances 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 thedependsproperty. 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
allowOverridemode 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) viathis.ctxand propagate context using thetool.with(ctx)chainable API. - 🔄 Async & Cancellable Tasks: Transparently integrates cancellation capabilities via
makeToolFuncCancelable. Automatically injects anaborteron every call, supports timeouts andAbortSignallinkage, and returns ataskhandle for external lifecycle control. - 🌊 Streaming Responses: Easily create streaming outputs using the
streamproperty. Process stream events seamlessly withcreateCallbacksTransformer, featuring unified cleanup hooks and zero-copy optimization. - 🚀 Lifecycle Hooks: Install
makeToolFuncLifecycleto turn thesetupanddisposehook 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) ortagsto 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
runAsSyncorrunAs, the framework prioritizes matching keys in thedependsmap (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()runssetup(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 sameinitialize/assignpipeline.scopeis a compile-time input: the func is compiled against the tool'sscope(its keys become closure variables, athiskey becomes the function'sthis), and those values are captured while it compiles. Ascopethatsetupprovides — throughthis.scopeor the options object — is therefore applied before the func is compiled, exactly as it was whensetupstill ran in the constructor.disposeis the exact inverse — its teardown runs when the tool is physically removed (refCount hits zero, orforceis used); its returned promise is tracked and any rejection is logged.- Sync initiates, async awaits:
unregister()andTools.clear()are synchronous entry points, so they only start a teardown.unregisterAsync()andTools.clearAsync()await it to the end — the tool's owndisposeand the dependency releases chained behind it. An asynchronous dependency teardown therefore overlaps its holder's underclear();clearAsync()avoids that, and reports the layer's failures as oneAggregateError. - 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
disposestill finds a live dependency to give back what itssetuptook — 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
disposere-arms the lifecycle, so every unregister/register cycle runssetupagain and is torn down again in turn —disposeis the exact inverse ofsetup. An instance with adisposehook is therefore strictly paired: executing it beforeregister()is treated as a bug. cleanup— the call-scoped twin ofdispose:setup/disposeare tied to the instance's registration lifetime,cleanupto 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 returnedReadableStream(once it finishes, fails or is cancelled), and an abort of the call's signal — a cancelled call never unwinds its own stack, so afinallywould never run. The body parks the per-call resources onthis(this.tx = begin()) andcleanupreleases 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 oneAggregateError.- Async setup: if
setupreturns 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 helpersregisterAsync(),createAsync(),awaitReady(), andwhenAllReady()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 withmakeToolFuncLifecycle(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.
finallyruns 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 identityTransformStreamwhoseonCloseis the release. - A cancelled call never unwinds its own stack. An
AbortSignalonly notifies; it does not throw through your frames, so afinallynever 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.
thisis the call's shadow instance. Declaringcleanupalso forces per-call isolation, so the fields the body parks onthisare private to that call — concurrent calls cannot collide. Sharing a single resource with nested calls is done throughthis.ctx, whichrunAstargets inherit.- Write it defensively.
cleanupis invoked even for a body that threw before acquiring anything, so guard it (this.tx?.commit()). - Nesting order. A nested
runAs/runAsSyncchild releases its own scope when its own call ends — before the parent continues, hence before the parent releases. When the parentawaits 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
AggregateErrorcarrying 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 returnsfalse(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)orunregister({ 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
ToolFuncinstance 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 — aSetof the ancestor names currently being registered — through the recursiveregistercalls:
- 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,
registerreturnsfalse— 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; theSetitself 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 version3. 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 inctx, setting this totruewill trigger the creation of a shadow instance, ensuring concurrency safety.inheritContext:boolean(optional). Core implementation, controls automatic context propagation. Defaults totrue. If set tofalse, 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 viathis.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 injectingCancelableability, it will be automatically injected and managed here.Custom Properties: You can spread any business-related Metadata (likeuserId,traceId) directly on the context object.
⚠️ Note on non-plain objects: If you pass a
ctxwith 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 useToolFunc.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 becauseToolFuncinstances have core metadata likename,params,title, etc. If the context happened to have anamefield, flattening it would destroy the tool definition and lead to hard-to-debug bugs.this.ctxprovides 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():
- Create Shadow: The framework executes
Object.create(tool). - Root Tracking: Every shadow instance has a hidden
_originproperty 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". - Inject Properties: Mount
ctx: { user: 'Alice' }on the resulting shadow object. - Logic Execution: The shadow object executes
func. At this point,thispoints to the shadow object, sothis.ctxreturns Alice; meanwhile, thanks to the prototype chain,this.namestill 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.
ctxparameter: Specifically refers to the "call-time context" explicitly passed by the user when callingrun(params, ctx)orrunSync(params, ctx).- Logic:
- If the user passed
ctx, it must be isolated to apply these overrides. - If the current instance is already a shadow instance (has its own
ctxproperty) and the user didn't pass a newctx, it won't re-isolate and will reuse the current one. - If the tool has async features like
Cancelableenabled, it must be forced to isolate to ensure aborter isolation.
- If the user passed
- 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.ctxobject held by the shadow instance after it's created. - Core Logic - Prototype Inheritance:
- It first gets the "parent context" (i.e., the existing
this.ctxof the current instance). - If
inheritContextistrue(default), it executesObject.create(parentCtx)to achieve property inheritance. - Automatic Ability Injection: For example, the
Cancelableplugin overrides this method to automatically inject anaborterinstance here and link it with externalsignal/signals. - Finally, overlay the
ctxexplicitly passed by the user onto the top of this new object.
- It first gets the "parent context" (i.e., the existing
- Custom Scenario: Plugins (like auto-logging) override this method to automatically inject a
loggerinstance, achieving transparent feature injection for business logic.
⚠️ Note: When overriding these methods, be sure to call
super._shouldIsolateorsuper._prepareContextto ensure normal operation of core framework features.- Role: Responsible for building the final
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
ctxproperties of A. - Explicit Control: A new
ctxcan be passed inrunAs(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 actxargument, you must usethis.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 asaborter) intothis.ctxof the shadow instance. - Environment Isolation: Each concurrent task has an independent aborter, without interference.
- Signal Linking: If external
signalorsignalsare passed in the context (ctx), the injectedaborterwill 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 standardAbortError, triggering the correct resource cleanup process.- Task Handle: The
taskobject is attached to the Promise returned byToolFunc.run. This allows callers to control the task lifecycle directly without needing to know context details. - Timeout Support: You can pass a
timeoutparameter (viaparamsorctx) directly when calling, and the framework will automatically set a timer and triggeraborter.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:
- Enable Streaming Capability: Set
stream: truein the tool's definition. This marks the tool as capable of streaming. - Check for Streaming Request: Inside your
func, use thethis.isStream(params)method. This checks if the current execution was requested as a stream. By default, it looks for astream: trueparameter in the incoming arguments. - Add a Control Parameter (Optional): If your tool should support both streaming and regular value returns, add a
stream: { type: 'boolean' }parameter to yourparamsdefinition. 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
onClosehook is guaranteed to run exactly once, regardless of how the stream ended (success, error, or cancel). This is the ideal place to release resources likeActiveTaskHandle. - 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
onCancelhook 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 (upstreamclose).onCancel(reason): Called if the reader cancels the stream.onError(err): Called if an error occurs.onClose(status, reason): The recommended cleanup hook.statusis'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: 8Recommendation: 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. optionsprovides everything else as defaults.const tool = new ToolFunc('myTool', { title: 'Default Title' });
- The first argument is the fixed
(function, options):- The first argument is the implementation
func. - Its
nameis used as a fallback if nonameis provided inoptions. - 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: '...' });
- The first argument is the implementation
(object, options):- The first argument is a configuration object or an existing
ToolFuncinstance. - The second argument fills in missing properties recursively.
const tool = new ToolFunc({ name: 'task', title: 'Main' }, { title: 'Fallback' }); // title will be 'Main'
- The first argument is a configuration object or an existing
(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');
- The first argument is the fixed
(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' });
- Same as above, plus an optional third config object describing
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 declareparamsas an array ([{ name: 'a' }, { name: 'b' }]) to userun/runSyncwith named params, or call it withrunWithPos/runWithPosSync. - Name derivation — when no
nameis configured, the name is derived from a named function expression (e.g.'function add(a, b) {...}'→add). - Scope — the
scopeoption 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, asetuphook 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.
