Aki, tiny library for (mostly) local agents

This tiny library has been mostly created to speed up bootstrapping agents. Its focused on llama.cpp and tiny models. The goal is to have something ultra tiny and easily importable in environments requiring low dependencies. There are two aspects, the agentic side and the eval side. Use which ever part you need more alone or together, for testing one another or both working toward a goal.

Help your CLI get more clues about digital Jazz or something. :)

This project is mostly a week-end and hobby work in progress project. As such, it may not have a very stable API and may break here and there as iteration speed is prioritized.

Any contributions are welcome!

Getting started

Grab the package:

$ npm install @tym17/aki

Once installed, few lines of code to bootstrap the agent and its tools:

import { Agent, tool } from "@tym17/aki";
import { readdir } from "node:fs/promises";

const listDir = tool({
  name: "list_dir",
  description: "List the entries of a directory.",
  parameters: {
    type: "object",
    properties: { path: { type: "string" } },
    required: ["path"],
  },
  // `path` is typed string, inferred from the schema literal above.
  execute: ({ path }) => readdir(path),
});

const agent = new Agent({
  url: "http://127.0.0.1:8080",
  system: "You are terse. Use the tools before answering; never guess.",
  tools: [listDir],
});

const { text } = await agent.run("What is in ./src?");
console.log(text);

What's inside

Tools are typed from their schema

One schema does two jobs: it constrains the model and types the handler.

parameters: {
  type: "object",
  properties: { mount: { type: "string" } },
  required: ["mount"],
},
execute: ({ mount }) => statfsSync(mount).bfree * 4096 / 1e9,
//         ^? string

Tools never throw the loop out: an unknown tool, malformed arguments or a crashing handler all come back as readable text in a tool message, which is what lets a small local model notice and retry.

Structured output

json() uses grammar-constrained decoding

const commit = await llama.json<{ type: string; subject: string }>(messages, {
  type: "object",
  properties: {
    type: { type: "string", enum: ["feat", "fix", "docs"] },
    subject: { type: "string", maxLength: 72 },
  },
  required: ["type", "subject"],
});

Eval helpers

Eventually, you might want to be sure that your prompts and local model choices are aligned with what you're doing. Running some tests can help you pin point where things might take a weird turn.

const suites: EvalSuite[] = [
  {
    name: "core",
    cases: [
      {
        name: "does arithmetic",
        prompt: "12 * 12? Number only.",
        expect: contains("144")
      },
      {
        name: "uses the tool",
        prompt: "how many lines has the file README.md?",
        expect: [ usesTools("count_lines"), contains('128') ],
        options: { tools: [countLines] }
      },
    ],
  },
];

Particularly helpful if you forget to change the description/name of a tool and the big model gets confused and iterates on a simple task for 5 minutes when most smaller models get it done in less than a minute.

This part of the lib allows you to build a benchmark to test your prompts and fine tune your local installation so your model pick and configuration works well for you.

Testing without a model

Faster iteration when the model's response is not directly the main focus.

import { fakeServer } from "@tym17/aki/fake";

const fake = fakeServer([
  { toolCalls: [{ name: "add", arguments: { a: 2, b: 3 } }] },
  { content: "5" },
]);

const agent = new Agent({ client: new LlamaClient({ fetch: fake.fetch }), tools });
const result = await agent.run("2 + 3?");

Samples

Small examples of usages of the lib might be more helpful to see how you can interact with it. Head to the samples folder, runs against both a fake server and your local install.

It has more examples like this:

// ...
const agent = new Agent({
  client: clientFor(fake),
  tools: [countLines],
  onEvent: (event) => {
    if (event.type === "tool_call") console.log(`  → ${event.call.function.name}(${event.call.function.arguments})`);
    if (event.type === "tool_result") console.log(`  ← ${event.content}`);
  },
});

const { text } = await agent.run("How long is the readme?");
console.log(`answer: ${text}`);
// ...

Compatibility

The wire format should be similar to OpenAI chat completions (POST /v1/chat/completions, Authorization: Bearer), so url can point at more than llama.cpp:

Backend url Notes Tested
llama-server http://127.0.0.1:8080 everything works, including json() and tok/s from timings
OpenRouter https://openrouter.ai/api set defaults.model; json() works where the model supports it x
Anthropic (OpenAI-compat) https://api.anthropic.com set defaults.model; response_format is ignored, so json() degrades to prose x

Three caveats away from llama.cpp: model becomes required. As most of this tiny library is llm generated despite heavy human review and tinkering and I only need it to work for local agents for now, it has only been tested for such purposes. If you happen to test it for one of these or another, any pull request to tick one of the tested boxes would be appreciated :).

Notes

License

MIT