A automated task composer and HTN based planner for building autonomous system agents in typescript.
NOTE even though this project is out of v0.x, we still consider it experimental, as we continue exploring different mechanisms to improve efficiency of various aspects of the framework. This project does adhere to the semantic versioning guidelines, so we wont perform any breaking API changes without a major version bump.
- Simple API. Define primitive tasks by declaring the
effect
it has on the system state, acondition
for the task to be chosen, and anaction
, which is the asynchronous operation that will be performed on the system if the task is chosen for the plan. Tasks can be used by other compound tasks (or methods) to guide the planner towards a desired behavior. - Highly configurable
Agent
interface allows to create autonomous agents to serve a wide variety of use cases. Create a single shot agent to just reach a specific target, or create a service agent that keeps monitoring the state of the world and making changes as needed to keep the system on target. Agents support re-planning if the state of the system changes during the plan execution or errors occur while executing actions. This runtime context can be used as feedback to the planning stage to chose different paths if needed. - Observable runtime. The agent runtime state and knowledge of the world can be monitored at all times with different levels of detail. Human readable metadata for tasks can be provided via the task
description
property. Plug in a trace function to generate human readable logs. - Parallel execution of tasks. The planner automatically detects when operations can be performed in parallel and creates branches in the plan to tell the agent to run concurrent operations.
- Easy to debug. Agent observable state and known goals allow easy replicability when issues occur. The planning decision tree and resulting plans can be diagrammed to visually inspect where planning is failing.
- Node.js 18+. Other runtimes are unsupported at this moment.
npm install --save mahler
- Autonomous system agent a process on a system that needs to operate with little or not feedback from an external system. An autonomous agent needs to be able to recover from failures and adapt if conditions on the system change while performing its duties. In our definition, such an agent operates based on a given target and will keep trying to achieve the target until this changes or some other exit conditions are met.
- Hierarchical Task Network (HTN) is a type of automated planning system that allows to define actions in the planning domain in a hierarchical manner, allowing actions to be re-used as part of compound tasks. This reduces the search domain and provides developers more control on what plans are preferable (over something like STRIPS). This has made this type of system popular in game design.
- Task a task is any operation defined by a domain expert to provide to the planner. A task can be a primitive task, called an action on this framework, e.g. "download a file", "write X to the database", or a method, i.e. a compound task, that provides a sequence of steps to follow.
- Plan a plan encodes what actions need to be executed by the agent in order to reach a certain target. Plans are represented as Directed Acyclic Graphs (DAG).
- Target a target is a desired state of the system. e.g, "temperature of the room == 25 degrees".
- Sensor a sensor is an observer of the system state, the agent can subscribe to one or more sensors in order to keep its local view of the state up-to-date and trigger re-planning if necessary.
The library design is inspired by the work in Exploring HTN Planners through example.
Let's to create a system controller for a counter. For that we can use an Agent
,
import { Agent } from 'mahler';
const counterAgent = Agent.from({
// The initial state of the system as known to the agent
initial: 0,
// A list of action or method tasks that encode knowledge about
// operations in the system
tasks: [],
});
The code above creates an agent with a counter starting at 0. We need to provide the agent with some tasks on how to control the system.
import { Task, View } from 'mahler';
const MySystem = Domain.of<number>();
const plusOne = Task.from({
// This means the task can only be triggered
// if the system state is below the target
condition: (state: number, { target }) => state < target,
// The effect of the action is increasing the system
// counter by 1
// `state._` allows us to get the value pointed by the View
effect: (state: View<number>) => ++state._,
// An optional description. Useful for testing
description: '+1',
});
The code above creates a task that has the effect
of increasing the system state by 1 if the state is below a given target (the condition
).
As function arguments are passed by value on JavaScript, we make use of the View
type to allow tasks to modify the actual state passed to the function instead of a copy. The need for this will hopefully become apparent as we start to use with more complex types. Note above that only the effect
function receives a View<number>
while the condition property receives just a number
. This is because only the condition should not modify the state and thus does not need to deal with views.
The code above however is a bit too verbose, though. The following is a cleaner way to do the same.
import { Task } from 'mahler';
// Setting the generic type allows typescript to infer the values of
// the function arguments
const plusOne = Task.from<number>({
condition: (state, { target }) => state < target,
effect: (state) => ++state._,
description: '+1',
});
Now we can give the task to the agent to use during planning.
// The type of Agent is inferred as `Agent<number>` from the task types
const counterAgent = Agent.from({
initial: 0,
tasks: [plusOne],
});
// This tells the agent to find a plan from the current
// state (0) to the target state (3). `seek` starts
// the agent operation without need to await
counterAgent.seek(3);
// Wait for the agent to return some result
const res = await counterAgent.wait();
if (res.success) {
console.log(res.state); // 3
}
The above code now initializes the agent with plusOne
as its only task and tells it to find a plan that gets the system to a state equal to 3
. On seek
, the agent will start perform the following operations:
- It will first calculate a plan to the target
- If found, it will execute the actions in the plan. If successful it will stop (by default, this can be configured)
- If no plan is found, it will wait for a bit and retry (the wait period and number of tries is configurable).
- If some error occurs while executing the plan, the agent will look for an alternative plan and continue.
In the example above, the agent should calculate a plan with 3 actions, and execute them in sequence before stopping.
The planner the agent uses can also be invoked directly, this is useful for testing our actions
import { Planner } from 'mahler/planner';
import { sequence, stringify } from 'mahler/testing';
// Create a new planner
const planner = Planner.from({ tasks: [plusOne] });
// Find a plan from 0 to 3
const res = planner.findPlan(0, 3);
// Compare the resulting plan with the desired sequence
expect(stringify(res)).to.deep.equal(sequence('+1', '+1', '+1'));
The code above uses the Planner
instance exposed under the mahler/planner
namespace as well as a couple of testing utilities. We'll learn more about these utilities further ahead.
The previous code only acts on the internal state of the agent, and doesn't perform any side-effects. In fact, the effect
function should NEVER perform side effects, as this function may be called multiple times during the planning process while searching for a path to the target.
In order to interact with the underlying system, an action
property can be provided to the task definition. Let's imagine we want the plusOne
task from the previous example to store the value of the counter somewhere for later retrieval using an async storeCounter
function that returns the stored value on success. Below is how that is implemented with Mahler.
Note
If no action
property is provided, the Task.from
constructor will use the effect
function as the task action.
const plusOne = Task.from<number>({
condition: (state, { target }) => state < target,
effect: (state) => ++state._,
action: async (state) => {
// storeCounter stores the given value on disk or
// throws if an error happens
await storeCounter(++state._);
},
description: '+1',
});
The action
property above updates the state and stores the value. While the action
and effect
functions are similar, the action
defines what will actually happen when the task is chosen as part of a plan, while the effect
just provides a "simulation" of the changes on the system state to be used during planning.
One more thing to note is that if storeCounter
throws while writing to disk, the agent runtime will revert the internal state to before the plusOne
action was executed to prevent the system state from becoming inconsistent.
Let's update the task to also read the stored state before updating it to avoid writing an inconsistent state.
const plusOne = Task.from({
// the rest of the task definition goes here
// since action can modify state, the argument given to action
// is also a `View`, same as effect
action: async (state, { target }) => {
// This is one way to deal with an out-of-date system state.
// we can read it as part of the action body and allow the
// agent to re-plan if it detects some inconsistency
state._ = await readCounter();
// We only update the stored value if it is below the target
if (state._ < target) {
await storeCounter(++state._);
}
},
});
There is some duplication going on between the action
, effect
and condition
functions. This is a necesity as the planner cannot determine the outcome of a task without executing its effect
function. Similarly, the agent needs the condition
function to detect if a specific task is still applicable or a re-plan is needed. This is a way to deal with non-determinism happening from the action
functions.
For instance, let's analyze how an agent with the above task definition would execute a plan from 0
(initial state) to 3
the target state. At the end of planning stage, the agent will need to execute the following plan
graph LR
A(+1) --> B(+1) --> C(+1)
When starting execution of the plan, the agent state is 0
. In the best case scenario, the agent execution will do 0 + 1 + 1 + 1 = 3
and terminate. Let's imagine now that the initial assumption about the system state being equal to 0
is wrong and when executing the first task readCounter
returns 1.
graph LR
A("state: 0
condition: 0 < 3
action: +1
result: 2
")
--> B("state: 2
condition: 2 < 3
action: +1
result: 3
") -->
C("state: 3
condition: 3 < 3
action: +1
result: abort
")
In the above scenario, the agent will execute the first task, since as far as the agent knowledge goes, the condition holds given the current state. The task will modify the state and set it to 2
. Since the condition still holds, the agent goes and executed the second action of the plan, which updates the state to 3
. When executing the third step in the plan, the agent sees the condition no longer holds, so it aborts execution. The condition acts here as a guard preventing the agent from running an action that would potentially put the system in an inconsistent state. In this case, it allows the agent to get to the desired target state, even despite the plan not being completely executed.
Some of the duplication in the previous example can be mitigated through modeling though. We could, for instance, model the system to read the state of the world at the beginning and write at the end, which would simplify the plusOne
task. We'll do that next.
Let's modify our system to be less agressive with read and writes, and reduce some code duplication. We'll update our system state model to be aware of reads and writes. Let's create the model first
type System = {
// Our counter state
counter: number;
// The timestamp for the last read of the the counter
// state obtained with performance.now()
lastRead: number | null;
// A boolean flag to track if the state needs
// to be commited to storage
needsWrite: boolean;
};
The new model now keeps track of reads via a timestamp, we'll use that property to ensure the agent knows that the counter state is up to date before making changes. Writes are tracked via a boolean flag, we can use that flag when setting a target to make sure the state is commited to storage. Let's write a read
and store
tasks.
// This is the maximum time allowed between reads
const MAX_READ_DELAY_MS = 1000;
const read = Task.from<System>({
// We only read if the state is out of date.
condition: (state) =>
state.lastRead == null ||
performance.now() - state.lastRead > MAX_READ_DELAY_MS,
effect: (state) => {
// The effect of the task is resetting of the timer
state._.lastRead = performance.now();
},
action: async (state) => {
// The action reads the counter and resets the timer
state._.counter = await readCounter();
state._.lastRead = performance.now();
},
description: 'readCounter',
});
const store = Task.from<System>({
// We only write after the system counter has reached the target
condition: (state, { target }) =>
state.counter === target.counter && state.needsWrite,
// The effect of the store task is toggle the needsWrite flag
effect: (state) => {
state._.needsWrite = false;
},
action: async (state) => {
// We write the counter and toggle the flag
await storeCounter(state._.counter);
state._.needsWrite = false;
},
description: 'storeCounter',
});
We can now modify the plusOne
task to only perform the update operation.
const plusOne = Task.from<System>({
condition: (state, { target }) =>
state.counter < target.counter &&
// We'll only update the counter if we know the internal counter is
// synchronized with the stored state
(state.lastRead == null ||
state.lastRead + MAX_READ_DELAY_MS >= performance.now()),
// The task has the effect of updating the counter and modifying the write requirement
// We no longer need to set an action as this operation no longer performs IO
effect: (state) => {
state._.counter++;
state._.needsWrite = false;
},
description: '+1',
});
The tasks above now will make sure that no counter changes can happen if more than 1 second has passed between reads, that condition will make sure a read
operation is added first to the plan. A store
will happen only once the counter target has been reached, this ensures that this action is always put last on the plan. This can be tested as follows.
const planner = Planner.from({ tasks: [plusOne, read, store] });
// Find a plan from 0 to 3
const res = planner.findPlan(
{ counter: 0, needsWrite: false },
{ counter: 3, needsWrite: false },
);
expect(stringify(res)).to.deep.equal(
sequence('readCounter', '+1', '+1', '+1', 'storeCounter'),
);
Let's talk for a second about the functions passed as task properties: condition
, effect
and action
. Until now we have seen that they receive the system state (or a View<System>
) as the first argument. These functions also receive a second argument, that can be seen in the plusOne
definition above where we used object destructuring to get the target
, property. The second argument is called the Context
object and it contains relevant metadata about the the context where the task is being used. We'll talk more about the context argument when we talk about Lenses. For now let us dive into the target property and the target state property.
You'll most likely have deduced by now that the target
property has the type of the task generic argument (i.e. System
in this case) and contains the target state passed to the planner. In our last example this means the target will be { counter: 3, needsWrite: false }
.
It's worth pointing out that the second argument of findPlan
above, or the argument to Agent.seek
in this case is not a System
type, but of Target<System>
. This is a special type that allows to provide partial versions of the type argument. This helps us to provide the planner/agent only with those parts of the state we care about as target. Let's say in our previous example, that we don't care if the counter is commited to storage just yet. We could do it with
// Find a plan from 0 to 3. Since we don't give a value for `needsWrite`, the planner will not
// add the storeCounter task to the plan
const res = planner.findPlan({ counter: 0, needsWrite: false }, { counter: 3 });
expect(stringify(res)).to.deep.equal(sequence('readCounter', '+1', '+1', '+1'));
This goes into the implementation of the planner. When we provide { counter: 3 }
as target, we are telling the planner to stop as soon as it finds that counter === 3
. If we provide { counter: 0, needsWrite: false }
, we are telling the planner to stop when counter === 3 && needsWrite === false
. Since the plusOne
actions in our plan flip the value for needsWrite
to true
, after reaching counter === 3
the planner knows it still needs to look for another task to flip the needsWrite
flag back to false
.
What about deletes? If Target<System>
is a partial type on the System
type, how can we tell the planner if we want to delete a value? Mahler provides the symbol UNDEFINED
to achieve this.
import { UNDEFINED } from 'mahler';
// This won't find a plan since we have no action defined to
// delete the lastRead property
const res = planner.findPlan(
{ counter: 0, needsWrite: false },
{ counter: 3, lastRead: UNDEFINED },
);
The example above is a bit nonsensical, but it shows how the UNDEFINED
symbol would be used. This tells the planner that we want the lastRead
property removed from the state object, so the planner would look for a task that results in the deletion of that property. Note that this is different than setting the value to undefined
. The difference is shown below
// If successful, it should result in the following state: {counter: 3, needsWrite: true}
agent.seek({ counter: 3, lastRead: UNDEFINED });
// If successful, it should result in the following state: {counter: 3, needsWrite: true, lastRead: undefined}
agent.seek({ counter: 3, lastRead: undefined });
You can also use the seekStrict
function of Agent
to tell the agent to look for the exact state given as the target
// This tells the agent the exact system state that
// we want to see at the end of the run.
agent.seekStrict({ counter: 3, needsWrite: true });
// The above is equivalent to
agent.seek({ counter: 3, lastRead: UNDEFINED });
We'll learn later how we can add an op
property to tasks to tell Mahler when a task is applicable to a delete
operation.
One last thing before moving on from this topic. What if you assign a required value the value of UNDEFINED
?
// Compilation fais with: `type symbol is not assignable to number|undefined`
agent.seek({ counter: UNDEFINED });
The compiler will prevent you from doing that to avoid ending up with inconsistent state (as long as you don't use as any
).
Now, as programmers, we want to be able to build code by composing simpler behaviors into more complex ones. We might want to guide the planner towards a specific solution, using the primitives we already have. For instance, let's say we want to help the planner get to a solution faster as adding tasks one by one takes too much time. We want to define a plusTwo
task, that increases the counter by 2
. We could create another primitive task to update the counter by two, but as programmers, we would like to reuse the code we have already defined. We can do that using methods.
// We'll go back to the simpler definition for this example
const plusOne = Task.from<number>({
condition: (state, { target }) => state.counter < target,
effect: (state) => ++state._,
description: '+1',
});
// Inspecting the plusTwo variable shows that this variable is of type `MethodTask`
const plusTwo = Task.from<number>({
// We want this method to be chosen only if the difference between the current
// state and the target is bigger than one
condition: (state, { target }) => target - state > 1,
// Defining the method property makes this task into a method
// A method should never modify the state, just return a sequence of applicable actions
method: (_, { target }) => [plusOne({ target }), plusOne({ target })],
description: '+2',
});
Now there is a lot happening here. We have replaced effect
and action
in the task constructor with method
. We are also directly using the plusOne
task as a function and passing the target as one of the properties of the argument object. Let's parse this example piece by piece.
The code above is the way to create compound tasks in Mahler, called methods. Using method
tells the task constructor to return a MethodTask
instead of an ActionTask
(like plusOne
).
A method should not directly modify the state, but return instead a sequence of actions that are applicable under the given conditions. As we see above, in this case, the method returns a sequence of two plusOne
tasks applied to the target.
Objects generated by task constructors are callable, and receive part of the Context
as the argument. Passing the context to a task binds it to the context, and this happens normally as part of the planning process. However, we reuse this mechanism to be able to reuse tasks as part of methods.
// Another test utility
import { runTask } from 'mahler/testing';
// We can execute the task directly by binding it to a context
// and then providing a state
console.log(await plusOne({ target: 3 })(0)); // 1
// Mahler provides the `runTask` helper function to
// call the task with the given state and context
// the call will throw if the task condition fails
console.log(await runTask(plusOne, 0, { target: 3 })); // 1
// `runTask` also works with methods, expanding the method
// into its actions and executing the actions sequentially
console.log(await runTask(doPlusTwo, 0, { target: 3 })); // 2
Methods are useful for tweaking the plans under certain conditions. They also help reduce the search space. When looking for a plan, the Planner will try methods first, and only if methods fail, proceed to look for action tasks. During planning, the method is expanded recursively into its component actions, so they won't appear on the final plan.
const planner = Planner.from({
tasks: [plusOne, plusTwo],
});
const res = planner.findPlan(0, 3);
// The method has already been expanded
expect(stringify(res)).to.deep.equal(sequence('+1', '+1', '+1'));
We can see method expansion by diagramming the planning process. Mahler provides a test util to generate Mermaid diagrams. We can construct the tracer using the mermaid function and pass it as a configuration to the planner.
import { mermaid } from 'mahler/testing';
// Create a tracer using the mermaid tool.
const trace = mermaid();
const planner = Planner.from({
tasks: [plusOne, plusTwo],
// Pass the trace as a configuration option to the planner
config: { trace },
});
// Find a plan from 0 to 3
const res = planner.findPlan(0, 3);
// render() returns a mermaid valid graph
console.log(trace.render());
Passing the output to the Mermaid Live Editor will produce the following diagram.
graph TD
start(( ))
start -.- d0{ }
d0 -.- f751444[["+2"]]
f751444 -.- b795d43("+1")
b795d43 -.- 4eb0bdd("+1")
4eb0bdd -.- d1{ }
d1 -.- 580ece0[["+2"]]
580ece0 -.- 580ece0-err[ ]
580ece0-err:::error
d1 -.- d634a71("+1")
d634a71 -.- stop(( ))
stop:::finish
classDef finish stroke:#000,fill:#000
start:::selected
start --> b795d43
b795d43:::selected
b795d43 --> 4eb0bdd
4eb0bdd:::selected
4eb0bdd --> d634a71
d634a71:::selected
d634a71 --> stop
classDef error stroke:#f00
classDef selected stroke:#0f0
In the diagram, the nodes connected via the dotted lines show the planning process, while nodes connected via the solid arrows show the resulting plan. The diagram shows how the planner tries first the +2
method, which results into the expansion ('+1', '+1')
, a second try of the method fails, because the condition no longer holds, so the planner adds a +1
action next, which allows the planner to reach the target.
Let's compare this diagram with one where the planner doesn't know the plusTwo
method. This new diagram looks like this:
graph TD
start(( ))
start -.- d0{ }
d0 -.- 2586e9e("+1")
2586e9e -.- d1{ }
d1 -.- 82f6258("+1")
82f6258 -.- d2{ }
d2 -.- a1f7280("+1")
a1f7280 -.- stop(( ))
stop:::finish
classDef finish stroke:#000,fill:#000
start:::selected
start --> 2586e9e
2586e9e:::selected
2586e9e --> 82f6258
82f6258:::selected
82f6258 --> a1f7280
a1f7280:::selected
a1f7280 --> stop
classDef error stroke:#f00
classDef selected stroke:#0f0
Here we see here that the planner needs to perform 3 iterations (the diamond nodes) to find a plan to the target.
Methods can also call other methods. We can now write plusThree
using plusOne
and plusTwo
const plusThree = Task.from<number>({
condition: (state, { target }) => target - state > 2,
// Methods can be referenced from other methods
method: (_, { target }) => [plusTwo({ target }), plusOne({ target })],
description: '+3',
});
Let's say now that we want our agent to manage multiple counters. Let's redefine our system state once more to do this
type System = { counters: { [key: string]: number } };
We can now easily (?) redefine our plusOne
task to handle this case.
const plusOne = Task.from<System>({
// This task will be chosen only if one of the keys is smaller than the target
condition: (state, { target }) =>
Object.keys(state.counters).some(
(k) => state.counters[k] < target.counters[k],
),
effect: (state, { target }) => {
// We find the first counter below the target, we know it exists because of
// the condition so we use the non-null assertion (!) at the end
const key = Object.keys(state._.counters).find(
(k) => state._.counters[k] < target.counters[k],
)!;
// Update the changed counter
state._.counters[key]++;
},
description: '+1',
});
The above code achieves the same as the single counter plusOne
(increase one of the counters), but is a bit convoluted, and we cannot tell from the description which counter will be increased. We can improve this by setting the lens
property when defining the action.
const plusOne = Task.of<System>().from({
// The lens defines the part of the state where this task is applicable
// this property defaults to '/', meaning the root object
lens: '/counters/:counterId',
condition: (counter, { target }) => counter < target,
effect: (counter) => ++counter._,
description: ({ counterId }) => `${counterId} + 1`,
});
We are again introducing a few new things here, but hopefully the code above is relatively intuitive. This declares plusOne
as a task on the System
type, but that acts on a specific counter, specified by the lens property. Since we don't know a priori which counter this will apply to, we replace the name of the counter with the placeholder variable, :counterId
. This variable is passed as part of the context to the task constructor properties. We can see this in the definition of description
, where we use counterId
. By the way, this also shows that the description can also be a function acting on the context.
Note also that instead of the usual Task.from
we preface the from
call with a Task.of
with a generic type argument. This is because of a limitation in typescript that does not allow it to perform partial inference of generic types. We'll see later how we can shorten this call by using a domain.
Now we can go a bit deeper on the context object, we mentioned before that it contains the context from the task selection process during planning. More specifically, the context object is composed by
- A
target
property, with the expected target value for the part of the state that the task acts on. This will have the same type as the first argument of the function, which will match the type pointed by thelens
property. We'll see when we talk about operations that this property is not present for delete or wildcard (*
) operation. - A
system
property, providing a read-only copy of the global state at the moment the task is used. This can be used, for instance, to define tasks with conditions on parts of the system that are unrelated to the sub-object pointed to by the lens. - A
path
property, indicating the full path of the lens where the task is being applied. - Zero or more properties with the names of the placeholders in the lens definition. For instance is the lens is
/a/:aId/b/:bId/c/:cId
,aId
,bId
, andcId
will be provided in the context, with the right type for the property within the path.
Note
Lenses allow us to have tasks operate on a part of the state to allow cleaner code. While the framework provides the system
property to be able to reference unrelated parts of the state object, it should be used sparingly as the code will quickly get harder to follow. If you find that you are using the system
property too often, consider refactoring of your model.
The name lens comes from the lens concept in functional programming, that are these structures that provide a view into a specific part of an object, allowing to read and modify values without needing to manipulate the full object. They can be thought as analogous to database views. However, unlike traditional database views or functional lenses, in order to make lenses useful in the context of tasks we need them to be bi-directional, that is, a modification to the contents of the view should affect the original data and vice-versa. For now the only supported lens allows us to focus into a specific sub-property of the system model, however, we would like to add some more powerful lenses in the future based on existing work in this area (see the work by Foster et al, Hoffman et al and Project Cambria).
Note
Hopefully this makes it clearer the reason the View
name was chosen to pass references to effect and action functions, changes to the view are indeed propagated to the global state object.
We can reuse these tasks in methods the same way as before, but we need to pass the values for the placeholder variables along with the target.
const plusTwo = Task.of<System>().from({
lens: '/counters/:counterId',
condition: (counter, { target }) => target - counter > 1,
method: (_, { target, counterId }) => [
plusOne({ target, counterId }),
plusOne({ target, counterId }),
],
description: ({ counterId }) => `${counterId} + 2`,
});
What happens with the above case if we look for a plan for multiple counters? What will be the plan generated below?
const planner = Planner.from({
tasks: [plusOne],
});
// Find a plan from 0 to 3
const res = planner.findPlan(
{ counters: { a: 0, b: 0 } },
{ counters: { a: 2, b: 2 } },
);
Again we can draw the plan using the mermaid test helper
graph TD
start(( ))
start -.- d0{ }
d0 -.- 4418a5c("a + 1")
4418a5c -.- d1{ }
d1 -.- a53644f("a + 1")
a53644f -.- d2{ }
d2 -.- 47bcbb6("b + 1")
47bcbb6 -.- d3{ }
d3 -.- a48a56b("b + 1")
a48a56b -.- stop(( ))
stop:::finish
classDef finish stroke:#000,fill:#000
start:::selected
start --> 4418a5c
4418a5c:::selected
4418a5c --> a53644f
a53644f:::selected
a53644f --> 47bcbb6
47bcbb6:::selected
47bcbb6 --> a48a56b
a48a56b:::selected
a48a56b --> stop
classDef error stroke:#f00
classDef selected stroke:#0f0
Even though the a + 1
and b + 1
actions modify totally different parts of the state, the planner still comes up with a sequential plan. This is a limitation of the planner right now, that we intend to improve in the future. Not all is bad news though, the planner does support parallel planning for methods. Let's define a generic nPlusOne
task that increases any pending counters.
const nPlusOne = Task.of<System>().from({
lens: '/counters',
condition: (counters, { target }) =>
Object.keys(counters).some((k) => counters[k] < target[k]),
method: (counters, { target }) =>
Object.keys(counters)
.filter((k) => counters[k] < target[k])
.map((k) => plusOne({ counterId: k, target: target[k] })),
description: 'counters++',
});
const planner = Planner.of({
tasks: [plusOne, nPlusOne],
config: { trace },
});
planner.findPlan({ counters: { a: 0, b: 0 } }, { counters: { a: 2, b: 2 } });
Here is how the plan looks now.
graph TD
start(( ))
start -.- d0{ }
d0 -.- 10de649[["counters++"]]
10de649 -.- c68b28e("a + 1")
10de649 -.- 49589fa("b + 1")
c68b28e -.- j8cb35b9
49589fa -.- j8cb35b9
j8cb35b9(( ))
j8cb35b9 -.- d1{ }
d1 -.- 5787f35[["counters++"]]
5787f35 -.- 65925ed("a + 1")
5787f35 -.- a8de3b6("b + 1")
65925ed -.- jce8b4d8
a8de3b6 -.- jce8b4d8
jce8b4d8(( ))
jce8b4d8 -.- stop(( ))
stop:::finish
classDef finish stroke:#000,fill:#000
start:::selected
start --> fj8cb35b9(( ))
fj8cb35b9:::selected
fj8cb35b9 --> c68b28e
c68b28e:::selected
fj8cb35b9 --> 49589fa
49589fa:::selected
j8cb35b9(( ))
c68b28e --> j8cb35b9
49589fa --> j8cb35b9
j8cb35b9:::selected
j8cb35b9 --> fjce8b4d8(( ))
fjce8b4d8:::selected
fjce8b4d8 --> 65925ed
65925ed:::selected
fjce8b4d8 --> a8de3b6
a8de3b6:::selected
jce8b4d8(( ))
65925ed --> jce8b4d8
a8de3b6 --> jce8b4d8
jce8b4d8:::selected
jce8b4d8 --> stop
classDef error stroke:#f00
classDef selected stroke:#0f0
As we can see, for the counters++
method, the planner detected that there were no conflicts between the steps returned by the method and automatically generated parallel branches for the plan. If conflicting changes were found between the branches, the planner would have reverted to a sequential execution (as it happens with plusTwo
in our previous examples). Plans generated this way also tell the agent that is safe to execute tasks in the given branches using Promise.all
, allowing faster execution overall.
This process is not perfect though, the planner can only detect if writes to the same part of the state happen, but what happens if one of the returned tasks of the method reads a value that is modified by the parallel branch? What would happen for instance with a method like the following?
const plusOneAndStore = Task.from({
method: (_, { target }) => [plusOne({ target }), store({ target })],
});
The store
action depends on the state being updated by plusOne
before it has anything to store, so parallel execution of this method will fail. This is again due a limitation of the planner that it cannot yet "see" into the action implementation, and as such, it doesn't know if an action is performing a read that conflicts with a write of the parallel branch. To solve this we provide the expansion
property for methods, that allows to force sequential expansion of the method as follows.
const plusOneAndStore = Task.from<number>({
// Force sequential expansion of the method.
// Supported values are 'detect' (default) or 'sequential'
expansion: 'sequential',
method: (_, { target }) => [plusOne({ target }), store({ target })],
});
Going back to the nPlusOne
example. How can we test that the generated plan meets our expectations? Mahler also provides test helpers for parallel plans
import { stringify, plan, branch } from 'mahler/testing';
const res = planner.findPlan(
{ counters: { a: 0, b: 0 } },
{ counters: { a: 2, b: 2 } },
);
// Compare the resulting plan to our expectation
expect(stringify(res)).to.deep.equal(
// Start a plan
plan()
// A fork with two branches, with one action each
.fork(branch('a + 1'), branch('b + 1'))
.fork(branch('a + 1'), branch('b + 1'))
// Convert the plan to a string for comparison
.end(),
);
Internally, the code above converts plans to a string representation for easier comparison using diff tools. Here is how the plan above would be represented
+ ~ - a + 1
~ - b + 1
+ ~ - a + 1
~ - b + 1
This represents a plan with two forks, each fork with two parallel branches of a single action each.
Here is what the symbology above can be read
+
denotes a fork in the plan, the point where the plan splits into branches~
denotes a branch of a fork-
denotes an element of the branch- Indentation denotes the level that the symbol is on with respect to the previous code
A couple of examples. The following represents a sequence of a + 1
actions.
- a + 1
- a + 1
- a + 1
The following represents a plan with a fork with two branches, each one with two actions. The fork is followed by a single a + 1
action.
+ ~ - a + 1
- a + 1
~ - b + 1
- b + 1
- a + 1
Symbols can be also chained for a more compact representation, can you figure out what the following means?
+ ~ + ~ - a++
- a++
~ - b++
- b++
~ + ~ - c++
- c++
~ - d++
- d++
- a++
What does the planner return in the following scenario?
type System = { counters: { [key: string]: number } };
const plusOne = Task.of<System>().from({
lens: '/counters/:counterId',
condition: (counter, { target }) => counter < target,
effect: (counter) => ++counter._,
description: ({ counterId }) => `${counterId} + 1`,
});
const planner = Planner.from({
tasks: [plusOne],
config: { trace },
});
const res = planner.findPlan(
{ counters: { a: 0 } },
{ counters: { a: 2, b: 1 } },
);
console.log(res.success); // false
In the above case the planner fails to find a plan, let's quickly analyze why. When given a target, the planner will calculate the pending changes between the current state and the target. When first starting the planner sees there that two operations are needed to get to the target, an update operation of counter a
and a create operation of counter b
, this is internally represented as below.
[
{ op: 'update', path: '/a', value: 2 },
{ op: 'create', path: '/b', value: 1 },
];
Tasks by default apply to update
operations, meaning the planner will know to chose the plusOne
task to serve the update
operation, but it will not find any task to serve the create
operation and hence planning fails. In the above scenario we would like perhaps that the planner would infer that counters start at 0
, but while that may work for this simple example, it is not true in the general case, particularly when dealing with more complex types. Moreover, creating a new counter could require some side effects that the planner could not anticipate.
In order to allow the planner to find a plan here, we need to provide a task for a create
operation.
const initCounter = Task.of<System>().from({
// This tells the planner that the task is applicable to a 'create' operation
// valid values are 'update' (default), 'create', 'delete', or '*'
op: 'create',
lens: '/counters/:counterId',
// No condition is needed in this case, the task will be chosen only if the counter
// is undefined
effect: (counter) => {
// Set the initial state for the counter
counter._ = 0;
},
description: ({ counterId }) => `${counterId} = 0`,
});
If we now add our task to the planner to look for the plan
const planner = Planner.from({
tasks: [plusOne, initCounter],
config: { trace },
});
const res = planner.findPlan(
{ counters: { a: 0 } },
{ counters: { a: 2, b: 1 } },
);
Will now yield the following plan
graph TD
start(( ))
start -.- d0{ }
d0 -.- 6d8d899("a + 1")
6d8d899 -.- d1{ }
d1 -.- 99adff5("a + 1")
99adff5 -.- d2{ }
d2 -.- 9312e2b("b = 0")
9312e2b -.- d3{ }
d3 -.- fd40601("b + 1")
fd40601 -.- stop(( ))
stop:::finish
classDef finish stroke:#000,fill:#000
start:::selected
start --> 6d8d899
6d8d899:::selected
6d8d899 --> 99adff5
99adff5:::selected
99adff5 --> 9312e2b
9312e2b:::selected
9312e2b --> fd40601
fd40601:::selected
fd40601 --> stop
classDef error stroke:#f00
classDef selected stroke:#0f0
What about deletion? What if you want to search for the following plan?
import { UNDEFINED } from 'mahler';
const res = planner.findPlan(
{ counters: { a: 0, b: 1 } },
// `UNDEFINED` is a symbol that tells mahler to
// look for a target that doesn't have the property `b`
{ counters: { a: 2, b: UNDEFINED } },
);
Same thing applies, the planner doesn't know if it can safely delete the value, so it will fail unless it finds a delete
task that is applicable to the path.
const delCounter = Task.of<System>().from({
op: 'delete',
lens: '/counters/:counterId',
// No condition is needed in this case, the task will be chosen only if the value
// exists
effect: () => {
// perform necessary cleanup here, no need to delete the counter as that happens automatically
},
description: ({ counterId }) => `delete ${counterId}`,
});
Once the effect/action functions have succeeded, the library will remove the counter.
Is important to note that delete tasks do not have a target
property in their context argument as that value is undefined for this type of operation.
In some cases, we may want to create tasks that are applicable to any operation, because we want to deal with complexity inside the task functions or some other reason. In that case, Mahler allows to use *
(wildcard) as the value of the operation
value. These tasks will be tried by the planner if the path for the pending operation matches, no matter what the operation is. As with delete
operations, wildcard tasks don't have a context target
property. Unlike delete
operations though, the library will not automatically delete the value once the effect/action functions are executed. Here is how we would define our delCounter
task as a wildcard task.
const delCounter = Task.of<System>().from({
op: '*',
lens: '/counters/:counterId',
// Do not use the task if the counter was already deleted
condition: (counter) => counter != null,
effect: (counter) => {
// we need to manually delete the counter here as the library cannot infer this step for wildcard tasks
counter.delete();
},
description: ({ counterId }) => `delete ${counterId}`,
});
The View
type provides the convenience delete()
method for such scenario.
Note
As wildcard tasks are applicable to any operation, restricting the condition is very important to prevent the planner from repeatedly chosing the task.
What if for a given operation we have multiple tasks that are applicable? For instance, for an update
operation on path /counters/a
, we could have a task that applies to the path /counters
(as with the parallel update example) and a task that applies to a given counter (like plusOne
). In that case, the planner will try to apply the task that applies to highest level path first (/counters
in this case), only if that fails, it will try the task that is applicable to /counters/a
.
In fact, when comparing the current state and target state, the planner will look for task that fits on every level. With our previous example, when comparing { counters: { a: 0 } }
and { counters: { a: 0, b: 1 } }
the planner will start from the root path looking for applicable operations, as shown below.
[
{ op: 'update', path: '/', value: { counters: { a: 0, b: 1 } } },
{ op: 'update', path: '/counters', value: { a: 0, b: 1 } },
{ op: 'create', path: '/counters/b', value: 1 },
];
Let's look at a couple more examples
Comparing { a: {} }
to { a: { b: { c: 0 } } }
, will cause the planner to look for tasks applicable to the following operations
[
{ op: 'update', path: '/', value: { a: { b: { c: 0 } } } },
{ op: 'update', path: '/a', value: { b: { c: 0 } } },
{ op: 'create', path: '/a/b', value: { c: 0 } },
];
We can see above that the planner will stop the search when a create
operation is found.
Comparing { a: { b: { c: 0 } } }
to { a: { b: { c: 1 } } }
, will cause the planner to look for tasks applicable to the following operations
[
{ op: 'update', path: '/', value: { a: { b: { c: 1 } } } },
{ op: 'update', path: '/a', value: { b: { c: 1 } } },
{ op: 'update', path: '/a/b', value: { c: 1 } },
{ op: 'update', path: '/a/b/c', value: 1 },
];
For delete
operations, the planner will expand the search to sub-elements of the deleted property. For example, comparing { a: { b: { c: { d: 'e' } } } }
to { a: { b: UNDEFINED } }
, will cause the planner to look for tasks applicable to the following operations
[
{ op: 'update', path: '/', value: { a: {} } },
{ op: 'update', path: '/a', value: {} },
{ op: 'delete', path: '/a/b' },
{ op: 'delete', path: '/a/b/c' },
{ op: 'delete', path: '/a/b/c/d' },
];
This is because of the implied hierarchy in the state, deleting a value of type /a/b
may need to make sure that no sub-elements of type c
exist before the delete can take place. This is similar to a database, where deletes of related entities require cascading deletes.
The planner doesn't enforce any hierarchy though, so it is up to the developer to add conditions to tasks to let the planner know if certain delete sequence is necessary.
As mentioned before, the planner uses root to leaf precedence related to changing paths. When multiple tasks are applicable to the same path, the planner will use Method tasks first and then chose tasks in order of insertion in the task array given to the planner.
Tasks can be grouped in a domain for less verbose code
import { Domain } from 'mahler';
const MySystem = Domain.of<System>();
const myTask = MySystem.task({
// task definition here
});
This is also true for sensors, which we describe in the next section
const mySensor = MySystem.sensor({
// sensor definition here
});
We are almost done introducing concepts, before continuing however, let's move to a better example. Let's write an agent for a simple space heater controller. The heater design is very simple, it is composed by a resistor that can be turned ON or OFF to heat the room, and a termometer that detects the room temperature. The heater interface allows to set a target room temperature. The controller will turn the resistor ON if the temperature is below target or OFF if the temperature is above target.
Let's start first by modelling the state. As the per the hardware design, the state needs to keep track of the resistor state and the room temperature.
import { Domain, Agent } from 'mahler';
type Heater = { roomTemp: number; resistorOn: boolean };
// We'll reuse the type name for the domain
const Heater = Domain.of<Heater>();
Now let's define a task for turning the heater ON.
const turnOn = Heater.task({
// Only run this task if the room temperature is below target
condition: (state, { target }) =>
state.roomTemp < target.roomTemp && !state.resistorOn,
// What should the planner expect after running this task
effect: (state, { target }) => {
// Turning the resistor ON does not change the temperature
// immediately, but the effect is that the temperature 'eventually'
// will reach that point
state._.roomTemp = target.roomTemp;
state._.resistorOn = true;
},
// The actual operation that will be executed by the plan runner
action: async (state) => {
// Call the I/O operation to turn the resistor on
await turnResistorOn();
state._.resistorOn = true;
},
// A description of the task to use for logging purposes
description: 'turn resistor ON',
});
We also need to define another task to turn the heater resistor OFF.
const turnOff = Heater.task({
condition: (state, { target }) =>
state.roomTemp > target.roomTemp && !!state.resistorOn,
effect: (state, { target }) => => {
state._.roomTemp = target.roomTemp;
state._.resistorOn = false;
},
action: async (state) => {
await turnResistorOff();
state._.resistorOn = false;
},
description: 'turn resistor OFF',
});
These two tasks specify the case where the temperature is off-target and the resistor is in the wrong state, however, what happens if the resistor is in the right state, but the temperature just has not been reached yet? In that case the planner would not be able to find any applicable tasks and fail. We can solve this by defining a task to handle this case.
const wait = Heater.task({
condition: (state, { target }) =>
// We have not reached the target but the resistor is already OFF
(state.roomTemp > target.roomTemp && !state.resistorOn) ||
// We have not reached the target but the resistor is already ON
(state.roomTemp < target.roomTemp && !!state.resistorOn),
effect: (state, { target }) => {
state._roomTemp = target.roomTemp;
},
// Nothing to do here, we let the agent wait
action: async () => void 0,
description: 'wait for temperature to reach target',
});
Now, these tasks define the behaviour of our agent under certain conditions, but the agent cannot control the temperature of the room, it can only measure it at certain points in time. For this sort of situation, Mahler provides the Sensor
interface.
// Sensor.of<Heater>.from works here too
const temperature = Heater.sensor({
// The sensor returns values to set the path given by the lens below
lens: '/roomTemp',
// The sensor is a generator function that yields values obtained from
// the measuring hardware
sensor: async function* () {
while (true) {
yield await readTemperature();
await setTimeout(1000);
}
},
});
To define the sensor, we need to provide it with a generator function, which can be synchronous or asynchronous. Every time the generator yields a new value, this will tell the agent to update the part of the state indicated by the lens.
Now that we have our tasks and our sensor defined, we can create our heater agent.
const heaterAgent = Agent.from({
// Provide the initial state of the world
initial: { roomTemp: 10, resistorOn: false },
// The tasks that the agent uses for planning
tasks: [turnOn, turnOff, wait],
// The sensors the agent uses to read the system state
sensors: [termometer],
// The `follow` flag tells the agent to keep monitoring
// the state and re-plan if the state gets off-target.
// By default, the agent wil terminate as soon as the
// target has been reached.
opts: { follow: true },
});
With that we can start the Heater controller with a specified target.
// Set the heater target temperature to 23 degrees
heaterAgent.seek({ roomTemp: 23 });
The above instruction will start the agent and have it run forever (because of the follow: true
). The Heater will continue monitoring the room temperature and turning the resistor ON or OFF as the temperature goes outside the expected value.
What if we want other part of our system to act on temperature changes? Perhaps we need to send temperature updates to a backend, or update some UI with the current state of the system. Agents are observable, meaning we can subscribe to state updates. In the previous example
// Print the state every time it changes to the console
const subscription = heaterAgent.subscribe(console.log);
The subscribe
function receives an Observer (or just a function). The next
function will be called every time some part of the state changes.
The agent runtime will also monitor changes to the state that happens while an action is being executed, imagine you have a task with a long running action to fetch something from another server
const fetch = Task.from<System>({
// The rest of the task definition here
action: async (resource, { target }) => {
// The second argument to the function is a callback to
// provide progress updates
const blob = await fetchData(target.URL, (progress) => {
// Store the progress as part of the system state
resource._.downloadProgress = progress;
});
},
});
In the case above, an agent when calling an action from the above task will update subscribers every time the download progress is updated.
In order to unsubscribe from agent updates, we can use the unsubscribe
method of the returned subscription.
subscription.unsubscribe();
A Mahler agent can be given a trace
function, which will be called on different agent runtime events. This function can be used for traceability/observability into the agent runtime. A readableTrace function is provided under mahler/utils
for human readable logs.
import { readableTrace } from 'mahler/utils';
const agent = Agent.from({
initial: 0,
tasks: [
/* task list */
],
// console conforms to the Logger interface
opts: { trace: readableTrace(console) },
});
This concludes the introduction of concepts. Here is some troubleshooting in case your agents are not working as expected.
While the planner has some loop detection capabilities, it can only detect if an action is being applied on the same state twice. This means there can be actions that are considered applicable and modify the state without actually contributing to the overall target. In this case, the action may be added forever (until the maximum stack depth is reached). If this is happening to you, check the condition for the task that is being added (you'll see it in the planner logs), it is possible that the condition is not restrictive enough if the task is being chosen each time. A good heuristic for conditions is that the condition should eventually become false after applying the task a number of times.
To avoid looping forever, the planner allows a maximum search depth of size 1000
, this can be configured via the planner constructor
import { Planner } from 'mahler/planner';
import { Agent } from 'maher';
const planner = Planner.from({
tasks: [
/* tasks here */
],
config: { maxSearchDepth: 10000 },
});
const agent = Agent.from({
initial: {
/* initial state here */
},
planner,
});
If you see unexpected values in the state after the agent runs, check that you are not modifying parts of the state in the condition
or method
functions. These functions are expected to treat the state as read-only, but this is not enforced during planning or execution context to avoid making unnecessary copies of the state. This means if you accidentally modify a part of the state within these functions, that change may be reflected in the end state.
As mentioned in the Task precedence section, when multiple tasks are applicable during the plan search, the planner will chose whichever comes first in the given array. For now the planning algorithm is fairly basic and it does not backtrack to check that it has the shortest version of the path. If the returned plan is not what expected, it is recommended to use methods to guide the planner towards more optimal solutions.
Let's now write an agent that monitors the connectivity of a system and switches the Wi-Fi network from a list of SSIDs if Internet connectivity is lost. As before, the first step is to declare the shape of the state.
import { Domain, Agent } from 'mahler';
type NetworkId = string;
type NetworkAuth = {
ssid: string;
psk: string;
// To indicate if we have already
// authenticated with the network
authenticated?: boolean;
// We are connected to this network.
connected?: boolean;
};
type Network = {
ssid: string;
psk: string;
// Indicates if the network is reachable a range 0 to 100%
// 0% means the network is out of reach
signal: number;
};
// The state represents everything the agent
// knows about the world
type State = {
currentNetwork?: NetworkId;
// We not only are connected, but we have access to the internet
internetAccess: boolean;
knownNetworks: { [id: NetworkId]: NetworkAuth };
localNetworks: { [id: NetworkId]: Network };
};
const NetworkManager = Domain.of<State>();
Now we define tasks for the agent. First we need a way to tell the planner to update the list of known networks.
const addNetwork = NetworkManger.task({
// This tells the planner to only consider this task if the state
// is performing a create operation. This also means the task will only be chosen
// if the network with the given id is undefined (the default condition)
op: 'create',
// This tells the planner that the task applies to a change in the known network
// list. The `:id` variable will provided as part of the context to the tasks
lens: '/knownNetworks/:id',
// The effect of the task is that the target network should now be part
// of the known network list. The `network` context object provides the
// functional lens funtions `set` and `get` that allow to easily modify
// a specific part of the state
effect: (network, { target }) => {
network._ = target;
},
// The action is what will be run by the task planner if the action gets selected.
action: async (state: State, network) => {
/* TODO: actually store the network in a local database */
network._ = target;
},
// We can add a description that will be used in the logs
description: (ctx) => `create network ${ctx.id}`,
});
In the given code, the op: 'create'
tells the planner that this task is applicable only if the given target is introducing a new entry to the /knownNetworks
dictionary. The lens
object, tells the planner that the task is relevant for changes to a specific portion of the state object. In the example above, the path: '/knownNetworks/:id'
tells the planner the task is only relevant for values of the knownNetworks
dictionary.
Now let's define tasks to authenticate and connect to a specific network.
const authenticate = NetworkManager.task({
// This task applies to a specific network
lens: '/knownNetworks/:id',
// Only run if the network is within reach and not authenticated
condition: (network, { id, system }) =>
system.localNetworks[id]?.signal > 0 && !network.authenticated,
// The task has the effect of modifying the network state to authenticate
effect: (network) => {
network._.authenticated = true;
},
// The action interacts with the system to authenticate with the SSID
action: async (network) => {
/* TODO: actually authenticate to the network */
network._.authenticated = true;
},
description: (network) => `authenticate network ${network.id}`,
});
const connect = NetworkManager.task({
// This task applies to a specific network
lens: '/knownNetworks/:id',
// Only run if the network is not connected and the signal is at least 20%
condition: (network, { id, system }) =>
system.localNetworks[id]?.signal > 20 &&
network.authenticated &&
!network.connected,
// The task has the effect of modifying the network state to connected
effect: (network, { system, id }) => {
network._.connected = true;
system.currentNetwork = id;
},
// The action interacts with the system to switch SSIDs
action: async (state: State, { id, system }) => {
/* TODO: actually connect to the network */
network._.connected = true;
// Update the current network in the system state
system.currentNetwork = id;
},
description: (network) => `connect to network ${network.id}`,
});
The two tasks above define how to perform the connection and authentication operations against a specific SSID, however we usually will want to run both if the network is not connected. Here is where methods are useful, we can define a method that will try to authenticate first and then connect.
const authenticateAndConnect = NetworkManager.task({
lens: '/knownNetworks/:id',
// Run this method if the network is not connected and the signal is good
condition: (network, { id, system }) =>
system.localNetworks[id]?.signal > 20 && !network.connected,
method: (network, { id, target }) => {
const tasks: Array<Action<State>> = []; // A method returns a list of tasks
if (network.authenticated) {
// Add an authentication step to the list of tasks
// Calling the the `authenticate` task as a function "grounds" the task
// with the specified context
tasks.push(authenticate({ id, target }));
}
// Now add the connect instruction
tasks.push(connect({ id, target }));
return tasks;
},
});
Since the method is applicable to the same path than the previously defined tasks, the planner will prefer it over the individual tasks.
Our planner needs to switch networks if Internet access is lost, so we need to define a method to chose between the list of available known networks for those that are in reach and try them.
const switchNetworks = NetworkManager.task({
// Only run if the system lost internet access
condition: (state) => !state.internetAccess,
// Chose from the list of known networks one different than the current
// network and that has signal
method: (state) => {
// we find the first network with good enough signal that is not
// the current network and it is in the list of known networks
// NOTE: a better approach could be to sort the networks by signal id
const networkId = Object.keys(state.localNetworks).find(
(id) =>
id != state.currentNetwork &&
state.localNetworks[id].signal > 20 &&
id in state.knownNetworks,
);
if (!networkId) {
// We return an empty array to tell the planner that this method
// is not applicable to the current state
return [];
}
// We found a candidate
return [
authenticateAndConnect({
id: networkId,
target: state.knownNetworks[networkId],
}),
];
},
});
Finally we need to define some sensors, one to scan for local networks, and one to test the internet connectivity of the system.
const networkScanner = NetworkManager.sensor({
lens: '/localNetworks',
sensor: async function*() {
while (true) {
const localNetworks = await scanNetworks();
yield localNetworks;
// Scan the network again in 60 seconds
await setTimeout(60 * 1000);
}
});
const connectivityCheck = NetworkManager.sensor({
lens: '/internetAccess',
sensor: async function *() {
while (true) {
// pings www.google.com and get connectivity
const hasConnectivity = await testConnectivity();
yield hasConnectivity;
// Check the connectivity again in 60 seconds
await setTimeout(60 * 1000);
}
});
Now that we have all our tasks and sensors defined, we can define our agent
const WifiConnect = Agent.from<State>({
initial: { internetAccess: false, knownNetworks: {}, localNetworks: {} },
tasks: [
addNetwork,
connect,
authenticate,
authenticateAndConnect,
switchNetworks,
],
sensors: [networkScanner, connectivityCheck],
opts: {
// We want the agent to run forever
follow: true,
// And keep retrying on failure (this is the default)
maxRetries: Infinity,
},
});
Now we start can start the agent with the initial target. As this introduces new networks and requires that the system is connected, this will add the networks to the internal database, perform authentication tasks and connect to the first network that is available.
WifiConnect.seek({
connected: true,
knownNetworks: {
home1: { ssid: 'My Home', psk: '' },
office1: { ssid: 'First floor', psk: '' },
office2: { ssid: 'Second floor', psk: '' },
},
});
We can modify the target after the agent has started. In this case we are adding a new network, which
will cause the agent to re-calculate the plan to include the addNetwork
task. Internally, this will stop
the currently running agent execution and trigger a search for a new target.
WifiConnect.seek({
connected: true,
knownNetworks: {
home1: { ssid: 'My Home', psk: '' },
office1: { ssid: 'First floor', psk: '' },
office2: { ssid: 'Second floor', psk: '' },
office3: { ssid: 'Kitchen', psk: '' },
},
});
You can see more examples in the planner unit tests, the agent unit tests or the service composition and service orchestration tests under the tests folder.
To run the examples, use the following
# Install dependencies
npm install logging
# Enable logging (options: trace,debug,info,warn,error)
export DEBUG=mahler:error,mahler:warn,mahler:info
# Run integration tests
npm run test:integration