Skip to main content

Overview & the port model

Mecatl uses ports and adapters to keep the agent loop independent of providers, storage, policy engines, and operating-system integrations. You can replace one of these capabilities without changing the loop or the other adapters.


The dependency flow

engine/agent depends on interfaces in engine/port, the domain packages, and the standard library. Construct the adapters in your composition root and inject them into the loop.


The port interfaces

Most seams the loop can be extended through are defined in engine/port. The table also includes service-owned persistence seams such as EventLog, which the relay uses but the agent loop does not consume directly.

InterfaceFileAbstractsReference adapters
LLMProviderport/llm.goModel calls — streams Chunk values; reports multimodal ProviderCapabilitiesengine/adapter/mockllm (offline); provider/openai, provider/anthropic, provider/openaichat (opt-in submodules); internal/adapter/openrouter; internal/adapter/llmresilience (decorator)
SessionStoreport/store.goPersist and reload session stateengine/adapter/memstore (in-memory, tests); internal/adapter/store/jsonlstore (append-only JSONL); internal/adapter/redisstore (Redis-backed)
PrunableStoreport/store.goOptional retention sweep (list + delete sessions)Same implementations that also carry SessionStore; discovered by type assertion
PermissionPolicyport/permission.goEvaluate a tool call → allow / ask / deny; learn per-session allow rulesengine/adapter/permpolicy (wraps the session-free governance.Evaluator)
PermissionStoreport/permission.goHold per-session learned rulesengine/adapter/permstore
HookRunnerport/hookrunner.goExecute lifecycle hooks (PreToolUse, PostToolUse, etc.)internal/adapter/hookexec (shell-exec); engine/adapter/mockllm test stubs
EventLogport/eventlog.goDurable append-only per-session event record; owned by the service relay rather than consumed by the agent loopengine/adapter/memstore (in-memory); internal/adapter/store/jsonlstore (.events.jsonl sidecar); internal/adapter/redisstore
EventSinkport/log.goLive mirror of the event stream (telemetry, ACP relay)internal/adapter/server (gRPC/HTTP relay); internal/adapter/telemetry
ToolCallRecorderport/log.goPer-tool audit record (timing, call, result)internal/adapter/store/jsonlstore; internal/adapter/redisstore; internal/adapter/telemetry
Diagnosticsport/diagnostics.goOperator-facing log lines (structured key/value, slog-shaped)internal/adapter/slogdiag (the only slog bridge); port.NopDiagnostics (zero-value default)
Clockport/clock.goWall clock — Now() time.Timeengine/adapter/wallclock (production); test fakes inline in engine tests
SessionLeaseport/lease.goCross-process single-writer lease for a session id (optional; cloud-native Phase 4)engine/adapter/memlease; internal/adapter/flocklease; internal/adapter/k8slease; internal/adapter/grpcdriver

Ports outside engine/port

The filesystem and execution-environment ports live in engine/tool:

  • tool.FileSystem provides the underlying filesystem operations.
  • tool.Workspace adds version-aware reads, create-only writes, conditional replacement, and a per-environment read ledger. Implementations mint opaque FileVersion values. The public interface has no unconditional overwrite.
  • tool.Environment combines a durable session.EnvironmentRef, a non-nil Workspace, and an optional CommandRunner. A runner is bound to one namespace when constructed; if it is absent, Shell returns ErrNoShell.
  • tool.WorkspaceNamespace optionally adds immediate directory listing, non-recursive removal, no-clobber rename, and no-clobber regular-file copy. Built-in namespace tools report unsupported when an embedder omits it.
  • tool.EnvironmentForker and tool.EnvironmentMerger create and merge child environments for isolated work.

Use engine/adapter/memfs for tests or internal/adapter/osfs for an OS-backed workspace. The ACP integration supplies an editor-buffer workspace. For the version protocol and environment lifecycle, see ADR 0208, ADR 0211, and ADR 0214.


When to implement a port vs. use the reference adapters

Most deployments use the reference adapters directly. Implement a port only when your application needs a different capability at that boundary.

Implement a port when you need to swap a specific capability at the boundary:

ScenarioPort to implement
Route to a different LLM provider (your own inference cluster, proxy, or custom API)LLMProvider
Store sessions in your own database (PostgreSQL, DynamoDB, …)SessionStore (+ optionally PrunableStore)
Enforce your own permission logic (RBAC, OPA, org-level policy engine)PermissionPolicy
Audit tool calls into your own observability pipelineToolCallRecorder
Route operator log lines to your logging infrastructureDiagnostics
Implement session leasing against your own distributed lock serviceSessionLease

You do not need to implement a port to:

  • Change which model is used — pass the model name through internal/app's provider registry.
  • Change permission rules — write settings.yaml config. The existing PermissionPolicy adapter picks it up per session.
  • Add lifecycle hooks — write shell hooks or use internal/adapter/hookexec. The HookRunner port is for replacing the execution engine, not adding hooks.
  • Add tools — extend the tool.Catalog at composition time.

How adapters are wired: the composition pattern

Mecatl uses explicit constructors. The shipped commands share the composition root in internal/app/build.go; an embedding application can follow the same pattern in its own composition root.

The schematic below shows how ports are satisfied for a typical deployment. Actual field names are illustrative; see internal/app/build.go for the live signatures.

// internal/app/build.go (schematic)

func Build(cfg Config) (*server.Service, error) {
// 1. Stand up the store (satisfies SessionStore, PrunableStore, EventLog, ToolCallRecorder)
store, err := jsonlstore.New(cfg.DataDir)

// 2. Build the LLM provider (satisfies LLMProvider)
// The llmresilience decorator wraps the raw provider with retry + stream watchdog.
raw, err := openai.New(openai.WithAPIKey(cfg.OpenAIKey), openai.WithBaseURL(cfg.BaseURL))
provider := llmresilience.Wrap(raw, llmresilience.Config{StreamIdleTimeout: 180 * time.Second})

// 3. Permission policy + store (satisfies PermissionPolicy, PermissionStore)
permStore := permstore.New()
rules := []governance.Rule{ /* your rules */ }
policy := permpolicy.NewPolicy(rules, permStore)

// 4. Diagnostics (satisfies Diagnostics)
diag := slogdiag.NewFromLogger(slog.Default())

// 5. Clock (satisfies Clock) -- the zero value is ready to use, no constructor
clk := wallclock.Clock{}

// 6. Hook runner (satisfies HookRunner)
hooks, err := hookexec.New(cfg.HookConfig)

// 7. Inject into the engine
// Note: EventLog is NOT an engine.Deps field — the service layer (not the engine)
// appends to the log. Pass store to the service constructor instead.
eng := agent.NewEngine(agent.Deps{
LLM: provider,
Store: store,
Policy: policy,
Hooks: hooks,
ToolCallRecorder: store,
Diagnostics: diag,
Clock: clk,
})

return server.New(eng, store, ...), nil
}

The example demonstrates three composition rules:

  • One object can satisfy multiple ports. jsonlstore.Store implements SessionStore, PrunableStore, EventLog, and ToolCallRecorder. Pass it separately wherever each interface is required; the service, rather than the engine, owns the EventLog.
  • Adapters are never imported by the engine. agent.Deps carries interface values only. A new LLM adapter never requires an engine change.
  • Composition is the only place adapters meet. Domain packages and engine/agent have no adapter imports, which the depguard allowlist and the DAG test verify on every build.

Replacing a single adapter

To swap, say, SessionStore for your own database backend:

  1. Implement port.SessionStore (and optionally port.PrunableStore) in a new package.
  2. In your composition root (either your own main or a fork of internal/app/build.go), construct your store and pass it in place of jsonlstore.New(...).
  3. Leave the engine and unrelated adapters unchanged.

To validate your implementation against the conformance suite:

// Run the standard store conformance tests against your adapter.
storeconformance.Run(t, func(t *testing.T) port.SessionStore { return yourstore.New() })

Conformance suites ship in engine/adapter/storeconformance, leaseconformance, fsconformance, sourceconformance, memconformance, eventlogconformance, and scheduleconformance. An adapter that passes its suite is compatible with Mecatl's expectations.


What's next

  • LLM provider — implement port.LLMProvider to route to a custom model endpoint.
  • Session store — implement port.SessionStore (and the optional PrunableStore / EventLog seams) for your own persistence backend.
  • Permission policy — replace Layer 1's rule engine with your own authorization logic.
  • Session lease — implement port.SessionLease for cross-process single-writer session exclusion.