UNPKG

mahler

Version:

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

333 lines • 12.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.findPlan = findPlan; const mahler_wasm_1 = require("mahler-wasm"); const assert_1 = require("../assert"); const lens_1 = require("../lens"); const pointer_1 = require("../pointer"); const ref_1 = require("../ref"); const task_1 = require("../task"); const node_1 = require("./node"); const types_1 = require("./types"); const utils_1 = require("./utils"); const DAG = require("../dag"); function tryAction(action, { initialPlan, callStack = [] }) { // Something went wrong if the initial plan // given to this function is a failure (0, assert_1.default)(initialPlan.success); // Generate an id for the potential node const node = node_1.PlanAction.from(initialPlan.state, action); const id = node.id; // Detect loops in the plan if (DAG.find(initialPlan.start, (a) => a.id === id) != null) { return { success: false, stats: initialPlan.stats, error: types_1.LoopDetected }; } // Because the effect mutates the state, we need to create a copy here const ref = ref_1.Ref.of(structuredClone(initialPlan.state)); action.effect(ref); const state = ref._; // We calculate the changes only at the action level const changes = (0, mahler_wasm_1.diff)(initialPlan.state, state); // If the action is not part of a method and it // performs no changes then we just return the initial plan // as we don't know how the action contributes to the overall goal if (changes.length === 0 && callStack.length === 0) { return initialPlan; } // We create the plan reversed so we can backtrack easily const start = DAG.createValue({ id, action, next: initialPlan.start }); return { success: true, start, stats: initialPlan.stats, state, pendingChanges: initialPlan.pendingChanges.concat(changes), }; } // Expand the method in a sequential way function trySequential(method, { initialPlan, callStack = [], maxSearchDepth, ...pState }) { // Something went wrong if the initial plan // given to this function is a failure (0, assert_1.default)(initialPlan.success); // Protect against infinite recursion if (callStack.length > maxSearchDepth) { throw new types_1.Aborted(`Maximum search depth ${maxSearchDepth} reached on recursion`, initialPlan.stats); } const output = method(initialPlan.state); const instructions = Array.isArray(output) ? output : [output]; // We use spread here to avoid modifying the source object const plan = { ...initialPlan }; const cStack = [...callStack, method]; for (const i of instructions) { const res = tryInstruction(i, { ...pState, initialPlan: plan, callStack: cStack, maxSearchDepth, }); if (!res.success) { return res; } // Update the plan plan.start = res.start; plan.state = res.state; plan.pendingChanges = res.pendingChanges; } return plan; } function findConflict(ops) { const unique = new Map(); for (const [i, patches] of ops.entries()) { for (const o of patches) { for (const [path, [index, op]] of unique.entries()) { if (i !== index && (o.path.startsWith(path) || path.startsWith(o.path))) { // We found a conflicting operation on a different // branch than the current one return [o, op]; } } unique.set(o.path, [i, o]); } } } function tryParallel(parallel, { trace, initialPlan, callStack = [], maxSearchDepth, ...pState }) { (0, assert_1.default)(initialPlan.success); // Protect against infinite recursion if (callStack.length > maxSearchDepth) { throw new types_1.Aborted(`Maximum search depth ${maxSearchDepth} reached on recursion`, initialPlan.stats); } const output = parallel(initialPlan.state); const instructions = Array.isArray(output) ? output : [output]; // Nothing to do here as other branches may still // result in actions if (instructions.length === 0) { return initialPlan; } const empty = DAG.createJoin(initialPlan.start); const plan = { ...initialPlan, start: empty, }; let results = []; const cStack = [...callStack, parallel]; for (const i of instructions) { const res = tryInstruction(i, { ...pState, trace, initialPlan: plan, callStack: cStack, maxSearchDepth, }); if (!res.success) { return res; } results.push(res); } // If all branches are empty (they still point to the start node we provided) // we just return the initialPlan results = results.filter((r) => r.start !== empty); if (results.length === 0) { return initialPlan; } // if the method has just a single branch there is no point in // having the fork and empty node, so we need to remove the empty node // and connect the last action in the branch directly to the existing plan if (results.length === 1) { const branch = results[0]; // Find the first node for which the next element is the // empty node created earlier const last = DAG.find(branch.start, (a) => a.next === empty); (0, assert_1.default)(last != null); // We remove the empty node from the plan last.next = initialPlan.start; return { success: true, state: branch.state, pendingChanges: branch.pendingChanges, start: branch.start, stats: initialPlan.stats, }; } // Here is where we check for conflicts created by the parallel plan. // If two branches change the same part of the state, that means that there is // a conflict and the branches need to be executed in sequence instead. // NOTE: This is currently implemented in a pretty brute way. A better algorithm // would be to find which branches are in conflict, keep one of them in the parallel // part of the execution and move the other ones to the sequential part. const conflict = findConflict(results.map((r) => r.pendingChanges)); if (conflict) { // TODO we need a trace event here so the diagram can be updated trace({ event: 'backtrack-method', method: parallel, state: initialPlan.state, }); return trySequential(parallel, { trace, initialPlan, callStack, maxSearchDepth, ...pState, }); } // We add the fork node const start = DAG.createFork(results.map((r) => r.start)); // Since we already checked conflicts, we can just concat the changes const pendingChanges = results.reduce((acc, r) => acc.concat(r.pendingChanges), initialPlan.pendingChanges); // Now we can apply changes from parallel branches const state = (0, mahler_wasm_1.patch)(initialPlan.state, pendingChanges); return { success: true, // We need to return the accumulated state here so a calling // method can use the state state, pendingChanges, start, stats: initialPlan.stats, }; } function tryInstruction(instruction, { trace, initialPlan, callStack = [], ...state }) { (0, assert_1.default)(initialPlan.success); trace({ event: 'try-instruction', operation: state.operation, parent: callStack[callStack.length - 1], instruction, state: initialPlan.state, prev: initialPlan.start, }); // test condition if (!instruction.condition(initialPlan.state)) { return { success: false, stats: initialPlan.stats, error: types_1.ConditionNotMet }; } let res; if (task_1.Method.is(instruction)) { // If sequential expansion was chosen, then we go straight to // evaluating the method in a sequential manner if (instruction.expansion === task_1.MethodExpansion.SEQUENTIAL) { res = trySequential(instruction, { ...state, trace, initialPlan, callStack, }); } else { // Otherwise. We try methods in parallel first. If conflicts are found then we'll try // running them in sequence res = tryParallel(instruction, { ...state, trace, initialPlan, callStack, }); } } else { res = tryAction(instruction, { ...state, trace, initialPlan, callStack }); } return res; } function findPlan({ distance, tasks, trace, depth = 0, initialPlan, callStack = [], maxSearchDepth, }) { // Something went wrong if the initial plan // given to this function is a failure (0, assert_1.default)(initialPlan.success); const { stats } = initialPlan; stats.maxDepth = depth > stats.maxDepth ? depth : stats.maxDepth; // Get the list of operations from the patch const ops = distance(initialPlan.state); // If there are no operations left, we have reached // the target if (ops.length === 0) { trace({ event: 'found', prev: initialPlan.start, }); return { success: true, start: initialPlan.start, state: initialPlan.state, stats, pendingChanges: [], }; } trace({ event: 'find-next', depth, state: initialPlan.state, prev: initialPlan.start, operations: ops, }); if (depth >= maxSearchDepth) { throw new types_1.Aborted(`Maximum search depth reached (${maxSearchDepth})`, stats); } for (const operation of ops) { // Find the tasks that are applicable to the operations const applicable = tasks.filter((t) => (0, utils_1.isTaskApplicable)(t, operation)); for (const task of applicable) { stats.iterations++; // Extract the path from the task template and the // operation const path = operation.path; // Get the context expected by the task // we get the target value for the context from the pointer // if the operation is delete, the pointer will be undefined // which is the right value for that operation const tgt = operation.op === 'delete' ? undefined : pointer_1.Pointer.from(distance.target, path); const ctx = lens_1.Lens.context(task.lens, path, tgt); const taskPlan = tryInstruction(task(ctx), { depth, distance, tasks, trace, operation, initialPlan, callStack, maxSearchDepth, }); if (!taskPlan.success) { trace(taskPlan.error); continue; } // If the start node for the plan didn't change, then the method // expansion didn't add any tasks so it makes no sense to go to a // deeper level if (taskPlan.start !== initialPlan.start) { // applyPatch makes a copy of the source object so we only want to // perform this operation if the instruction suceeded const state = (0, mahler_wasm_1.patch)(initialPlan.state, taskPlan.pendingChanges); const res = findPlan({ depth: depth + 1, distance, tasks, trace, initialPlan: { ...taskPlan, state, pendingChanges: [], }, callStack, maxSearchDepth, }); if (res.success) { return res; } else { trace(res.error); } } else { trace(types_1.MethodExpansionEmpty); } } } return { success: false, stats, error: types_1.SearchFailed, }; } //# sourceMappingURL=findPlan.js.map