Skip to content

Coding agent and inference integration

This page covers how the in-process CodingAgent (Path A) executes a convergence round, and how that contrasts with dispatching to an SDK worker (Path B). It also shows the inference resolution path — important because a misconfigured catalog is the most common reason a Smithers run fails before it starts.

CodingAgent at a glance

ts
// src/coding-agent/CodingAgent.ts:179-194
export class CodingAgent extends MatrixActor {
  static override accepts = {
    'code.run': {},
    'code.steer': {},
    'code.stop': {},
    'code.status': {},
    'code.history': {},
    'code.chat': {},
  };
  static override emits = {
    '$stateChanged': {},
    'code.event': {},
    'code.done': {},
  };
}

State (CodingAgent.ts:195-211):

ts
private _phase: 'idle' | 'running' | 'done' | 'error' | 'cancelled' = 'idle';
private _abortController: AbortController | null = null;
private _piaiConfig: PiAiConfig | null = null;
private _turn = 0;
private _toolCalls = 0;
private _filesChanged: Array<{ path: string; kind: string }> = [];
private _startedAt: number = 0;
private _priorMessages: unknown[] = [];
private _activityTo: string[] = [];
private _eventLog: CodingAgentEvent[] = [];

The agent is single-threaded — one running loop at a time. code.steer does not start a new loop; it injects a prompt into the existing iteration if running, or starts fresh if idle.

Inference resolution

_resolvePiAiConfig (CodingAgent.ts:213-264) is where credentials come in. It races the catalog lookup against a configurable timeout:

ts
const resolution = await Promise.race([
  (async () => {
    const inferenceCatalog = await import('@open-matrix/inference-catalog').catch(() => null);
    if (!inferenceCatalog?.createInferenceConfig) {
      return { config: null, adapter: null, error: 'Inference catalog unavailable' };
    }
    const result = await createInferenceConfig((this as any)._context);
    if (result?.useBus && result.config) {
      return { config: result.config as PiAiConfig, adapter: result.adapter ?? null, error: null };
    }
    return { config: null, adapter: null, error: 'Inference catalog unavailable or has no healthy providers' };
  })(),
  new Promise<...>((resolve) => setTimeout(() => resolve({
    config: null, adapter: null,
    error: `Inference catalog resolution timed out after ${INFERENCE_RESOLUTION_TIMEOUT_MS}ms`,
  }), INFERENCE_RESOLUTION_TIMEOUT_MS)),
]);

The resolution result fills _piaiConfig and _busAdapter (the latter routes through system.inference instead of holding a static API key). _lastInferenceResolutionError is preserved for diagnostics — onCodeRun surfaces it if the loop can't start.

Important: Per project Rule 4, credentials never leave the local machine. The _busAdapter path is what enforces that — calls go through system.inference and system.factotum rather than embedding keys in Smithers' own code.

The shell tool

buildShellTool (CodingAgent.ts:52-95) produces a single Native tool definition: shell(cmd: string). It's the only tool Smithers' Path A exposes:

ts
function buildShellTool(cwd: string, signal: AbortSignal, onPatch: (files) => void): NativeToolDef {
  // Runs cmd via execFile in cwd, captures stdout/stderr,
  // truncates to SHELL_OUTPUT_CAP_BYTES,
  // detects file changes and calls onPatch.
}

The agent gets exactly one tool — no separate read/write/grep/glob/find. Everything is shell, by design (project rule: agents should be unix-native). applyPatch.ts provides a controlled patch-application path the agent uses via shell apply-patch < patch.diff rather than direct FS writes.

The streaming oracle

streamingOracle.ts runs the LLM tool loop. Pseudocode:

ts
let messages = [systemPrompt, ...userMessages];
for (let turn = 0; turn < maxTurns; turn++) {
  const stream = await piai.stream({ config, messages, tools: [shellTool] });
  for await (const chunk of stream) {
    emit('code.event', { kind: 'text', text: chunk.text });
  }
  if (stream.toolCalls.length === 0) {
    emit('code.done', { ... });
    return;
  }
  for (const call of stream.toolCalls) {
    emit('code.event', { kind: 'tool-call', call });
    const result = await shellTool.run(call.args);
    emit('code.event', { kind: 'tool-result', result });
    messages.push({ role: 'tool', content: result });
  }
}

The actual implementation handles aborts (AbortController), per-turn output caps (agentOutputCapBytes), _priorMessages for steer, and activityTo routing so frames go to the right inbox.

Evidence extraction

When a run completes, evidence-extraction.ts (extractEvidenceFromAgentOutput in src/actors/evidence-extraction.ts) parses agentOutput and pulls out:

  • File-change list (with paths and kind: add/update/delete).
  • Test invocations and their results.
  • Lint/build outputs.
  • Citations of architecture-doc sections (matched against ArchitectureDocWatcher).
  • Tool-call counts and turn counts.

This evidence object is what ReviewerAgent evaluates each constraint against during the review barrier.

Path B — SDK worker dispatch

When a SdkWorkerRef is available (Codex or Claude Code SDK runtime registered in system.runtimes), Smithers prefers it. Difference vs Path A:

ConcernPath A (CodingAgent)Path B (SDK worker)
ProcessIn Smithers' Node processSeparate process / container
Tool surfaceOne shell toolWhatever the SDK provides (file read/write/edit/grep/etc.)
Session storageIn-memory _eventLogsystem.agents session under <host-home>/.matrix/sessions/
SteerRe-enter loopsystem.agents.session.send
Stopcode.stop (abort)system.agents.session.stop
Activity streamcode.event from this actor$activity from system.agents session
EvidenceagentOutput captured by streamingOracleRead from session trace via system.agents.session.read-key('runtime.trace')
Restart on rejectionNew code.run with feedbackNew session in same worker, feedback prepended

Smithers' supervisor (SmithersSupervisor.ts:1296-1336) routes Path B reads through helper methods:

ts
private async _stopSdkWorkerSession(workerRef: SdkWorkerRef, sessionId: string): Promise<void>
private async _readAgentSessionState(sessionId: string, key: string): Promise<string | null>
private async _appendAgentOutputFromSessionTrace(sessionId: string, info: ActiveIssueInfo): Promise<void>

This is the abstraction that lets the supervisor not know whether it's talking to its own CodingAgent or a Codex/Claude SDK worker — both end up writing to info.agentOutput.

Why this matters

The two paths exist because:

  1. Path A is the fallback. If no SDK worker is registered, Smithers can still run convergence locally — at the cost of having only one tool and no isolation.
  2. Path B is the productization target. P2.6 demands public Smithers reach a Codex/Claude SDK worker via system.agents, with container isolation per P2.5's audit. Until that audit closes, Path B is allowed only locally.
  3. The constraint protocol is the same. The agent sees the same prompt either way; the supervisor reads the same evidence; the review barrier doesn't care which path produced the work. This is a deliberate decoupling so that the convergence protocol can be evaluated independently of the agent runtime.

Adding a new agent runtime

To support a new SDK (say @open-matrix/codeium-agent):

  1. Implement the package as a runtime that registers in system.runtimes with SdkWorkerRef.workerKind: 'codeium' (extend the type).
  2. Make sure it exposes system.agents session ops (session.open, session.send, session.stop, session.read-key, session.read-trace).
  3. Smithers will discover it via _registerSdkWorker (look at how codex-agent and claude-agent register themselves).

No changes needed to Smithers source.

See also

Source: projects/matrix-3/packages/smithers/src/coding-agent/CodingAgent.ts:179-264, :271-450, projects/matrix-3/packages/smithers/src/actors/SmithersSupervisor.ts:1296-1336.