Skip to content

@shutter-network/concorde/agent-container

One Run in one fresh container, generic over which agent program runs in it. An Agent Container is the declaration of that container: the image, what it reaches on disk, the networks, the environment and the flags the framework does not model. It is inert, and creates nothing until a Run starts.

createAgentContainerRuntime is the entry point. It takes an AgentContainerRuntimeSpec, which is an AgentContainer beside one function that answers each Run with a RunPlan: what to put after the image, what to write on stdin, and how to read stdout. AgentContainerRuntime comes back, a Runtime the Signal Worker accepts, and commandFor on it composes one Run's ComposedCommand and starts nothing. MountTable is the disk half, one Mount per directory or file, and mountArguments turns a table into container arguments on its own.

Reach for this to drive an agent program this package does not adapt. For pi, @shutter-network/concorde/pi supplies that one function and two defaults, and takes an AgentContainer written exactly as it is written here. Nothing on this subpath names an agent program or reads a value one of them defines, so what the agent finds in its image and on its command line stays the author's to decide.

Nothing here reads the filesystem. createAgentContainerRuntime composes a command line once, at construction, so a declaration that cannot mean anything is refused where the Operator wrote it. Whether a path exists is the container runtime's answer, and it arrives at the first Run as a Run that failed and will not be retried. This subpath has no Component and no route, it does not use the Db, and it exports no schema.

Example

A Runtime for an agent program of your own: it takes the Prompt on stdin and prints what it said on stdout.

ts
import { createAgentContainerRuntime } from "@shutter-network/concorde/agent-container";
import { createGateway } from "@shutter-network/concorde/gateway";

const runtime = createAgentContainerRuntime({
  container: {
    image: "my-own-agent:1",
    networks: ["concorde_default"],
    // Only what is named here reaches the agent. None of the Gateway's own environment does.
    env: { MY_AGENT_KEY: process.env.MY_AGENT_KEY ?? "" },
    mounts: {
      // The host's path to the shared tree, and every entry written under it.
      runtimeDir: "/srv/concorde",
      entries: [
        { agentPath: "/workspace", path: "workspace" },
        { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true },
      ],
    },
  },
  // Called once per Run, and its result drives both the command line and the reading of stdout.
  run: (prompt) => ({
    args: ["--session", prompt.session],
    stdin: prompt.text,
    outcome: async (stdout) => {
      const chunks: Uint8Array[] = [];
      for await (const chunk of stdout) chunks.push(chunk);
      const said = Buffer.concat(chunks).toString("utf8").trim();
      // A bad stream is a failed Run and never a throw, which would kill the container.
      return said === "" ? { ok: false, error: "the agent said nothing" } : { ok: true };
    },
  }),
});

// The whole command line, with the defaults applied and every environment value hidden.
console.log(runtime.commandFor({ session: "notes", text: "say hello" }).redactedArgs);

const gateway = createGateway({
  databaseUrl: process.env.DATABASE_URL ?? "",
  runtime,
  // Not loopback: the agent reaches this server from a container of its own.
  agentListen: { host: "0.0.0.0", port: 8081 },
  publicListen: { host: "0.0.0.0", port: 8080 },
  handlers: () => ({}),
});

await gateway.start();

Type Aliases

AgentContainer

ts
type AgentContainer = {
  readonly containerCommand?: readonly string[];
  readonly entrypoint?: readonly string[];
  readonly env?: Readonly<Record<string, string>>;
  readonly extraArgs?: readonly string[];
  readonly image: string;
  readonly logger?: Logger;
  readonly mounts?: MountTable;
  readonly networks?: readonly string[];
};

The container one Run happens in, as an Operator declares it. Inert: it creates nothing, checks no path and starts nothing.

Everything but image is a default worth overriding, or a fact about a deployment that most deployments do not have.

The container is always run with --rm, with stdin open and no TTY, and as this process's own uid and gid. None of the three is configurable: a TTY makes an agent decide it is being used interactively, and a container running as root leaves files in a bind mount that a Signal Handler can read and delete but cannot change.

Properties

containerCommand?
ts
readonly containerCommand?: readonly string[];

How the container runtime is invoked. Defaults to ["docker"], and ["podman"] works.

entrypoint?
ts
readonly entrypoint?: readonly string[];

What to run inside the image, in place of its own ENTRYPOINT.

The first word becomes --entrypoint, which takes exactly one. Anything after it is the container's command and lands after the image name, ahead of what the agent's own function contributes.

env?
ts
readonly env?: Readonly<Record<string, string>>;

Environment variables for the agent's container, such as a provider API key or a proxy.

Only what is named here reaches the agent, and none of the Gateway's own environment does, which is most of why the agent runs in a container at all. Every value is hidden in the loggable copy of the command line, with no exception for a name that looks harmless.

extraArgs?
ts
readonly extraArgs?: readonly string[];

Container flags the framework does not model, spliced in last so that one here overrides one the framework set.

The one escape hatch, and how to countermand --user, a later --user winning. It reaches the container runtime only: there is still no way to pass the agent itself an unmodelled flag.

image
ts
readonly image: string;

The container image, handed to the container runtime as written, so a tag or a digest pins what runs.

logger?
ts
readonly logger?: Logger;

Where this Runtime logs its two debug lines per Run, the composed command line and how the container ended. Defaults to a pino instance on stdout, which drops both.

mounts?
ts
readonly mounts?: MountTable;

What the container can reach on disk. Absent means nothing at all.

That is a real deployment: an image that bakes in its own configuration and keeps no state mounts nothing. What it costs is silent, because nothing written survives the container. Every Run is then a first Run, whatever Session it names, and no log line says so.

networks?
ts
readonly networks?: readonly string[];

The container networks to join, one --network each.

Plural, a container being able to join several. There is no default and no good one: the container runtime's own is the shared bridge, and no network at all breaks every Run, the agent needing both its model and the Agent server.


AgentContainerRuntime

ts
type AgentContainerRuntime = Runtime & {
  commandFor: (prompt: RunPrompt) => ComposedCommand;
};

A Runtime, plus one pure method the seam itself has no use for.

commandFor composes the command line for a Prompt without starting anything, which is the only way to see this Runtime's own defaults applied to a declaration.

Type Declaration

commandFor()
ts
commandFor(prompt: RunPrompt): ComposedCommand;
Parameters
prompt

RunPrompt

Returns

ComposedCommand


AgentContainerRuntimeSpec

ts
type AgentContainerRuntimeSpec = {
  readonly container: AgentContainer;
  run: (prompt: RunPrompt) => RunPlan;
};

What one containerised agent is: the box an Operator declares, and the one function that drives an agent inside it.

The two are separate fields rather than one flat object, so a field written in the wrong half is a type error rather than a container flag nothing reads.

Properties

container
ts
readonly container: AgentContainer;

Methods

run()
ts
run(prompt: RunPrompt): RunPlan;

The whole of what an Agent Implementation adds. Called once per Run, and its result drives both the command line and the reading of stdout.

One function and not two, because outcome comes out of it per Run and can therefore close over which Run this is and name the Session when it fails.

prompt.session is always a string here. A Signal Handler may ask for a fresh Session, and the Signal Worker has already settled that and named it before anything reaches this.

Parameters
prompt

RunPrompt

Returns

RunPlan


ComposedCommand

ts
type ComposedCommand = {
  readonly args: readonly string[];
  readonly command: string;
  readonly redactedArgs: readonly string[];
  readonly stdin: string;
};

One Run's command line, and what to feed it.

Properties

args
ts
readonly args: readonly string[];

Its arguments: the container's flags, then the image, then the agent's own.

command
ts
readonly command: string;

The program: the container runtime.

redactedArgs
ts
readonly redactedArgs: readonly string[];

The same arguments with every environment value replaced. Log this and never args.

Redacted here because this is the one place that knows which argument is a value and which is a flag. A variable set to nothing stays visibly empty, there being nothing in it to hide.

stdin
ts
readonly stdin: string;

The Prompt, or whatever else the agent's own function asked to have written to stdin.


Mount

ts
type Mount = {
  readonly agentPath: string;
  readonly path: string;
  readonly readOnly?: boolean;
};

One entry: a directory or a single file the agent's container can reach.

Nothing here says which of the two it is, and nothing needs to. There are two paths because there are two namespaces: agentPath is the agent's own container, and path is the host's, written against the table's MountTable.runtimeDir.

Properties

agentPath
ts
readonly agentPath: string;

The mount point the agent sees. Absolute, and POSIX whatever platform this is.

Two entries naming one agentPath are refused, a trailing slash making no difference.

path
ts
readonly path: string;

Where the same thing sits inside the Runtime Directory, relative to it.

A leading / is refused, because an absolute path here would resolve under that directory a second time rather than fail. The empty string is the Runtime Directory itself.

readOnly?
ts
readonly readOnly?: boolean;

Whether the agent can write it. Defaults to false.

A read-only file nested inside a read-write directory works, the container runtime sorting bind mounts by destination depth: the file is unwritable and unlinkable while every operation on its siblings still succeeds. That is how a file the agent must not change becomes one it cannot.


MountTable

ts
type MountTable = {
  readonly entries: readonly Mount[];
  readonly runtimeDir: string;
};

The whole of what the agent's container can reach on disk.

Everything else about the container belongs to the AgentContainer that carries this: the image, the entry point, the networks and the environment.

Properties

entries
ts
readonly entries: readonly Mount[];

The entries, in whatever order suits the reader.

Declaration order is preserved in the arguments and means nothing to the outcome. The daemon sorts bind mounts by destination depth, so a nested entry nests under its parent however the two were written.

An empty list is a deployment too and is not refused. Nothing the agent writes then outlives the container, so every Run is a first Run.

runtimeDir
ts
readonly runtimeDir: string;

The host's path to the Runtime Directory every entry is written against.

This is the one namespace the table has: the container runtime's daemon resolves a bind source on the host, so this is the string it is handed, unread. Where the Gateway process itself reaches that directory is not stated here and, for a Gateway in a container, is not in general reachable at all, so anything the Gateway reads for itself comes from its own image or from a path it holds separately. Nothing discovers this value.

"/" is how a shared tree spanning more than one host mount is expressed: an entry then reads mnt/b/thing and resolves to /mnt/b/thing. It is an ordinary value of the same rule and not a special case. A trailing separator makes no difference.


RunPlan

ts
type RunPlan = {
  readonly args: readonly string[];
  readonly stdin: string;
  outcome: (stdout: AsyncIterable<Uint8Array<ArrayBufferLike>>) => Promise<RunOutcome>;
};

How to perform one Run: the agent's arguments, its stdin, and how to read what comes back.

Properties

args
ts
readonly args: readonly string[];

The agent's own arguments, placed after the image name.

stdin
ts
readonly stdin: string;

Written to the container's stdin, which is then closed.

Methods

outcome()
ts
outcome(stdout: AsyncIterable<Uint8Array<ArrayBufferLike>>): Promise<RunOutcome>;

Reads the container's stdout into an outcome, and decides whether the Run succeeded.

Raw bytes rather than text, so a multi-byte character split across two chunks is this function's to reassemble. Report a bad stream as a failed Run rather than throwing: a throw kills the container and propagates, where a failure is recorded against the Run with the exit status and stderr appended to the message.

The stream is what decides. A reader that answers success is believed even if the container then exits non-zero, which is logged as the contradiction it is.

Parameters
stdout

AsyncIterable<Uint8Array<ArrayBufferLike>>

Returns

Promise<RunOutcome>

Functions

createAgentContainerRuntime()

ts
function createAgentContainerRuntime(spec: AgentContainerRuntimeSpec): AgentContainerRuntime;

Builds a Runtime that runs the agent as one fresh container per Run, discarding the container afterwards.

A command line is composed once here and thrown away, so that a declaration which cannot work is refused where the Operator wrote it. That is worth a startup failure because the alternative is a Run that fails at the first Signal and is never retried.

Parameters

spec

AgentContainerRuntimeSpec

Returns

AgentContainerRuntime

Throws

If the image is empty, or if the Mount Table cannot mean what it says.


mountArguments()

ts
function mountArguments(table: MountTable): readonly string[];

Turns a Mount Table into one --mount and its value per entry, in declaration order, or refuses the table.

Pure and total. It joins each entry's path onto runtimeDir, and it refuses a relative agentPath, a leading / on an entry's path, a . or .. segment in any path it resolves, and two entries naming one target.

It performs no I/O, so it cannot say whether any of these paths exists. That answer comes from the daemon at the first Run, as a Run that failed and will not be retried, which is why createAgentContainerRuntime calls this at construction: the refusals it can make, it makes where the Operator wrote the table.

Parameters

table

MountTable

Returns

readonly string[]

Throws

On any of those four.