@agentiny/core
v0.8.0
Published
Lightweight TypeScript agent framework - core package
Downloads
260
Maintainers
Readme
@agentiny/core
Small TypeScript primitives for building reactive agents with state, triggers, conditions, and actions.
Use it when you want simple in-process automation:
- Watch state and run actions when checks pass.
- Chain actions by updating state.
- Emit named events and react to them.
- Wait for cascades to finish with
settle().
Install
npm install @agentiny/coreQuick Start
import { Agent } from '@agentiny/core';
type State = {
count: number;
message?: string;
};
const agent = new Agent<State>({
initialState: { count: 0 },
});
agent.when(
(state) => state.count >= 3,
[
(state) => {
state.message = 'threshold reached';
},
],
);
await agent.start();
agent.updateState({ count: 3 });
await agent.settle();
console.log(agent.getState().message); // "threshold reached"
await agent.stop();How It Works
An agent owns one state value and a set of triggers.
A trigger has:
check: decides if the trigger should run.conditions: optional extra guards.actions: functions that run when the check and conditions pass.
Checks, conditions, and actions can be sync or async.
agent.addTrigger({
id: 'send-alert',
check: (state) => state.count > 10,
conditions: [(state) => state.message !== 'sent'],
actions: [
async (state) => {
await sendAlert();
state.message = 'sent';
},
],
repeat: true,
});Actions receive the live state object. If you mutate it directly, later triggers
in the same evaluation pass can see that mutation. If you need to schedule a new
evaluation pass, call setState() or updateState().
Actions may also accept an optional second ActionContext argument:
async (state, ctx) => {
// ctx.signal is aborted when the agent is stopped or paused
await fetch(url, { signal: ctx?.signal });
};stop() / pause() called from inside an action do not deadlock: they request
shutdown and return without awaiting the execution loop.
Trigger delay is non-blocking — other triggers keep evaluating while a delayed
trigger waits. Actions always see state as of execution time (not a pre-delay
snapshot). At most one delay is pending per trigger.
Common Patterns
Partial State Updates
Use updateState() for object state.
agent.updateState({ count: agent.getState().count + 1 });Use setState() when replacing the whole state value.
agent.setState({ count: 0, message: undefined });One-Time Triggers
agent.once(
(state) => state.count > 0,
[(state) => console.log('first positive count', state.count)],
);Event Triggers
agent.on('saved', [
() => {
console.log('saved');
},
]);
agent.emitEvent('saved');
await agent.settle();emitEvent is a coalesced wake signal, not a queue. Multiple
emitEvent('foo') calls before the next cycle fire each matching trigger
once, not once per call — if you need per-message delivery, model the count
in state. A pending emission is consumed only after the trigger's conditions
pass, so a failing condition leaves the emission armed until conditions are
satisfied on a later cycle or the trigger is removed.
Emissions made while the agent is idle or stopped stay pending and fire on
the next start(). Already-consumed emissions are not re-fired across
stop/start cycles.
Wait For A Condition
waitFor(predicate, timeout?) returns a promise that resolves with the state the
moment a synchronous predicate first becomes true. Where settle() waits for the
whole system to go quiet, waitFor() waits for one specific condition.
await agent.start();
const ready = await agent.waitFor((state) => state.ready);
console.log(ready); // the state that satisfied the predicatePredicates are evaluated immediately when called, on every
setState()/updateState() change (so they resolve with zero latency, even
while idle or paused), and on every loop cycle while running (so in-place action
mutations are observed too). The resolved value is the live state reference,
consistent with getState() — it is not deep cloned.
waitFor() is callable in any status, but without a running agent only
setState()/updateState() changes can satisfy the predicate (trigger- and
timer-driven changes require start()). It rejects with an AgentError coded
WAITFOR_TIMEOUT if not satisfied within timeout (default 10000ms), or
AGENT_STOPPED if the agent is stopped while waiting. A predicate that throws
rejects the returned promise; invalid arguments throw synchronously.
Time-Based Triggers
every(interval, ...) fires repeatedly on a fixed interval. The interval is
either milliseconds or a duration string (ms, s, m, h).
Timers start when the agent is running. If a schedule is registered while idle
or stopped, it begins on the next start().
agent.every('2h', [refreshFeed]);
agent.every(30_000, [hasPendingWork], [flushQueue]);
agent.every('5s', [poll], { immediate: true }); // also fires on the first cycleat(time, ...) fires at a wall-clock time of day in the host's local timezone.
Accepts "HH:MM" (24h) or "H:MMam" / "H:MMpm" (12h, case-insensitive).
Repeats daily by default; pass { once: true } to fire only on the next
occurrence and self-remove.
agent.at('21:30', [sendDailyReport]);
agent.at('9:30am', [isWeekday], [sendStandupReminder]);
agent.at('00:00', [resetCounters], { once: true });Both methods accept the optional middle conditions array, return the trigger
id, and honor priority / maxFires via the options bag. They throw an
AgentError with code INVALID_TIME or INVALID_INTERVAL on malformed
input.
Pause And Resume
Pause keeps state and triggers, but stops trigger evaluation.
await agent.start();
await agent.pause();
agent.updateState({ count: 10 }); // no triggers run while paused
await agent.resume(); // triggers are evaluated again
await agent.settle();Call resume() to leave the paused state. Calling start() while paused throws.
Temporarily Disable A Trigger
const id = agent.when((state) => state.count > 5, [handleCount]);
agent.disableTrigger(id);
agent.enableTrigger(id);When a trigger is re-enabled while the agent is running, it is evaluated again without requiring another state update.
Trigger Priority
Higher priority triggers run first. Equal priority triggers keep insertion order.
agent.addTrigger({
id: 'normalize',
priority: 100,
check: (state) => state.count < 0,
actions: [
(state) => {
state.count = 0;
},
],
});Auto-Remove After N Fires
agent.addTrigger({
id: 'show-hint',
check: (state) => state.count > 0,
actions: [showHint],
maxFires: 3,
});maxFires must be a positive integer.
Reset State
agent.reset(); // restore initialState, keep triggers
agent.reset(true); // restore initialState, clear triggersreset() restores the original initialState reference. It does not deep clone
the initial state.
API
Agent
new Agent<TState>(config?: AgentConfig<TState>)State
getState(): TStatesetState(newState: TState): voidupdateState(partial: Partial<TState>): voidsubscribe(callback): () => void
Lifecycle
start(): Promise<void>pause(): Promise<void>resume(): Promise<void>stop(): Promise<void>reset(clearTriggers?: boolean): voidisRunning(): booleanisPaused(): booleangetStatus(): AgentStatussettle(quietCycles?: number, timeout?: number): Promise<void>waitFor(predicate: (state: TState) => boolean, timeout?: number): Promise<TState>
Valid lifecycle transitions:
idle -> running
running -> paused
paused -> running
running -> stopped
paused -> stopped
stopped -> runningTriggers
addTrigger(trigger): stringgetTrigger(id): Trigger | undefinedgetAllTriggers(): Trigger[]removeTrigger(id): voidclearTriggers(): voiddisableTrigger(id): voidenableTrigger(id): voidisTriggerDisabled(id): booleanoff(id): void
Convenience Methods
when(check, actions): stringwhen(check, conditions, actions): stringonce(check, actions): stringonce(check, conditions, actions): stringon(event, actions, repeat?): stringon(event, conditions, actions, repeat?): stringat(time, actions, options?): stringat(time, conditions, actions, options?): stringevery(interval, actions, options?): stringevery(interval, conditions, actions, options?): stringemitEvent(event): voidremoveEventTrigger(event, id): voidremoveAllEventTriggersForEvent(event): voidgetEventTriggersForEvent(event): Trigger[]getEventTriggers(): Map<string, Trigger[]>
Types
type TriggerFn<TState> = (state: TState) => boolean | Promise<boolean>;
type ConditionFn<TState> = (state: TState) => boolean | Promise<boolean>;
interface ActionContext {
signal: AbortSignal;
triggerId: string;
}
type ActionFn<TState> = (state: TState, ctx?: ActionContext) => void | Promise<void>;
interface AgentConfig<TState> {
initialState?: TState;
triggers?: Trigger<TState>[];
onError?: (error: Error) => void;
idleTimeout?: number;
/** @default 1000 — breaks unbounded setState cascades */
maxCascadeDepth?: number;
logger?: (error: unknown) => void;
}
interface Trigger<TState> {
id: string;
check: TriggerFn<TState>;
conditions?: readonly ConditionFn<TState>[];
actions: readonly ActionFn<TState>[];
repeat?: boolean;
delay?: number;
maxFires?: number;
priority?: number;
}
enum AgentStatus {
Idle = 'idle',
Running = 'running',
Paused = 'paused',
Stopped = 'stopped',
}Error Handling
Agent lifecycle and trigger-management errors throw AgentError.
try {
await agent.start();
await agent.start();
} catch (error) {
if (error instanceof AgentError) {
console.error(error.code, error.context);
}
}Use onError for errors raised while evaluating checks, conditions, or actions.
Action errors are collected and reported, but later actions still run.
const agent = new Agent({
initialState: { count: 0 },
onError: (error) => {
console.error(error);
},
});Notes
updateState()is a shallow merge for object state.settle()requires the agent to be running.waitFor()is callable in any status and resolves with the matching state.- Disabled triggers remain registered and can be re-enabled.
- Event triggers created with
on()are normal triggers and can be removed withoff(). - Scheduled triggers registered with
at()andevery()keep their trigger IDs across stop/start. - While paused, scheduled timers may continue ticking, but actions do not run until resume.
idleTimeoutcontrols how often the loop wakes when there is no work. The default is100ms.maxCascadeDepth(default1000) caps consecutive dirty evaluation passes. When exceeded, the agent reportsCASCADE_LIMIT_EXCEEDEDviaonError, clears the dirty flag, and keeps running.- Long-running actions should observe
ctx.signalfor cooperative cancellation on stop/pause.
License
MIT
