Skip to content

Repository files navigation

TaskLoop

A self-running LLM task harness, fully local, with tag-filtered tools.

The one-sentence pitch

Every task has tags, every tool has tags, and the harness only loads the schemas for the tools whose tags match the task — so the model's context stays small and the loop can keep running for hours without drowning or stalling.

Why this exists

Running an LLM "task loop" today means babysitting it: context fills up with irrelevant tool definitions, the model drifts, it stalls, and someone has to monitor and restart it. The root cause is that most of the bloat is tool schemas the task never needs. Tag-filtered tools fix it at the source:

  • Less context, less latency — fewer schema tokens in every request, which is the difference between usable and unusable on local hardware.
  • Better function-calling — small models pick correctly between 5 tools much more reliably than between 25.
  • Safety by construction — a health-check task literally cannot see git or filesystem tools, so it can't drift into doing them. No hoping the model behaves; the tools aren't even in the prompt.

The core loop

  1. Task = summary + tags + schedule (one-off or recurring, e.g. "daily health check").
  2. select_tools — a pure, deterministic filter: task.tags ∩ tool.tags → toolbox. Fixed at run start. No mid-run dynamic switching (deliberate).
  3. Run — the prompt is intentionally small: task brief + plan + the toolbox schemas only. The model calls any tool in the toolbox; the harness executes against the tool's server and appends the results.
  4. Checkpoint — every step serialized to state.json; a crash resumes from the checkpoint instead of restarting from zero.
  5. Schedule — recurring tasks fire on cron; each occurrence spawns a fresh run with the same toolbox selection.

Grounding: the model's local facts

Tool results are raw machine data ({"day_of_year": 37}), and small models invent a calendar when they don't have one. So at run start the system prompt gets a grounding block appended: the current date/time/weekday/ISO week/season, plus your configured location and units.

Grounding is global — identical for every task, computed fresh per run. It can be switched off, but is never per-task or otherwise dynamic.

  • TASKLOOP_LOCATION= — free-text location line (omitted when unset).
  • TASKLOOP_UNITS=metric|imperial — the unit system the model should assume when interpreting numbers (validated; default metric).
  • TASKLOOP_GROUNDING_ENABLED=true — set false/0/no/off to drop the grounding block entirely (the task rules remain; only the facts go away).
  • TASKLOOP_GROUNDING_TIMEZONE= — optional IANA name (e.g. America/New_York) the reported clock is converted to; empty uses the machine's local zone. Unknown names fail loudly at startup, never silently mid-run.

Concurrency: the LLM is the scarce resource

Each run keeps its own state.json, so running multiple tasks never shares context. That means "concurrency" is really about one thing: how many LLM calls happen at once.

  • llm_max_concurrency: X — a single config knob (X=1 is the typical local setup).
  • A task occupies the LLM only while it's actually on the wire to the model. The moment its tool call starts executing — that's I/O wait, the model is idle — it lets go and another task can take the slot.
  • Two daily tasks hitting the scheduler together do not both fire at the model. The second is parked in a ready queue. Task A does an LLM step, hands off to a tool call, and while that runs, Task B gets the slot.

One mechanism gives all three behaviors for free:

  1. SerializeX=1: tasks effectively run one at a time; the queued one starts when the first has stopped needing the model (done, or busy in a tool).
  2. Take turns — the ready queue is FIFO; every ready task gets its next LLM step in turn, so a long-running task never starves a short one.
  3. Interleave at I/O — while task-1's tool call runs, task-2 does an LLM step. The "really good" case, and it's just the default behavior of not holding the slot during tool execution.

(The interleave is also why this is cheap to build: scheduling happens at the LLM call, not at the task level — no preempting run loops, no context juggling.)

Details that keep it safe:

  • The queue is persisted in state.json, so a crash/restart keeps waiting tasks waiting instead of losing their place.
  • Tools never call the LLM in v0, so there's no nested-acquire deadlock: a tool either completes, or the run loop releases the slot and retries.
  • Fairness is plain FIFO for now. Per-task priority / "jump the queue" is a near-future knob.

Context window: lean by construction

Tag-filtered toolboxes keep schema tokens small. Auto-compaction keeps the transcript small too: instead of re-sending the entire run history on every LLM turn, the harness builds a trimmed window each turn from the full record.

Rules (static — no summarization, no LLM involvement):

  1. Never re-send prior thinking/reasoning blocks.
  2. Never re-send old tool-call XML beyond the tail. Only the last TASKLOOP_WINDOW_TAIL_EXCHANGES exchanges (a tool-call turn + its results) are kept after the prompt head; 1 (default) keeps just the latest exchange.
  3. Always keep the system + task prompt at the front.
  4. Cap every tool result to max_tool_output_percentage of the context limit (default 10%), truncated with a …[truncated] marker so the model knows it's incomplete. 0 drops tool results entirely.

The DB record is never trimmed — it stays the full, honest transcript for resume-after-crash and the UI. Only what's sent to the model is cut down.

Multi-tool calls are recorded the OpenAI way: one assistant tool_calls turn for the whole model turn, followed by one tool result per call — never a separate turn per call. The alternate (old) shape dropped every parallel call but the last from the transcript, orphaning the other results' tool_call_ids: strict providers (OpenAI's API) 400, permissive ones quietly hide the forgotten call, and small models re-call the lost work — which is exactly the "keep re-calling the same tool" loop.

Three config knobs (global, env-driven, defaults shown):

  • TASKLOOP_CONTEXT_LIMIT=4000 — estimated-token hard cap (chars/4 heuristic) on the trimmed window sent each turn.
  • TASKLOOP_MAX_TOOL_OUTPUT_PERCENTAGE=10 — max size of one tool result as a percentage of context_limit.
  • TASKLOOP_WINDOW_TAIL_EXCHANGES=1 — how many recent exchanges the window keeps. 1 is minimal (a task forgets a tool call as soon as the next one runs — great small-model loop bait); 2-3 keeps recent steps so the model remembers where it's been. context_limit stays the hard cap, so a too-large tail still fails closed (context exhausted) rather than blow the budget mid-run.

If pruning still can't fit the budget, the run fails with a clear error (context exhausted: estimated tokens N > context_limit M) rather than being summarized-to-continue. LLM-driven compaction is deliberately out of scope.

Tool servers & protocols

Tools are not assumed to live on one box. The catalog declares named servers, and every tool points at the server that owns it. The harness resolves each tool to its server at run time and speaks whichever protocol that server exposes.

servers: — the named server registry

At the top of tools.yaml (and any shipped catalog), a servers: map declares each reachable tool server:

servers:
  mcp:                  # the local mcp-server (OpenAPI/JSON routes)
    url: http://localhost:8627
    api_key: ""         # optional bearer token
    protocol: openapi   # default; can be omitted
  vital:                # the health-data server
    url: http://localhost:9000
    protocol: mcp       # Streamable HTTP transport
  notes:                # the notes app
    url: http://localhost:3000/mcp
    protocol: mcp

Per-tool server: — which server, which route

Each tool's server: block names the server it belongs to (plus any route-level override). Everything not declared uses safe defaults:

tools:
  - name: log
    server:
      name: mcp          # required: which entry in `servers:`
      method: POST       # openapi-only; default POST
      base_path: log     # openapi-only; default /<tool name>

  - name: health.pull_steps
    server:
      name: vital        # protocol comes from the server entry (mcp)

  - name: weather
    server:
      name: mcp
      method: GET        # openapi GET with query params
      base_path: weather

Two protocols, both fully supported

  • openapi (HTTP/JSON) — the native format of the local mcp-server: one dedicated route per tool, JSON request/response. This is the default and is done today (method + base_path routing, GET query params, POST JSON body, optional bearer auth).
  • mcp (Streamable HTTP transport) — the modern MCP protocol, to be added. The harness initializes a session over HTTP (/initialize, tools/list, tools/call), then calls tools as MCP calls.

Deliberately out of scope: no stdio, no legacy HTTP+SSE transport, no third-party MCP SDK dependencies. Just the current, modern Streamable HTTP transport — written against the spec so any compliant server works (health data reader, notes app, etc.).

The protocol is a property of the server, not the tool, so a single catalog freely mixes servers — and a single task's toolbox can span several. select_tools never needs to know: it matches on tags, and the runner dispatches by server.

Config

mcp_server_url / mcp_api_key remain as a convenience alias for the common single-server case (equivalent to one servers: entry named mcp). When a servers: map exists in the registry, it wins.

Boot-time warnings for misconfigured servers

A server listed in tools.yaml that is effectively unusable used to fail silently — its tools would route to a stub or never be discovered, with no signal. At catalog load the registry now logs a WARNING for each of:

  • unknown keys on a server entry (almost always a typo: protcol: instead of protocol:), listing the ignored key(s) and the known keys;
  • no url: after placeholder expansion (discovery skipped, tools can't execute);
  • unrecognized protocol: (not openapi/mcp) — warned, and routing falls back to openapi;
  • tools pinned to a server that isn't configured, or whose configured entry has no URL;
  • discovery failing for a server at boot (was log.debug, invisible at the default log level) — now warned with the server's name, protocol, and URL, and last-known tools from the snapshot are kept.

Configurable system prompt

The base system prompt (the "you are running a local task harness…" text, with the raw-tool-data warning) ships in the repo at taskloop/prompts/system_prompt.md. To customize it, point at your own file rather than the bundled default:

TASKLOOP_SYSTEM_PROMPT_PATH=my-system-prompt.md

File-based on purpose: even a small prompt is multi-line prose, and a file is far more ergonomic to edit and version than cramming it into an env var. A missing file or whitespace-only file fails loudly at startup; an unset path keeps the shipped default. The dynamic grounding block (clock/units/ location, configured via TASKLOOP_LOCATION / TASKLOOP_UNITS) is always appended on top at run start, so a customization never loses the running clock.

Secrets & configuration: bare metal vs Docker

Configuration follows 12-factor: real environment variables are the source of truth, and a local .env file is a dev-only convenience.

  • Bare metal / dev: cp .env.example .env, edit, run. TASKLOOP_ENV defaults to development, so the .env is loaded automatically.
  • Docker / production: set TASKLOOP_ENV=production. The .env loader short-circuits before python-dotenv is even imported, and every setting arrives as a real environment variable (see docker-compose.yml for the shape). .env is also excluded from the image via .dockerignore.

Two mechanisms make this "best of both worlds":

  1. The env switch (TASKLOOP_ENV) gates .env loading in config.py: prod never runs load_dotenv, so there is zero env-file processing in a deployed container.
  2. ${VAR:-default} substitution in tools.yaml — same shell/ docker-compose semantics the rest of the stack uses. Server URLs and API keys live in the environment, not in the catalog:
servers:
  mcp:
    url: ${TASKLOOP_MCP_URL:-http://localhost:8627}
    api_key: ${TASKLOOP_MCP_API_KEY:-}
    protocol: openapi
  vital:
    url: ${TASKLOOP_VITAL_URL:-http://localhost:9000}
    protocol: mcp

${VAR} expands to the env value (empty if unset); ${VAR:-default} falls back to default when unset/empty. And load_dotenv uses override=False, so an already-set env var always beats a .env value — .env can only fill gaps, never clobber real config.

Tool catalog

A registry (YAML, same pattern as mcp-server's) where every tool declares:

  • name, description (one line)
  • tags: — the domains it belongs to
  • side_effect: flag — can it change state or touch the outside world?
  • server: — which named server owns it and how to reach it

The one-liner + tags are what select_tools reads (and later auto-tagging). The heavy JSON schemas only ever enter the prompt for the selected toolbox, never for the full catalog.

Initial catalog pulls in what already exists: mcp-server registry (log/notify/commands) and vital-pulse (health data readers), tagged and ready. The health-data and notes servers (both MCP protocol) are the first real third-party integration targets once Streamable HTTP support lands.

known-tools.yaml — the discovered-tools snapshot

The harness never hand-maintains tools.yaml from server output. At boot it discovers what each configured server advertises (tools/list / openapi.json) and writes a normalized snapshot to known-tools.yaml (next to tools.yaml, or TASKLOOP_KNOWN_TOOLS_PATH):

# known-tools.yaml — generated snapshot of server-discovered tools.
#
# Written automatically at boot from what each configured server
# advertises via tools/list / openapi.json. Hand-curated overrides
# belong in tools.yaml (explicit wins, never rewritten here). This
# file is a snapshot: edits are overwritten on the next boot.
# Missing (server down) entries are kept; removed (server stopped
# advertising) entries are dropped on the next successful boot.
tools:
  - name: notes.search
    description: Search your notes
    tags: [notes]
    server:
      name: notes
    schema:
      type: object
      properties:
        q:
          type: string

Why a separate file instead of appending into tools.yaml?

  • tools.yaml stays explicit. It's the hand-curated override file — the harness never rewrites it, so your edits and tags are never stomped.
  • known-tools.yaml is a copy-friendly snapshot. Every entry carries the full discovered schema + server routing + inherited tags, so you can lift one straight into tools.yaml to pin it (add/manually tune tags, then it leaves the snapshot on the next boot).
  • Merge order — explicit always wins: tools.yaml > known-tools.yaml

    live discovery.

  • Graceful drift & downtime. Tools a server stops advertising are dropped on the next successful boot (snapshot, not archive); tools missing only because a server was down are kept, so a flaky server doesn't wipe the known catalog. The boot log reports N known-tools (X new, Y updated since last boot).

Not set in stone: because the snapshot is regenerated every boot, hand edits to known-tools.yaml are overwritten — that's by design. Pin-then-edit in tools.yaml instead.

Mobile web UI (phone-first)

A dead-simple interface, because managing tasks has to work from the phone:

  • Task list with run status — big tap targets
  • Create/edit a task: summary + tags + schedule (with suggested tags later)
  • Recent run history and results per task
  • Scheduler view: which task currently holds the LLM, how many are queued and waiting, run-now from the phone

Status & roadmap

Done:

  • Tool registry with tags: (extends the mcp-server registry format)
  • Task CRUD + select_tools static filter
  • Concurrency limiter: llm_max_concurrency semaphore + persisted FIFO ready queue + natural interleaving while a tool call is in flight
  • One-shot run loop: compile prompt → local LLM (OpenAI-compatible: Ollama/vLLM/llama.cpp) → execute tool calls → checkpoint/resume
  • Real execution wired to mcp-server (weather / log / log_read end-to-end, verified live) with per-tool server: {method, base_path} routing
  • Minimal mobile-friendly UI, incl. a scheduler view: task list, create, run, history, and who's on the LLM / who's queued
  • Auto-tagging at task creation — a cheap LLM turn: task summary + the one-line tool list → "choose your tool tags" when nothing is picked manually
  • Config & secrets: TASKLOOP_ENV switch — prod never touches .env, ${VAR:-default} env substitution in tools.yaml, .env.example + .dockerignore + production-style compose example

In progress (this branch):

  1. MCP Streamable HTTP protocol support — alongside the existing OpenAPI path, so tools on real MCP servers (health data, notes app) execute too. No stdio / legacy transports.

Near-future (not yet started):

  • Run-history digests / a "check in" notification when a task needs attention.
  • A request_tool escape hatch for mid-run pivots (deferred on purpose — static selection first).
  • Per-task priority / jump-the-queue in the LLM slot scheduler.

Stack

  • Backend: Python (FastAPI, asyncio) — shares the Python ecosystem with mcp-server and keeps LLM/tool-call JSON handling natural; async makes the concurrency limiter straightforward
  • Storage: SQLite for tasks, runs, and checkpoints
  • LLM: local, via an OpenAI-compatible endpoint (Ollama / vLLM / llama.cpp)
  • Tools: execution delegated to one or more tool servers — the local mcp-server over OpenAPI, and any modern MCP (Streamable HTTP) server
  • UI: lightweight server-rendered mobile pages (vanilla JS / Alpine)

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages