String Assembler: Reusing Behaviours in Cooperating Agents

A small production line turns ASCII codes into a printed string. A Generator converts numbers to characters, an Assembler joins the characters, and a Printer displays the result. The work is simple enough to follow while comparing three ways to organise it: one agent, a pipeline of workers, and workers coordinated by a manager.

This example builds on Reusing behaviour and Building cooperating agents. It uses ASTRA 2.6.1, Java 17 or later, and Maven.

Run the examples

Download the complete String Assembler project, extract it and run these commands from its folder:

mvn
mvn -Dastra.main=Pipeline
mvn -Dastra.main=Managed

The default startup agent is Standalone. Each version processes three jobs in order: the codes for hello, an invalid code (10, newline), and the codes for ASTRA. Each run finishes automatically. No external services are needed.

The standalone output is:

[main] Result of job 1 is:[hello]
[main] Job complete: 1
[main] Job failed: 2
[main] Result of job 3 is:[ASTRA]
[main] Job complete: 3

The two distributed versions produce the same results, with [printer] on the lines that print strings. Scheduler and messaging startup lines may also appear.

Define the reusable behaviours

The three behaviour classes are shared by every version. They do their work through goals, so their callers can use them locally or expose them through message-handling plans.

Messages.astra supplies formula declarations for the message vocabulary. The common classes inherit it, making that vocabulary available to their subclasses. Its declarations describe content such as generate(int, list) and printed(int); they do not automatically create beliefs or execute goals.

Generate characters

agent Generator extends Messages {
    module assembly.Ascii ascii;

    plan +!generate(list codes, list letters) {
        letters = [];
        forall (int code : codes) {
            if (code < 32 | code > 126) { fail; }
            letters = letters + [ascii.character(code)];
        }
    }
}

The output variable letters is filled by the goal. Printable ASCII codes range from 32 (space) to 126 (~). A code outside that range fails the goal. The example supports printable ASCII only, rather than arbitrary Unicode text.

The small Java module performs the numeric-to-character conversion:

package assembly;

import astra.core.Module;

/** Conversion is called only after Generator checks the printable ASCII range. */
public class Ascii extends Module {
    @TERM
    public char character(int code) {
        if (code < 32 || code > 126) {
            throw new IllegalArgumentException("Expected printable ASCII (32 to 126)");
        }
        return (char) code;
    }
}

The ASTRA plan validates codes before calling the module; the module also checks the range if used directly. Both files are included in the Maven project.

Assemble a string

agent Assembler extends Messages {
    plan +!assemble([], string text) { text = ""; }

    plan +!assemble([char letter | list rest], string text) {
        !assemble(rest, string suffix);
        text = "" + letter + suffix;
    }
}

The empty-list plan ends the recursion. The second plan splits a list into its first character and its remaining characters, assembles the remainder, then prepends the first character. For example, ['h', 'e', 'l', 'l', 'o'] produces hello. An empty list produces an empty string. This recursive implementation is intended for short teaching examples.

Version 1: one agent

agent Standalone extends Generator, Assembler, Printer {
    module System system;

    plan +!main(list args) {
        !process(1, [104, 101, 108, 108, 111]);
        !process(2, [10]);
        !process(3, [65, 83, 84, 82, 65]);
        system.exit();
    }

    plan +!process(int id, list codes) {
        try {
            !generate(codes, list letters);
            !assemble(letters, string text);
            !printString(id, text);
            console.println("Job complete: " + id);
        } recover {
            console.println("Job failed: " + id);
        }
    }
}

Multiple inheritance makes all three goals available to Standalone. A single intention performs generation, assembly and printing in sequence. Output variables pass the intermediate results between subgoals.

The try/recover block surrounds one job. When generation fails for job 2, that job reports failure and the main plan continues with job 3. None of the behaviour classes needs to know which caller adopted its goal.

Version 2: a worker pipeline

The client requests generation; workers forward each result to the next stage, and the printer reports completion to the client.

The Pipeline startup agent creates three workers named generator, assembler and printer. It then uses the shared Client behaviour to submit jobs to the generator. These are agents in the same JVM, communicating through ASTRA messages.

The generator handles a request by adopting its inherited goal and forwarding the resulting list:

agent PipelineGenerator extends Generator {
    plan @message(request, string sender, generate(int id, list codes)) {
        send(agree, sender, generate(id));
        try {
            !generate(codes, list letters);
            send(request, "assembler", assemble(id, letters, sender));
        } recover {
            send(failure, sender, printed(id));
        }
    }
}

agree acknowledges receipt; it does not mean the job has completed. The assembler joins the characters and requests printing:

agent PipelineAssembler extends Assembler {
    plan @message(request, "generator", assemble(int id, list letters, string client)) {
        try {
            !assemble(letters, string text);
            send(request, "printer", printString(id, text, client));
        } recover {
            send(failure, client, printed(id));
        }
    }
}

The printer reports completion to the original client:

agent PipelinePrinter extends Printer {
    plan @message(request, "assembler", printString(int id, string text, string client)) {
        try {
            !printString(id, text);
            send(inform, client, printed(id));
        } recover {
            send(failure, client, printed(id));
        }
    }
}

Each forwarded request carries the client name. This lets the last stage send its result directly to the requester, and lets any failing stage report failure there. Each worker owns the decision to invoke the next stage.

Wait for the matching reply

agent Client extends Messages {
    module System system;
    module Console console;
    types replies { formula finished(int, boolean); }

    plan +!run(string receiver) {
        !submit(receiver, 1, [104, 101, 108, 108, 111]);
        !submit(receiver, 2, [10]);
        !submit(receiver, 3, [65, 83, 84, 82, 65]);
        system.exit();
    }

    plan +!submit(string receiver, int id, list codes) {
        send(request, receiver, generate(id, codes));
        try {
            wait(finished(id, boolean ok), 5000);
            -finished(id, ok);
            if (ok) { console.println("Job complete: " + id); }
            else { console.println("Job failed: " + id); }
        } recover {
            console.println("Job timed out: " + id);
        }
    }

    plan @message(agree, string sender, generate(int id)) { }
    plan @message(inform, "printer", printed(int id)) { +finished(id, true); }
    plan @message(inform, "manager", printed(int id)) { +finished(id, true); }
    plan @message(failure, string sender, printed(int id)) { +finished(id, false); }
}

The sender returns from send(...) before the work is complete. Receiving plans turn completion and failure messages into finished(id, ok) beliefs. The waiting intention resumes when the matching belief arrives, consumes it, and reports the outcome.

The five-second timeout bounds the wait when a worker does not respond. It does not cancel a job or stop a worker. Jobs are submitted sequentially here, using unique identifiers. A production application would also handle late replies, duplicate identifiers and cancellation.

Version 3: a manager coordinates the workers

The client sends a request to the manager, which requests generation, assembly and printing from independent workers.

Managed creates a manager and three workers. The client submits exactly the same jobs, but sends them to manager instead of generator.

The manager stores the requester for each job. It asks the next worker to act only after the previous worker has returned a result:

agent Manager extends Messages {
    types jobs { formula processing(int, string); }

    plan @message(request, string client, generate(int id, list codes)) {
        +processing(id, client);
        send(agree, client, generate(id));
        send(request, "generator", generate(id, codes));
    }

    plan @message(inform, "generator", generated(int id, list letters))
            : processing(id, string client) {
        send(request, "assembler", assemble(id, letters));
    }

    plan @message(inform, "assembler", assembled(int id, string text))
            : processing(id, string client) {
        send(request, "printer", printString(id, text));
    }

    plan @message(inform, "printer", printed(int id)) : processing(id, string client) {
        -processing(id, client);
        send(inform, client, printed(id));
    }

    plan @message(failure, string worker, printed(int id)) : processing(id, string client)
            & (worker == "generator" | worker == "assembler" | worker == "printer") {
        -processing(id, client);
        send(failure, client, printed(id));
    }
}

The managed workers return their results to the manager. They do not know which worker should act next. For example:

agent ManagedGenerator extends Generator {
    plan @message(request, "manager", generate(int id, list codes)) {
        try {
            !generate(codes, list letters);
            send(inform, "manager", generated(id, letters));
        } recover {
            send(failure, "manager", printed(id));
        }
    }
}

ManagedAssembler similarly returns assembled(id, text), and ManagedPrinter returns printed(id). Both use the same inherited behaviour goals as the pipeline workers. The manager removes its processing belief when it reports success or failure.

Design

Who chooses the next step?

How results move

Standalone

The agent’s !process(...) plan

Goal output variables

Worker pipeline

Each worker’s receiving plan

Messages directly to the next worker

Managed workers

The manager’s receiving plans

Results return to the manager

The manager provides a single place to change routing or choose workers. This example fixes worker names to keep that choice visible; it does not implement worker discovery, load balancing or retries.

Try a change

  • Submit [72, 105, 33] to print Hi!, or [] to print an empty string.

  • Replace 10 with 127 to exercise the upper range check.

  • Add a fourth job after the invalid one to see that failure affects only its own job.

  • Add a formatting behaviour before printing. Compare changing the standalone sequence, the pipeline routing, and the manager’s plans.

Verify the downloadable project

From a checkout of the documentation repository, run:

python3 scripts/verify_string_assembler.py

The checker verifies that the ZIP matches the source project, extracts it into a temporary folder, and runs all three designs. It also checks empty input, the printable ASCII boundaries, rejection above and below that range, and a successful job after a failed job. Add --offline when the Maven dependencies are already cached.

Relationship to the old guide

This adapts the String Assembler chapter archived on 6 August 2018. Its Core Behaviours and Revised Pipeline Code introduced the same distinction between reusable work and coordination.

The current example declares its message formulae, supplies the conversion module, bounds client waits and returns failure outcomes. It sends each list of characters in one message. The historical example streamed indexed characters followed by an end marker and assembled them from beliefs; that protocol adds buffering and completion concerns that can be explored as an extension. The diagrams above are new drawings of the adapted designs.