Skip to content

Commit

Permalink
Add feature to seek a strict target with an Agent
Browse files Browse the repository at this point in the history
A Target in Mahler is by default 'relative', meaning that only property
changes and additions should be considered when comparing current and
target states for planning. Property deletion need to be done explicitely
via the `UNDEFINED` symbol. This allows a cleaner interface for for
defining system targets and allows the system state to have additional properties
than the target.

A 'relative' target is the opposite to a 'strict' (or absolute) target, where what is passed to
the planner/agent describes exactly the desired state of the system is.

This PR introduces the `StrictTarget` type and the `seekStrict` method
on the `Agent` interface. This allows users some additional flexibility
to specify an absolute state of the final system.

Example: let's say we are modelling the state of two variables `x` and `y`.

Given the current state `{x: 0}`, the target state `{y: 1}` means that the
planner needs to only to find a task that can create the variable `y` and increase its
value to `1`. The final expected state should be `{x: 0, y:1}` (assuming nothing else changes `x`).

If the goal was to remove the variable `x` at the same time that variable `y` is introduced, the
relative target would need to be `{x: UNDEFINED, y: 1}`

The equivalent strict target in this case is just `{y: 1}`.

Change-type: minor
  • Loading branch information
pipex committed Apr 9, 2024
1 parent fc71930 commit 56ce7ba
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 24 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,17 @@ agent.seek({ counter: 3, 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

```ts
// 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](#operations).

One last thing before moving on from this topic. What if you assign a required value the value of `UNDEFINED`?
Expand Down
17 changes: 17 additions & 0 deletions lib/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,23 @@ describe('Agent', () => {
agent.stop();
});

it('it should terminate once the strict target has been reached', async () => {
const roomTemp = 30;
resistorOn = true;
const agent = Agent.from({
initial: { roomTemp, resistorOn },
tasks: [turnOn, turnOff, wait],
sensors: [termometer(roomTemp)],
opts: { minWaitMs: 10, logger },
});
agent.seekStrict({ roomTemp: 20, resistorOn: false });
await expect(agent.wait(1000)).to.eventually.deep.equal({
success: true,
state: { roomTemp: 20, resistorOn: false },
}).fulfilled;
agent.stop();
});

it('it should allow observers to subcribe to the agent state', async () => {
const roomTemp = 18;
resistorOn = false;
Expand Down
80 changes: 59 additions & 21 deletions lib/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Subscribable } from '../observable';
import { Subject } from '../observable';
import { Planner } from '../planner';
import type { Sensor } from '../sensor';
import type { Target } from '../target';
import type { Target, StrictTarget } from '../target';
import type { Task } from '../task';
import { Runtime } from './runtime';
import type { AgentOpts, Result } from './types';
Expand Down Expand Up @@ -88,7 +88,7 @@ export * from './types';
*/
export interface Agent<TState = any> extends Subscribable<TState> {
/**
* Tells the agent to seek a new target.
* Tells the agent to seek a new (relative) target.
*
* The method doesn't wait for a result.
*
Expand All @@ -100,6 +100,23 @@ export interface Agent<TState = any> extends Subscribable<TState> {
*/
seek(target: Target<TState>): void;

/**
* Tells the agent to seek a new strict target.
*
* A strict target means that the final state of the agent should
* look exactly like the given target, with the exception of those properties
* matching one of the `strictIgnore` globs in the Agent options.
*
* The method doesn't wait for a result.
*
* If the agent is already seeking a plan, this will cancel
* the current execution and wait for it to be stopped
* before starting a new run.
*
* @param target - The target to seek
*/
seekStrict(target: StrictTarget<TState>): void;

/**
* Wait for the agent to reach the given target or
* terminate due to an error.
Expand Down Expand Up @@ -191,6 +208,7 @@ function from<TState>({
maxWaitMs: 5 * 60 * 1000,
minWaitMs: 1 * 1000,
backoffMs: (failures) => 2 ** failures * opts.minWaitMs,
strictIgnore: [],
...userOpts,
logger: { ...NullLogger, ...userOpts.logger },
};
Expand All @@ -212,27 +230,47 @@ function from<TState>({

let setupRuntime: Promise<Runtime<TState> | null> = Promise.resolve(null);

return {
seek(target) {
// We don't want seek to be an asynchronous call, so we
// wrap the runtime in a promise. This way, we can ensure
// that operations are always working on the right runtime,
// when the target changes or the agent is stopped
setupRuntime = setupRuntime.then((runtime) => {
// Flatten the promise chain to avoid memory leaks
setupRuntime = (async () => {
if (runtime != null) {
await runtime.stop();
state = runtime.state;
}
function seekInternal(target: Target<TState>, strict: false): void;
function seekInternal(target: StrictTarget<TState>, strict: true): void;
function seekInternal(
target: Target<TState> | StrictTarget<TState>,
strict: boolean,
) {
// We don't want seek to be an asynchronous call, so we
// wrap the runtime in a promise. This way, we can ensure
// that operations are always working on the right runtime,
// when the target changes or the agent is stopped
setupRuntime = setupRuntime.then((runtime) => {
// Flatten the promise chain to avoid memory leaks
setupRuntime = (async () => {
if (runtime != null) {
await runtime.stop();
state = runtime.state;
}

runtime = new Runtime(subject, state, target, planner, sensors, opts);
runtime.start();
runtime = new Runtime(
subject,
state,
target,
planner,
sensors,
opts,
strict,
);
runtime.start();

return runtime;
})();
return setupRuntime;
});
return runtime;
})();
return setupRuntime;
});
}

return {
seek(target) {
seekInternal(target, false);
},
seekStrict(target) {
seekInternal(target, true);
},
stop() {
void setupRuntime.then((runtime) => {
Expand Down
19 changes: 16 additions & 3 deletions lib/agent/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type { EmptyNode, Planner } from '../planner';
import { Node, SearchFailed } from '../planner';
import { Ref } from '../ref';
import type { Sensor } from '../sensor';
import type { Target } from '../target';
import type { StrictTarget } from '../target';
import { Target } from '../target';
import type { Action } from '../task';
import { observe } from './observe';

Expand Down Expand Up @@ -59,10 +60,11 @@ export class Runtime<TState> {
constructor(
private readonly observer: Observer<TState>,
state: TState,
private readonly target: Target<TState>,
private readonly target: Target<TState> | StrictTarget<TState>,
private readonly planner: Planner<TState>,
sensors: Array<Sensor<TState>>,
private readonly opts: AgentOpts,
private readonly strict: boolean,
) {
this.stateRef = Ref.of(state);
// add subscribers to sensors
Expand Down Expand Up @@ -102,7 +104,18 @@ export class Runtime<TState> {
return ['delete', o.path];
};

const changes = diff(this.stateRef._, this.target);
let target: Target<TState>;
if (this.strict) {
target = Target.fromStrict(
this.stateRef._,
this.target as StrictTarget<TState>,
this.opts.strictIgnore,
);
} else {
target = this.target;
}

const changes = diff(this.stateRef._, target);
logger.debug(
`looking for a plan, pending changes:${
changes.length > 0 ? '' : ' none'
Expand Down
6 changes: 6 additions & 0 deletions lib/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface AgentOpts {
* A Logger instance to use for reporting
*/
logger: Logger;

/**
* List of globs to ignore when converting a strict target to a relative target
* when using `seekStrict`
*/
strictIgnore: string[];
}

/**
Expand Down

0 comments on commit 56ce7ba

Please sign in to comment.