@agentiny/core
v0.10.0
Published
Lightweight TypeScript agent framework - core package
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() and pause() abort the session signal, skip the remaining actions in
the sequence, and wait for the active action and execution loop to finish.
An action that ignores cancellation can keep those promises pending until it
returns.
Inside a check, condition, or action, use the synchronous requestStop() or
requestPause() methods. They request shutdown without waiting for the callback
that invoked them:
agent.once(
(state) => state.done,
[
async () => {
await flushResults();
agent.requestStop();
},
],
);Migration: replace await agent.stop() / await agent.pause() inside
callbacks with agent.requestStop() / agent.requestPause(). Awaiting the
completion methods from inside the work they wait for would deadlock. Call
start() / resume() from outside those callbacks; they wait for the previous
session, including one-shot and fire-count bookkeeping, before starting a new
execution loop. An external stop() / pause() call can also await an existing
request while that session is still finishing.
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
An event name is either a signal or a message channel. The first emitEvent
that finds a listener locks it. Mixing the two throws EVENT_CHANNEL_MISMATCH.
An emit with no listener does nothing and does not lock the name.
A bare call is a coalesced wake signal. Several emitEvent('saved') calls
before the next cycle fire each listener once. Pass a payload to enqueue one
delivery per call. emitEvent('chat', undefined) is a message whose payload
is undefined, not a signal.
agent.on<{ id: string }>('saved', [
(state, ctx) => {
state.lastId = ctx?.payload?.id;
},
]);
agent.emitEvent('saved'); // signal, coalesced
agent.emitEvent('saved', { id: 'a' }); // throws: name is already a signal
agent.on<{ text: string }>('chat', [
(state, ctx) => {
state.last = ctx?.payload?.text;
},
]);
agent.emitEvent('chat', { text: 'hi' }); // one fire per callctx.payload is set on the check, the conditions, and the actions for that
delivery. The property is absent for signal events and for triggers that are
not event listeners.
Each listener has its own cursor into the shared queue. A failed condition leaves that listener's head in place, and later payloads stay behind it until the head is accepted or the listener is removed. Other listeners can move ahead. One queued payload is delivered per listener per pass. A listener that joins after messages are already queued starts at the end and does not replay them. There is no queue length cap: a stuck condition retains every payload behind its head.
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. Removing the last listener clears the channel, so the next
listener may lock the name either way.
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>requestStop(): voidrequestPause(): voidreset(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, payload?): voidremoveEventTrigger(event, id): voidremoveAllEventTriggersForEvent(event): voidgetEventTriggersForEvent(event): Trigger[]getEventTriggers(): Map<string, Trigger[]>
Types
type TriggerFn<TState, TPayload = unknown> = (
state: TState,
ctx?: ActionContext<TPayload>,
) => boolean | Promise<boolean>;
type ConditionFn<TState, TPayload = unknown> = (
state: TState,
ctx?: ActionContext<TPayload>,
) => boolean | Promise<boolean>;
interface ActionContext<TPayload = unknown> {
signal: AbortSignal;
triggerId: string;
/** Present only for a message-channel delivery. The value may be `undefined`. */
payload?: TPayload;
}
type ActionFn<TState, TPayload = unknown> = (
state: TState,
ctx?: ActionContext<TPayload>,
) => 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.settle()waits for active checks, conditions, actions, pending delays, and cascading updates, then the requested quiet cycles. It does not wait for future scheduled ticks or background work that an action did not await.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
