Explicit Over Implicit — Why We Reverted Stream Inference in FabrCore
A few weeks ago we shipped a feature that let FabrCore infer stream routing from message context alone — no explicit handle required. It looked clean, reduced boilerplate, and felt like a natural evolution. Then agents started talking to the wrong agents, debugging became nearly impossible, and we reverted the whole thing. This is the story of that decision.
Why Implicit Seemed Appealing
FabrCore's messaging system routes every AgentMessage using explicit handles — the ToHandle field specifies exactly where the message goes. Handles follow the "owner:agentAlias" format, and the system resolves them deterministically through HandleUtilities.
The implicit inference experiment tried to remove that requirement. If an agent had recently communicated with another agent, the system would infer the target from conversation history. The pitch was simple: less boilerplate, fewer fields to fill in, and a more natural feel for developers building multi-agent workflows.
// Implicit — the system would guess the target
var request = new AgentMessage { Message = "Analyze this data" };
var reply = await fabrcoreAgentHost.SendAndReceiveMessage(request);
// Who receives this? Depends on conversation history...
On the surface, it looked like a developer experience win. In practice, it introduced a class of bugs that were invisible until production.
What Went Wrong
Three problems emerged almost immediately once real multi-agent systems started using the feature.
Ambiguous routing. When an agent communicates with multiple peers — common in fan-out/gather patterns — the inference engine had no reliable way to determine which agent should receive the next message. An aggregator agent that talks to analyst-1, analyst-2, and analyst-3 in parallel cannot have its next outbound message guessed from history. The wrong analyst would receive follow-up instructions, producing nonsensical responses.
Debugging difficulty. With explicit handles, tracing a message path is straightforward — every AgentMessage carries a TraceId, a FromHandle, and a ToHandle. When the target was inferred, the ToHandle was populated late in the pipeline, making it nearly impossible to reconstruct message flow from logs. The TraceId was still there, but correlating it with the actual destination required digging into inference state that was ephemeral.
Cross-owner confusion. FabrCore supports cross-owner routing where "user2:analyst" is a completely different agent from "user1:analyst". The inference system had no way to distinguish owner context, meaning an agent that worked with both owners could silently route to the wrong one. This is a security-sensitive boundary, and ambiguity there is unacceptable.
The Return to Explicit Handles
We reverted to the explicit model and doubled down on making it ergonomic. FabrCore's HandleUtilities class provides a clean set of methods that make handle construction predictable and concise.
// Build an owner prefix
var prefix = HandleUtilities.BuildPrefix("user1"); // "user1:"
// Ensure a bare alias gets the caller's owner
var full = HandleUtilities.EnsurePrefix("assistant", "user1:");
// "user1:assistant"
// Already-qualified handles pass through unchanged
var same = HandleUtilities.EnsurePrefix("user2:assistant", "user1:");
// "user2:assistant" — unchanged, cross-owner preserved
// Parse a handle into its components
var (owner, agent) = HandleUtilities.ParseHandle("user1:assistant");
// ("user1", "assistant")
Inside agents, the IFabrCoreAgentHost provides direct access to handle components without manual parsing:
var full = fabrcoreAgentHost.GetHandle(); // "user1:assistant"
var owner = fabrcoreAgentHost.GetOwnerHandle(); // "user1"
var agent = fabrcoreAgentHost.GetAgentHandle(); // "assistant"
var (o, a) = fabrcoreAgentHost.GetParsedHandle(); // ("user1", "assistant")
With explicit handles, every messaging pattern is unambiguous. A delegator agent routes based on clear, deterministic logic:
[AgentAlias("router")]
public class RouterAgent : FabrCoreAgentProxy
{
public override async Task<AgentMessage> OnMessage(AgentMessage message)
{
var response = message.Response();
string target = message.MessageType switch
{
"code-review" => "code-reviewer",
"writing" => "writer",
_ => "general-assistant"
};
// Explicit target — no ambiguity
var reply = await fabrcoreAgentHost.SendAndReceiveMessage(target, message);
response.Message = reply.Message;
return response;
}
}
And fan-out patterns work correctly because each target is named:
var tasks = new[]
{
fabrcoreAgentHost.SendAndReceiveMessage("analyst-1", message),
fabrcoreAgentHost.SendAndReceiveMessage("analyst-2", message),
fabrcoreAgentHost.SendAndReceiveMessage("analyst-3", message)
};
var replies = await Task.WhenAll(tasks);
Lessons Learned
This experience reinforced a principle we now hold firmly: in distributed agent systems, implicit behavior is a liability. When messages cross process boundaries, owner boundaries, and potentially network boundaries, every routing decision must be auditable and deterministic.
A few takeaways from the revert:
- Convenience at the cost of clarity is not a trade-off worth making. The boilerplate we removed was a handful of characters — a
ToHandlefield. The debugging cost of not having it was hours per incident. - Security boundaries demand explicitness. FabrCore's ACL system evaluates access based on owner patterns, agent patterns, and caller patterns. Inferred routing bypassed the mental model that developers use when reasoning about access control.
- Tracing is a first-class concern. Every
AgentMessagecarries aTraceIdand bothFromHandleandToHandle. With those three fields populated at message creation time, reconstructing a multi-agent conversation path is trivial. With late-bound targets, it was not.
The explicit handle model — combined with HandleUtilities for ergonomic construction and IFabrCoreAgentHost for self-inspection — gives developers the clarity they need without excessive ceremony. Bare aliases like "assistant" still get auto-prefixed with the caller's owner, so same-owner routing remains concise. Cross-owner routing requires a fully-qualified handle like "user2:assistant", making the boundary crossing visible in code.
We shipped the implicit feature, learned from it, and reverted it. The codebase is better for it.
Built with FabrCore on .NET 10.
Builder of FabrCore and OpenCaddis.