Skip to content

Repository files navigation

@oliwoods-org/agent-runtime

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.

Why this exists

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 — no tool_use handling, no loop.
  • CoFounder had a real task source and a real LLM call, but resolveAgentTools returned prose instead of tool schemas, and core-engine.js never passed tools: to the model. Its agents' only actuation was regex-extracted markdown code fences.

One loop, shared, fixes both. The products stay separate.

Design: everything is a port

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.

Usage

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.

Structured output

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 } });

Tool policy

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.

Behaviour worth knowing

  • Batch authorization: if a pre-action hook 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 lockKey run strictly in model-emitted order; everything else runs concurrently.
  • Shared auth recovery: authRecovery runs 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. budgetUsd stops the loop once accumulated cost exceeds the ceiling.

Conformance

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)

Scope

Deliberately not here: agent roles, team configs, dispatchers, schedulers, persistence, MCP clients, model selection. Those are product concerns and belong in the product.

About

Shared agentic tool-call loop for MAMA and CoFounder. Zero-dependency, port-injected.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages