This article is a part of the systemic-ts series.
systemic

SystemicTS: Agency and Systemic Agents

Approaching system design with agency in mind is crucial. Agency is the various aspects of an agent, something that works on behalf of the other. In the computer program context, an agent works on behalf of the user.

An agent within a program is usually a long-running and concurrent process that has a part (or sometimes the whole) of the user's purpose.

While crucial, agency cannot be explained clearly because there is no single implementation that can fulfill all its aspects.

  • Modern OOP (as opposed to Alan Kay's OOP) tried to imply and activeness of an object by enabling methods.
  • Multithreading tried to allow two sub-programs to run concurrently, allowing programmers to assume an active role within a system of several active roles. Messaging between agents would later take the form of Mutex, Channels, Semaphore, etc.
  • Agent-oriented programming essentially is a pattern to capture and synthesize the aspects of agents and agency.
  • Beliefs (e.g. over the meaning of signals, environment), capabilities, commitments, and message passing are partially fulfilled by the likes of interface, typings, serialization/deserialization, etc, network.

A more concretely refined definition of agent I prefer is the independent multithreading-enabled self-centered code style combined with its outer shell interface definition reinforced with static typing, which looks like:

// pseudocode
thread.spawn(() => {
  // I'll do my own stuff here
  // regardless of my parent's state
});

Merits of Previous Tips

If you have not visited the previous three articles, I suggest doing so:

The merits of the three previous articles, the complication articles, and possibly the next articles culminate here. In building systems, the concept of agents eventually arises to combat the growing scale of the purpose of a system.

An agent, or more specifically the maintainer of a program-agent, the programmer, can only fit a number of symbols in one's memory at a time (i.e. name and meaning of types, functions, modules, routines, development processes, etc). One has to wrap a part of the purpose of the whole program into an agent, a sub-process that enacts tasks without direct supervision, and either report back or be examined at a lower intensity and frequency.

"Too complex" is a common complaint indicating that a new agent is required to bear a part of the purpose. Isn't it convenient, the idea of being able to specifically "Please take care of this stuff for me". Fire and forget.

// "Please take care of OSNotification for me"
const osNotificationWorker = OSNotificationWorker.make();

The main point of systemic-ts is to design agents that work well and are easily maintained. The Throwless Pact's absence of throw, combined with runtime type validation provides the reliability of agents. The signature transparency of The Throwless Pact aids by quickening the process of understanding agents' essence, and purpose, without having to scrutinize the implementation detail. Last, but not least, closure over class provides a quicker way to iterate while developing and maturing an agent.

In the end, an agent is a system that may reside within another bigger system. It is by principle must not be scriptic.

Agent as Cybernetic Loop

The good news is: JavaScript has a simple yet powerful async function that allows modeling concurrent agents. TypeScript completes the static typing part.

But before we dive into the core idea of this article, have you ever seen the pattern below?

function doStuffIndefinitely() {
  doStuff();
  setTimeout(doStuffIndefinitely, 100);
}

That pattern is a prototype of what I prefer to call the cybernetic loop--this article's core idea.

Cybernetic is about circular repeating processes. Norbert Wiener named it kubernētikēs (meaning steering) inspired by the phenomenon where a helmsman's steering behavior is affected by his observation of the effect of his own past steering on the current ship's course.

The helmsman's cybernetic loop is a loop containing the routine of: observe, steer, observe, steer, observe, steer, and so on. Put abstractly, a cybernetic loop contains the routine of its agent.

Let's cut to the chase and convert the above "prototype" code into a loop.

const doStuffIndefinitely = async () => {
  while (true) {
    doStuff();
    await sleep(100);
  }
};

// helper
const sleep = (dur: number) => new Promise((res) => setTimeout(res, dur));

In a high-level language such as JavaScript, this loop is not as common of a pattern as it is in lower-level languages (e.g. C, C++, Rust). In JavaScript, functionalities that require such a loop have been enabled by its internal event loop.

However, JavaScript's event loop's capability is limited, so people start to form the loop's "prototype" to make up for the missing features.

The cybernetic loop is a powerful model for long-running processes such as:

  • a game engine's main loop
  • an HTTP server
  • a controller within a pipeline
  • fiber processor.

Let us see how easy to add a feature to a cybernetic loop.

External and internal termination can be added just in several lines.

const QuittableAgent = async (externalKillSignal: () => bool) => {
  while (!externalKillSignal()) {
    const res = await work();
    if (res === QUIT) break;
    await sleep(0);
  }
};

Adding a state would allow advanced mechanisms such as self-regulation.

const SelfRegulatingAgent = async (externalKillSignal: () => bool) => {
  const state = makeState();
  while (!externalKillSignal()) {
    const res = await work(state);
    if (res === QUIT) break;
    await sleep(0);
  }
};

Finally, an external could be established by means of an input buffer. Add a bit of an end-related feature (e.g. waiting for the agent to shut down).

const InteractibleAgent = (externalKillSignal: () => bool) => {
  const input = makeInputBuffer();
  const state = makeState();

  const end = (async () => {
    while (!input.externalKillSignal() && !externalKillSignal()) {
      const res = await work(state, input);
      if (res === QUIT) break;
      await sleep(0);
    }
  })();

  let endFlag = false;
  const isEnded = () => endFlag;

  end.finally(() => (ended = true));

  return { input, end, isEnded };
};

The above agent is interactible this way.

const agent = InteractibleAgent();
// issue command on agent
await agent.input.someCommand();
// retrieve data from agent
const agent = input.getSomeValue();
// end agent
agent.input.kill();
// wait for the agent to end
await agent.end;

Keep note that these forms are simplifications.

For example, input, state, and work(state, input) will be more complex in the real world. input for example should be properly typed so that parties that interact with the agent can know the agent's capability.

Another important point is that this pattern naturally leads the programmer to program input (or any externally available methods) as a buffer or a cache. This makes the agent more resilient toward mischievous external parties (e.g. if one of the methods is spammed)

Sub agents

Cybernetic loop pattern is compatible with a hierarchical structure. Let us take an example of a parallel processing hub, a small machine that reroutes requests to other powerful processors. The processors work at different paces so the hub needs to communicate with each processor asynchronously.

const Hub = (params: {
  processorsAddresses: string[];
  requestPort: Port;
  offloadPort: Port;
  externalKillSignal: () => bool;
}) => {
  // Generate an agent for each processorsAddresses
  // 'agents' variable is a container of agents with helper
  // methods to find agents of various statuses
  const agents = ProcessorAgents.makeBulk(
    params.processorsAddresses,
    params.externalKillSignal
  );

  while (!params.externalKillSignal()) {
    // Offload done work to offload port
    const doneWorkings = agents.findAwaitingForOffload();
    doneWorkings.forEach((agent) => agent.offloadFrom(params.offloadPort));

    // For each idle agent, supply it with a request
    const readyForWorks = agents.findIdleAgents();
    readyForWorks.forEach((agent) => agent.startWorkFrom(params.requestPort));

    await sleep(500);
  }
};

This setup is nice, the hub is not blocked by neither the Ports nor the ProcessorAgents--no bottlenecks.

It does not have to worry about the ProcessorAgents timing because we assume that these processors self-regulate. For example, 1.) they signal their state accurately, whether they are ready to take another request or ready to offload so that findAwaitingForOffload and findIdleAgents filters the correct agents, or 2.) that malicious calls to startWorkFrom or offloadFrom are handled well internally.

Explicit Lifetime Management and Agents

The idea of explicit lifetime management has been briefly visited in the closure-over-class, but now let us revisit the idea and relate it to agents and sub-agents.

Unless demanded by exotic requirement, generally and by default the sub-agents must not outlive their parent. This reasoning spans from the top-level or root-level agent to all leaves (if the hierarchical agents are perceived as a tree) so that the root lives the longest and encompasses all agents' lifetimes.

However, since an agent's control flow is detached from its ancestors, each parent is responsible for explicitly stopping its children and waiting for their lifetime to end before ending their own lifetime. Therefore it is necessary for each agent to have a destroy method that is only exposed to the parent.

Poetically, the root-level destroy "method" of a GUI application is the close (X) button that the user, its parent, can click before the user turns off the PC and continues with life.

Illusion of Task

Since an agent is long-running and concurrent, it must also be asynchronous in TypeScript/JavaScript world. This notion is not limited to the TypeScript/JavaScript world and is often the primary cause of confusing an agent with a task.

To counter-argue my previous point that "sub-agents" must not outlive their parent, I am tempted to say that nowadays in the cloud-pervasive world, we often spawn a remote agent that works for us while its parent is sleeping. Particularly, I am talking about spawning in-cloud rent computers e.g. AWS, Azure, and Google's virtual machines.

But a computer we issue remotely is not purely an agent of us. In a way, what we do is issue a task to a third-party provider, which is put into practice by spawning an agent of theirs as an agent of ours.

I encourage you to meditate on this phenomenon and put it back in the context of agents within programs, or even within a TypeScript-based program.

For an entity that could be an agent, could it be represented as a task instead?

This question is important because agents need explicit lifetime management and manually managing lifetime isn't an easy task. Thus, a program could be modeled as agents passing tasks (and messages in general) to one another. This brings us back to the first inspiration of OOP, but not having to develop a whole new language and falling into the traps such as prematurely marrying inheritance to the language, or prematurely marrying module visibility with data encapsulation, or deciding that everything is an object!

systemic