DEV Community

Jude
Jude

Posted on

I counted our agent loop: 239 lines. Frameworks and runtimes are not the same layer.

The short version

Agent framework Agent runtime
Operates during the days you write code every execution after that
Core question how should the agent think, compose, call is this call allowed, is it written down, who pays for it
Typical surface prompt composition, chain/graph authoring, model and tool wrappers, developer experience permission checks, secret boundaries, egress policy, ledger and replay, cost attribution, approval interrupts
Judged by expressiveness, time to first agent, ecosystem breadth can you reconstruct what happened, can you stop it, can you replay it
Where it lives in our repo modules/agent/runtime/ (planner + executor + verifier) kernel/ports/, kernel/runtime/, kernel/security/
Size 239 lines the policy gateway on one port alone is 475

The last two rows are what this post argues, and they are not a diagram I drew. They are wc -l.

Why this needs a whole post

"Agent platform" currently swallows at least three separate things: the library you write
an agent with
, the engine that runs it, and the control plane that governs it. Once
those three share a word, every discussion becomes two people answering different questions —
one is saying "I built an agent in three lines", the other is saying "I need to know who
approved that call at 3pm yesterday". Both are right. Neither is talking to the other.

I am not going to characterize anyone else's internals. That would require me to have read
their code closely enough to be accountable for the claim, and every claim in this post has to
come with a line number. So the post does two things:

  1. describes what the framework layer generally owns — consensus, no assertions about anyone;
  2. uses our own repo as the specimen to locate what the runtime layer owns.

The specimen is github.com/soit-ai/soit, Apache 2.0. Every
line number below refers to commit 3a57ae1.

1. Counting our own agent loop: 239 lines

An agent loop is three things: decide the next step, do it, check whether it is done. In our
repo those are three files:

server/app/modules/agent/runtime/planner.py    98 lines
server/app/modules/agent/runtime/executor.py   42 lines
server/app/modules/agent/runtime/verifier.py   99 lines
Enter fullscreen mode Exit fullscreen mode

The executor is 42 lines and nearly all of it fits here:

class AgentExecutor:
    """Execute tool actions for agent."""

    def __init__(self, tool_port: ToolPort):
        self.tool_port = tool_port

    async def execute_tool(
        self, tool_ref, parameters, ctx, run_id, tool_call_id,
        idempotency_key, run_step_id=None, resume_approval=False, lease_owner=None,
    ) -> ToolResponse:
        """Execute tool call."""
        return await self.tool_port.invoke(
            tool_ref=tool_ref, parameters=parameters, run_id=run_id,
            tool_call_id=tool_call_id, idempotency_key=idempotency_key,
            run_step_id=run_step_id, resume_approval=resume_approval,
            lease_owner=lease_owner, ctx=ctx, strict_registry=True,
        )
Enter fullscreen mode Exit fullscreen mode

This class has no logic. It hands the call to a port. That is not laziness; it is the
thesis of this post: the rules of execution are not written in the loop, they are written on
the port.

The planner is equally plain. It hands messages to the model using native function calling and
gets back either tool calls or text — there is no third case:

response = await self.llm_port.chat(
    messages=planning_messages, model=model, temperature=temperature,
    tools=tool_definitions if tool_definitions else None,
    tool_choice="auto" if tool_definitions else None,
    run_id=run_id, reasoning_effort=reasoning_effort,
)
if response.tool_calls:
    return PlanResult(action="tool", tool_calls=response.tool_calls, ...)
return PlanResult(action="respond", response=response.text or "", ...)
Enter fullscreen mode Exit fullscreen mode

No prompt template DSL. No chain or graph authoring language. No regex pulling Action: out of
model output. The verifier is the same shape: one structured-output tool definition
(verify_response, fields ok and reason) asking the model whether the answer is adequate.

This layer is the framework's home turf, and we deliberately built almost nothing here.
Thin is neither a virtue nor a flaw. It just means we are not competing on expressiveness.

2. So what are the 1,617 lines wrapped around it doing?

server/app/modules/agent/application/service.py is 1,617 lines. Its import block answers the
question on its own:

from app.kernel.identity.guard import workspace_guard
from app.kernel.ports.approvals import ApprovalLedgerPort, ApprovalRecord
from app.kernel.ports.common.rate_limiter import RateLimiter
from app.kernel.runtime.runs.tool_calls import RuntimeToolExecutionService, ToolExecutionCommand
from app.kernel.runtime.runs.writer import TraceWriter
from app.kernel.runtime.tools.approval import tool_approval_rule
from app.kernel.runtime.tools.resolver import ToolResolver
Enter fullscreen mode Exit fullscreen mode

Workspace guard, approval ledger, rate limiter, tool-execution ledger (with leases and
idempotency), trace writer, approval rules, tool resolver. Plus a dedicated control signal for
human approval:

class _AgentApprovalInterrupt(Exception):
    """Internal control signal for a durable human approval checkpoint."""
Enter fullscreen mode Exit fullscreen mode

The loop itself is one while (service.py:653):

while pending_tool_calls or iterations < data.max_iterations:
Enter fullscreen mode Exit fullscreen mode

One line of loop, sixteen hundred lines around it. Almost none of those lines are about how
the agent thinks. They are about whether this call is allowed, whether it is written down, who
it is billed to, and how to resume when a human interrupts it.

If nine tenths of a codebase answers the second set of questions, calling it "another agent
framework" describes the least important tenth of it.

3. Where the line actually is: ten gates on one tool call

server/app/kernel/ports/tools/interface.py is 54 lines: an abstract ToolPort with a single
abstract invoke(). That is the seam.

The thing that does the work is ToolPolicyGateway in
server/app/kernel/ports/tools/policy.py, 475 lines, implementing that same ToolPort. Which
means calling a tool raw and calling a tool through governance look identical to the caller
the only difference is which implementation got injected.

ToolPolicyGateway.invoke() starts at policy.py:231. One tool call passes, in order:

# Gate What it does Where
1 Secret resolution and redaction Secret references in the parameters are resolved through secrets_port, and a redacted copy is produced for everything downstream that records policy.py:253–256
2 Ledger claim The call is claimed in the run ledger; tool_call_id and an idempotency key are minted or reused policy.py:264–290
3 Idempotent replay If the claim comes back replayed, the cached response is returned and the call is not made again policy.py:291–296
4 Lease The execution takes a lease of max(60, ceil(timeout) + 10) seconds, renewed while it runs policy.py:281, policy.py:326
5 Egress policy Every http URL found anywhere in the parameters goes through check_egress_policy policy.py:307–309
6 Rate limit Per-minute limit keyed on tool_ref + tenant + workspace + user policy.py:311–318
7 Daily quota 86,400-second window keyed on tool_ref + tenant + workspace policy.py:319–324
8 Tracing A soit.tool.invoke OTel span carrying tenant, workspace, run and step attributes policy.py:336–348
9 Timeout and retry Shared timeout/retry; with an idempotency key, max_retries=1, and the comment says why policy.py:349–360
10 Audit and settlement Audit log (written with the redacted parameters), step status and metrics, cost policy.py:367–383, 410–425

Watch how gate 1 and gate 10 cooperate: real values only ever reach the call; the redacted
copy is the only thing that reaches the record.
The code carries resolved_parameters and
redacted_parameters side by side precisely so that audit and metrics can never accidentally
take the wrong one. That is not a discipline you can maintain by being careful in application
code. It only works if it sits on the path everything must take.

Gate 9's comment is the tell:

# Durable Agent calls are at-most-once at this boundary.
# Not every downstream adapter can honor an idempotency key.
Enter fullscreen mode Exit fullscreen mode

That is a runtime-layer concern in one sentence. It does not care whether your agent logic is
elegant. It cares whether a retry files the same ticket twice.

4. The fact that proves governance does not live in the loop

At this point you could reasonably say: you just pushed the governance code downstream of your
agent loop, that proves nothing.

So look at a second execution model. Besides the agent loop, the repo has a workflow engine —
modules/workflow/, 6,269 lines, including a 969-line engine.py and an 855-line
executor.py. It is a DAG. It has nothing structurally in common with an agent loop.

How does it call a tool?

# server/app/modules/workflow/runtime/executors/tool.py:428
response = await context.tool_port.invoke(...)

# server/app/modules/workflow/runtime/executors/llm.py:105
response: ChatResponse = await context.llm_port.chat(...)
Enter fullscreen mode Exit fullscreen mode

The same tool_port.invoke. The same llm_port.chat.

Two unrelated execution models, one set of gates. That is the operational definition of a
layer: governance is a property of the port, not of any particular loop. Swap the execution
model above and not one of the ten gates below goes away.

Which is also the technical reason the two layers do not conflict: anything that calls
through these ports gets governed
— our loop, a DAG engine, or something else entirely. The
layer underneath cannot tell the difference and does not need to.

5. The dependency list is the most honest positioning statement a project has

What a project says it is, you read in the README. What it actually is, you read in its
dependencies.

In server/pyproject.toml, exactly three core dependencies relate to agents at all:

"mcp>=1.28.1,<2",          # tool protocol
"ag-ui-protocol==0.1.19",  # front-end interaction event protocol
"litellm==1.91.1",         # model invocation
Enter fullscreen mode Exit fullscreen mode

All three are protocols or call layers. There is no agent framework in the core
dependencies.
That is not a manifesto, it is pyproject.toml lines 68 to 71.

And LangChain? It is there. Here:

[project.optional-dependencies]
local-embedding = [
    "sentence-transformers>=4.1.0",
    "langchain-huggingface>=0.0.6",
    ...
]
Enter fullscreen mode Exit fullscreen mode

pyproject.toml:75–83 — an optional extra named local-embedding, for running embedding
models locally. Nothing to do with agent orchestration.

While I am here, a correction about us. Our README's tech-stack table lists the LLM row as
OpenAI · Anthropic · DeepSeek · Qwen · LangChain (adapter layer) (README.md:240). That does
not match the dependencies. LangChain is not an LLM adapter layer here; it is an optional local
embedding dependency. That is our documentation misleading readers, and I intend to open an
issue to fix it — I had not filed it when this was written, so there is no link to give you.

6. The seams are other people's protocols, not shapes we invented

A layer boundary is only real if the seam is public. Three seams:

Requests in. POST /api/v1/responses accepts AG-UI's RunAgentInput directly:

# server/app/api/v1/responses/router.py:222
async def create_response(payload: RunAgentInput | ResponseCreateRequest, ...):
Enter fullscreen mode Exit fullscreen mode

A front end does not have to learn a SOIT-specific message shape.

Tools in. Tool references are namespaced strings; adapters/tools/router.py shows three
prefixes — tool:http:*, tool:function:*, and mcp_tool:*. Any MCP server resolves into the
tool registry without a code change.

Events out. The run is persisted and streamed to the front end as AG-UI interaction events
(adapters/agui/agent.py and responses.py, 909 lines together).

Three protocols, no dialect of our own. That is the precondition for two layers being able to
snap together at all.

7. We did not write a framework at the model layer either

adapters/llm/ is 2,619 lines, of which router.py is 534. Those 534 lines do routing,
credential resolution and egress guarding — not prompt composition:

# server/app/adapters/llm/router.py:192 (inside _authorize_provider_target)
await self.egress_guard.authorize(ctx, f"model-provider:{provider_slug}", url)
Enter fullscreen mode Exit fullscreen mode

Both provider-resolution paths (router.py:368 and router.py:422) go through it first. In
other words, calling a model is itself subject to egress policy. If the target host is not
in policy, the call does not leave the box.

One more that is easy to miss: in production, a provider with no configured credential is
rejected outright (router.py:316–324, MODEL_PROVIDER_CREDENTIAL_REQUIRED). "Production mode
refuses to let you cut corners" is a runtime-layer job description. It does nothing for your
developer experience. It exists to stop development-time convenience from reaching production.

8. The boundary is welded shut by CI, not asserted in a doc

Layering usually dies by being true in the documentation and false in the code. So we handed
this one to a tool. The first contract in server/importlinter.ini:

[importlinter:contract:kernel_isolation]
name = Kernel is isolated
type = forbidden
source_modules =
    app.kernel
forbidden_modules =
    app.api
    app.modules
    app.adapters
    app.infra
    app.wiring
Enter fullscreen mode Exit fullscreen mode

The kernel may not import anything above it. The moment the governance kernel depends
backwards on a product module, "swap the execution model and every gate survives" stops being
true. That is not something to leave to good intentions; let CI hit the wall instead.

server/app/kernel/README.md states the rule in prose, and one line of it is worth quoting:

Kernel extension points that need product or infrastructure data must use
provider interfaces registered from wiring/.

When the kernel needs outside data it does not reach for it; it takes a provider interface
registered in wiring/. That is the standing cost of keeping a layer boundary alive.

9. The honest part: those 6,269 workflow lines do overlap

So far I have made us sound very tidy: we only do runtime, not framework.

That is not quite true.

modules/workflow/ is 6,269 lines, with a compiler (compiler.py, 259), variable resolution
(variable_resolver.py, 212), node executors (the tool node alone is 610), resume and reaper
paths. It is an orchestration engine, and it overlaps in function with orchestration
frameworks people already use.

I am not going to argue that ours is somehow a different species. It overlaps. The only
distinction is the one from section 4: it calls tools through the same tool_port.invoke.

So the accurate statement is not "we don't build framework things". It is: we built the
minimum of it we needed, and we made it obey the same rules as everybody else.

10. What "not competitors" concretely means — including what you cannot do today

Three things you can do, and one you cannot.

You can:

  1. Bring framework-side tools in over MCP and have them governed. Any MCP server resolves into the tool registry, and from then on every call it makes goes through the ten gates.
  2. Bring framework-side services in as HTTP plugins. adapters/plugins/http_runtime.py and skill_runtime.py are the two plugin runtime implementations (129 and 85 lines).
  3. Keep your front end. Interaction is an AG-UI event stream, and the request shape coming in is AG-UI's RunAgentInput.

You cannot (and this matters more than the three above):

There is no entry point today for handing us a framework-written agent to host wholesale.
Your loop still runs in your process. What SOIT governs is the hand it reaches out with
tool calls, model calls, egress — not the reasoning inside it.

That is the real boundary right now. Do not size it up as a universal container. If what you
want is "host my existing agent code as-is and get the whole governance stack for free", this
repo does not do that today. What it does is make every hand that code reaches out with sign
its name.

Confession

  • This is a read-the-code and read-the-config piece with no new end-to-end run. Every claim can be opened and checked at commit 3a57ae1; whether it behaves this way at runtime is outside what this post verified.
  • All line counts are wc -l, including blanks, comments and docstrings. Fine for orders of magnitude, wrong for estimating effort.
  • The README inaccuracy in section 5 is real. Our own documentation misled readers about this. I intend to file an issue; it was not filed when this was written.
  • I made no claims about any specific framework's internals. "What the framework layer owns" here is the industry-consensus description. Everything with a line number is our own repo.
  • SandboxToolPort is not a security sandbox. The 61 lines in kernel/ports/tools/sandbox.py are a dry run for pre-release rehearsal: the run exercises the full decision path while the side effect is stopped at the boundary, so rehearsing a release does not actually file a pile of tickets. It stops side effects, not hostile code.
  • The section 4 argument has a precondition. "Swap the execution model and every gate survives" holds only for execution models that call through the ports. Code that opens its own socket is not governed by any of this — the import contract in section 8 keeps the kernel clean; it does not stop application code from going around.

One sentence

A framework decides how the agent thinks. A runtime decides whether the hand it reaches out
with counts.

These do not compete, because they do not even operate on the same timescale — one acts on the
few days you spend writing code, the other on every execution afterwards.

If they compete for anything, it is attention. Somewhere on the path from demo to
production, a team's attention has to move from the first to the second, and it usually moves
too late — as in, after the first incident.

Try it, or take it apart

Code at github.com/soit-ai/soit, Apache 2.0. Every line
number in this post refers to commit 3a57ae1.

Three files are worth opening, because they carry the entire argument:

  • server/app/modules/agent/runtime/executor.py — 42 lines, see how empty it is
  • server/app/kernel/ports/tools/policy.py — 475 lines, the ten gates
  • server/importlinter.ini — how the boundary is welded shut

If you think the section 4 argument has a hole in it, or you have seen a better way to draw this
line in another project, say so in an issue. The failure mode for a post like this is talking
to myself.

Disclosure: I maintain SOIT.

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow

The separation between resolved parameters and redacted parameters at the port level is the cleanest part of this architecture. If you try to sanitize secrets inside the agent loop or prompt formatter, any uncaught exception or mid-flight crash dumps raw credentials straight into local stderr or the trace database.

The other headache this port-level gate pattern solves is non-idempotent retries. When an agent calls a downstream service that lacks idempotency keys, a naive loop retry policy easily double-fires external mutations on transient timeouts. Moving the retry clamp down to the policy gateway where idempotency support is tracked keeps the planner from turning a network blip into duplicate side effects.

Collapse
 
hannune profile image
Tae Kim

Ran into this distinction the hard way last year debugging a production pipeline. The agent loop itself was under 200 lines but I'd let the audit surface grow silently alongside it without treating it as a separate concern. Once I drew that line explicitly, replay went from guesswork to something I could actually pin to a checkpoint. The wc -l comparison is the most honest way I've seen this gap illustrated.