The shared agentic tool-call loop for MAMA and CoFounder. It owns the tool contract and the execution loop, and nothing else — roles, dispatch, model selection, persistence and UX stay in each product.
Both products had built half of this and neither had finished it:
- MAMA had a real, tested tool loop (
src/agents/tool-loop.ts) that almost nothing could reach. Its production dispatcher advertised 8 tools to the model and never executed a single one — notool_usehandling, no loop. - CoFounder had a real task source and a real LLM call, but
resolveAgentToolsreturned prose instead of tool schemas, andcore-engine.jsnever passedtools:to the model. Its agents' only actuation was regex-extracted markdown code fences.
One loop, shared, fixes both. The products stay separate.
The loop imports no product code. Hosts inject what they have:
| Port | Required | Default |
|---|---|---|
LlmPort |
yes | — |
LoggerPort |
no | silent |
HooksPort |
no | permit-all |
OptimizerPort |
no | no model pinning |
This is what lets the two products share a loop while keeping separate LLM layers: MAMA injects its local-first gateway (so the woods-mini Ollama path survives), CoFounder injects its 5-provider failover engine. Neither learns about the other.
The package has zero runtime dependencies. That is deliberate, not minimalism for its own sake: MAMA runs zod 3 and CoFounder runs zod 4, whose types are incompatible. A shared package typed against either would break the other. So structured output takes an injected validator instead of a schema.
import { runToolLoop, type LlmPort, type LoopTool } from "@oliwoods-org/agent-runtime";
const llm: LlmPort = {
callWithTools: (req) => myGateway.callWithTools(req),
estimateCost: (c) => myGateway.estimateCompletionCost(c),
resolveForceModel: (m) => myGateway.resolveForceModel(m), // optional
};
const readFile: LoopTool = {
spec: {
name: "read_file",
description: "Read a file from the workspace",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
},
execute: async (args) => fs.readFile(String(args.path), "utf8"),
lockKey: (args) => String(args.path), // serialize same-path calls
};
const result = await runToolLoop({
agentId: "researcher",
system: "You are a research agent.",
userPrompt: task.description,
llm,
tools: [readFile],
denyTools: ["exec_command"], // policy guard; needs no host hook engine
maxTurns: 8,
budgetUsd: 0.5,
});result.stopReason is one of final, structured, max_turns, budget, blocked, error — always check it rather than assuming success.
The package is validator-agnostic. Adapt whichever zod your repo runs:
const validate = (raw: unknown) => {
const r = schema.safeParse(raw);
return r.success
? { ok: true as const, value: r.data }
: {
ok: false as const,
error: r.error.issues
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
.join("; "),
};
};
await runToolLoop({ ...opts, structuredOutput: { validate, parameters: jsonSchema } });allowTools / denyTools filter by name before the first turn — denied tools are never advertised to the model and cannot execute even if the model names one anyway. Deny always wins over allow, so an operator revoking a tool cannot be overridden by a caller that allowlists it.
This implements the tools_allowed / tools_blocked fields the MAMA-3000 spec called for, which had never existed in code.
- Batch authorization: if a
pre-actionhook denies any call in a batch, the entire batch is cancelled and every call is reported back to the model as cancelled. One bad call does not let its siblings through. - Per-resource serialization: calls sharing a non-undefined
lockKeyrun strictly in model-emitted order; everything else runs concurrently. - Shared auth recovery:
authRecoveryruns at most once per loop; concurrent auth failures await the same promise, then each failed tool retries once. maxTurns(default 8) is hard runaway protection.budgetUsdstops the loop once accumulated cost exceeds the ceiling.
tests/tool-loop.test.ts is the conformance suite: 17 tests covering final answers, tool execution and result feedback, max-turns, usage accumulation, budget ceiling, provider errors, hook blocking and batch cancellation, unknown tools, lock-key serialization, shared auth recovery, tool policy, and structured output with retry.
Run it against your own LlmPort adapter. If both hosts stay green, they cannot silently drift apart — which is exactly how two tool loops got built in the first place.
npm test
npm run build # emits dist/*.js + dist/*.d.ts (CoFounder's orchestrator is plain JS and needs both)Deliberately not here: agent roles, team configs, dispatchers, schedulers, persistence, MCP clients, model selection. Those are product concerns and belong in the product.