mahler
Version:
A automated task composer and HTN based planner for building autonomous system agents
373 lines • 14.1 kB
JavaScript
"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