Appearance
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
_busAdapterpath is what enforces that — calls go throughsystem.inferenceandsystem.factotumrather 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:
| Concern | Path A (CodingAgent) | Path B (SDK worker) |
|---|---|---|
| Process | In Smithers' Node process | Separate process / container |
| Tool surface | One shell tool | Whatever the SDK provides (file read/write/edit/grep/etc.) |
| Session storage | In-memory _eventLog | system.agents session under <host-home>/.matrix/sessions/ |
| Steer | Re-enter loop | system.agents.session.send |
| Stop | code.stop (abort) | system.agents.session.stop |
| Activity stream | code.event from this actor | $activity from system.agents session |
| Evidence | agentOutput captured by streamingOracle | Read from session trace via system.agents.session.read-key('runtime.trace') |
| Restart on rejection | New code.run with feedback | New 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:
- 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.
- Path B is the productization target.
P2.6demands public Smithers reach a Codex/Claude SDK worker viasystem.agents, with container isolation perP2.5's audit. Until that audit closes, Path B is allowed only locally. - 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):
- Implement the package as a runtime that registers in
system.runtimeswithSdkWorkerRef.workerKind: 'codeium'(extend the type). - Make sure it exposes
system.agentssession ops (session.open,session.send,session.stop,session.read-key,session.read-trace). - Smithers will discover it via
_registerSdkWorker(look at howcodex-agentandclaude-agentregister 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.