UNPKG

mahler

Version:

A automated task composer and HTN based planner for building autonomous system agents

373 lines • 14.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Runtime = void 0; const promises_1 = require("timers/promises"); const ref_1 = require("../ref"); const target_1 = require("../target"); const observe_1 = require("./observe"); const patch_1 = require("./patch"); const types_1 = require("./types"); const DAG = require("../dag"); const path_1 = require("../path"); const lens_1 = require("../lens"); const pointer_1 = require("../pointer"); const view_1 = require("../view"); class ActionError extends Error { id; action; cause; constructor(message, id, action, cause) { super(message); this.id = id; this.action = action; this.cause = cause; } } /** * Internal error */ class ActionRunFailed extends ActionError { id; action; cause; constructor(id, action, cause) { super(`Action '${action.description}' failed with error: ${cause}`, id, action, cause); this.id = id; this.action = action; this.cause = cause; } } class PlanRunFailed extends Error { errors; constructor(errors) { super(`Plan execution failed`); this.errors = errors; } } class ActionConditionFailed extends ActionError { id; action; constructor(id, action) { super(`Condition for action '${action.description}' not met`, id, action); this.id = id; this.action = action; } } class Cancelled extends Error { constructor() { super('Agent runtime was stopped before plan could be fully executed'); } } class PlanNotFound extends Error { constructor(cause) { super('Plan not found', { cause }); } } class PlanningTimeout extends Error { timeout; constructor(timeout) { super(`Planning aborted after ${timeout}(ms)`); this.timeout = timeout; } } class Runtime { observer; target; planner; sensors; opts; strict; promise = Promise.resolve({ success: false, error: new types_1.NotStarted(), }); running = false; stopped = false; subscriptions = {}; stateRef; constructor(observer, state, target, planner, sensors, opts, strict) { this.observer = observer; this.target = target; this.planner = planner; this.sensors = sensors; this.opts = opts; this.strict = strict; this.stateRef = ref_1.Ref.of(state); // Perform actions based on the new state this.updateSensors(path_1.Path.from('/')); } get state() { return this.stateRef._; } findPlan() { const { trace, plannerMaxWaitMs } = this.opts; let target; if (this.strict) { target = target_1.Target.fromStrict(this.stateRef._, this.target, this.opts.strictIgnore); } else { target = this.target; } trace({ event: 'find-plan', state: this.stateRef._, target, }); // Trigger a plan search return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new PlanningTimeout(plannerMaxWaitMs)); }, plannerMaxWaitMs); const result = this.planner.findPlan(this.stateRef._, this.target); clearTimeout(timer); if (!result.success) { trace({ event: 'plan-not-found', stats: result.stats, cause: result.error, }); reject(new PlanNotFound(result.error)); return; } // trace event: plan found // data, iterations, time, plan resolve(result); }); } updateSensors(changedPath) { // for every existing subscription, check if the path still // exists, if it doesn't unsusbcribe Object.keys(this.subscriptions) .filter((p) => lens_1.Lens.startsWith(p, changedPath)) .forEach((p) => { const parent = pointer_1.Pointer.from(this.stateRef._, path_1.Path.source(p)); // If the parent does not exist or the key does not exist // then delete the sensor if (parent == null || !Object.hasOwn(parent, path_1.Path.basename(p))) { this.subscriptions[p].unsubscribe(); delete this.subscriptions[p]; } }); // For every sensor, find the applicable paths // under the changed path const sApplicablePaths = this.sensors.map((sensor) => ({ sensor, paths: lens_1.Lens.findAll(this.stateRef._, sensor.lens, changedPath), })); // for every sensor, see if there are new elements // matching the sensor path, if there are, subscribe for (const { sensor, paths } of sApplicablePaths) { for (const p of paths) { if (p in this.subscriptions) { continue; } this.subscriptions[p] = sensor(p).subscribe((change) => { // Patch the state // We don't handle concurrency as we assume sensors // do not conflict with each other (should we check?) (0, patch_1.applyPatch)(this.stateRef, change); // Notify the observer of changes in the state this.observer.next(change); if (this.opts.follow) { // Trigger a re-plan to see if the state is still on target this.start(); } }); } } } async runAction(id, action) { const { trace } = this.opts; // Make a copy of the path modified by the action before the change const before = structuredClone(pointer_1.Pointer.from(this.stateRef._, action.path)); // TODO: if a sensor makes a change to the path pointed by this action between // here and the rollback those changes will be lost. We might need to queue those // changes to re-apply them. const parent = pointer_1.Pointer.from(this.stateRef._, path_1.Path.source(action.path)); const existsBefore = before !== undefined || // If the pointer returns undefined, we check if the child exists under the parent // path, as it is possible the value exists and is set to `undefined` (parent != null && typeof parent === 'object' && Object.hasOwn(parent, path_1.Path.basename(action.path))); try { trace({ event: 'action-start', action }); // The observe() wrapper allows to notify the observer of every // change to some part of the state await (0, observe_1.observe)(action, this.observer)(this.stateRef); trace({ event: 'action-success', action }); } catch (e) { // If an error occured, we revert the change const after = view_1.View.from(this.stateRef, action.path); if (!existsBefore) { after.delete(); this.observer.next({ op: 'delete', path: action.path }); } else { after._ = before; this.observer.next({ op: 'update', path: action.path, target: before }); } trace({ event: 'action-failure', action, cause: e }); throw new ActionRunFailed(id, action, e); } } async runPlan(root) { const { trace } = this.opts; await DAG.mapReduce(root, Promise.resolve(), async (node, prev) => { // Wait for the previous action to complete, // this also propagates errors await prev; const { id, action } = node; if (this.stopped) { throw new Cancelled(); } trace({ event: 'action-next', action }); if (!action.condition(this.stateRef._)) { trace({ event: 'action-condition-failed', action }); throw new ActionConditionFailed(id, action); } await this.runAction(id, action); this.updateSensors(action.path); }, async (actions) => { // Wait for all promises to be settled to prevent moving // on with a new planning cycle before the state is settled const results = await Promise.allSettled(actions); // Aggregate the results from previous calls // we use a map to deduplicate errors since map reduce // will propagate the same errors on every branch const actionErrorMap = {}; for (const r of results) { if (r.status === 'rejected') { const { reason: err } = r; actionErrorMap[err.id] = err; if (err instanceof ActionError) { actionErrorMap[err.id] = err; } else { // Propagate any other errors throw err; } } } const errors = Object.values(actionErrorMap); if (errors.length > 0) { throw new PlanRunFailed(errors); } }); } start() { if (this.running) { return; } const { trace } = this.opts; this.promise = (async () => { this.running = true; let tries = 0; let found = false; trace({ event: 'start', target: this.target }); while (!this.stopped) { try { const result = await this.findPlan(); const { start } = result; // The plan is empty, we have reached the goal if (start == null) { trace({ event: 'success' }); return { success: true, state: structuredClone(this.stateRef._), }; } trace({ event: 'plan-found', start, stats: result.stats }); // Execute the plan await this.runPlan(start); // If we got here, we have successfully found and executed a plan found = true; tries = 0; // We've executed the plan succesfully // we don't exit immediately since the goal may // not have been reached yet. We will exit when there are no // more steps in a next re-plan trace({ event: 'plan-executed' }); } catch (e) { found = false; if (e instanceof PlanNotFound) { // nothing to do } else if (e instanceof PlanningTimeout) { // planning timed-out but we can keep searching as the // state could be updated by a sensor trace({ event: 'plan-timeout', timeout: e.timeout }); } else if (e instanceof ActionError || e instanceof PlanRunFailed) { // nothing to do } else if (e instanceof Cancelled) { // exit the loop break; } else { /* Something else happened, better exit immediately */ trace({ event: 'failure', cause: e }); return { success: false, error: new types_1.UnknownError(e), }; } } if (!found) { if (tries >= this.opts.maxRetries) { trace({ event: 'failure', cause: new types_1.Failure(tries) }); return { success: false, error: new types_1.Failure(tries), }; } } const wait = Math.min(this.opts.backoffMs(tries), this.opts.maxWaitMs); trace({ event: 'backoff', tries, delayMs: wait }); await (0, promises_1.setTimeout)(wait); // Only backoff if we haven't been able to reach the target tries += +!found; } // The only way to get here is if the runtime was stopped trace({ event: 'failure', cause: new types_1.Stopped() }); return { success: false, error: new types_1.Stopped() }; })() // QUESTION: if we get here this is pretty bad, should we notify // subscribers of an error? .catch((e) => ({ success: false, error: e })) .finally(() => { this.running = false; this.stopped = false; }); } async stop() { this.stopped = true; // Wait for the loop to finish await this.promise; // Unsubscribe from sensors Object.values(this.subscriptions).forEach((s) => { s.unsubscribe(); }); } async wait(timeout = 0) { if (timeout === 0) { return this.promise; } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new types_1.Timeout(timeout)); }, timeout); this.promise .then((res) => { clearTimeout(timer); resolve(res); }) .catch(reject); }); } } exports.Runtime = Runtime; //# sourceMappingURL=runtime.js.map