Skip to content

feat(loki): give Loki its own in-app tool loop - #214

Merged
github-actions[bot] merged 1 commit into
mainfrom
feat/loki-tool-loop
Aug 13, 2026
Merged

feat(loki): give Loki its own in-app tool loop#214
github-actions[bot] merged 1 commit into
mainfrom
feat/loki-tool-loop

Conversation

@catomean

Copy link
Copy Markdown
Collaborator

Follow-up to #212. That PR grounded Loki's context; this one moves retrieval in-app so the grounding is actually enforceable.

Why

Loki was a thin proxy to the OpenClaw main agent, whose tool root is ~/.openclaw/workspace/. Asked who to contact, it grepped a schema-less JSON blob there and narrated incidental matches as attributes — "Ilya Druzhnikov (UZH)", where UZH is the substring inside drUZHnikov. FleetCrown's own people table was invisible to it.

You cannot constrain an agent whose retrieval you don't own.

What lands

File Role
tools/registry.ts SSOT + defineTool(). Two kinds only: read and propose.
tools/handlers.ts 8 tools over FleetCrown's own tables — people, projects, pgvector knowledge, goals, habits, commitments/events, propose_action, and ask_openclaw (the gateway demoted to one tool, output labelled an unverified second-hand report).
llm.ts The model seam, with two tool protocols.
loop.ts seed → model → tools → facts → verify → repair.

The invariant: every tool returns Fact[], never prose. A tool result enters the same contract as everything else — declared fields, <not recorded> gaps, a citation id, the verifier. There is no channel by which a tool can put text in front of the model without it also becoming checkable evidence. ToolResult has no escape hatch.

No execute kind exists. Loki still cannot act; it queues drafts and the operator approves. A test asserts that boundary so adding an executing tool fails CI.

Dual protocol — measured, not assumed

Tool calling is where cheap models diverge most. Native tools is advertised, and a line protocol is parsed from ordinary text on every response regardless:

TOOL: search_people
ARGS: {"query": "Elena"}

npm run probe:loki-models against live Groq:

✓ llama-3.3-70b-versatile   calls=1 via=text
✓ llama-3.1-8b-instant      calls=1 via=text
✓ qwen/qwen3.6-27b          calls=1 via=text
✓ allam-2-7b                calls=1 via=text
✓ groq/compound-mini        calls=1 via=text

Every model — down to 7B — drove the loop, and every one used the TEXT protocol, including models where native tool calling was advertised and supported. A native-only parser would have scored zero on all five. Line-based rather than nested JSON is chosen for the weakest model expected to run it.

Bounds are observed failure modes

3 rounds (weak models re-call a tool that already answered); the final round advertises no tools so the model is forced to answer instead of hanging; a repair is kept only if it reduces violations, so the safety pass cannot degrade a turn.

loki-core prefers the loop and falls back to the gateway (still grounded and verified) on error or LOKI_TOOL_LOOP=0 — an operational escape hatch, not a feature flag.

Verification

Loop deps are imported lazily so the module stays pure to load — which is what lets scripts/test/agent-tool-loop.ts run with no DB and no API key. 10 checks: protocol tolerance (bolded keys, fenced ARGS, copied parens, bare calls, trailing prose), the no-execute boundary, fact accumulation, bad-argument recovery, bounds. 61/61 unit files pass, tsc clean, 0 lint errors.

⚠️ Unrelated live bug found while probing: GROQ_VISION_MODEL (meta-llama/llama-4-scout-17b-16e-instruct) is decommissioned — 404, and no vision model is available on the account. Loki's image attachments therefore always fail. It degrades honestly (visible error, no fabrication), so it is not a grounding bug and is left for a separate decision about which vision provider to move to.

🤖 Generated with Claude Code

Loki was a thin proxy to the OpenClaw `main` agent, whose tool root is
~/.openclaw/workspace/. Asked who to contact, it grepped a schema-less
JSON blob there and narrated the incidental matches as attributes —
"Ilya Druzhnikov (UZH)", where UZH is the substring inside drUZHnikov.
FleetCrown's own `people` table, the authoritative one, was invisible.

You cannot constrain an agent whose retrieval you do not own. The
previous PR grounded the CONTEXT; this moves retrieval in-app so the
grounding is actually enforceable.

  tools/registry.ts   SSOT + defineTool(). Two kinds only: `read` and
                      `propose`. There is deliberately no `execute` —
                      Loki still cannot act, it queues drafts and the
                      operator approves. A test asserts that boundary.
  tools/handlers.ts   8 tools over FleetCrown's own tables: people,
                      projects, pgvector knowledge, goals, habits,
                      commitments/events, propose_action — and
                      ask_openclaw, the gateway DEMOTED to one tool
                      whose output is labelled an unverified
                      second-hand report.
  llm.ts              the model seam, with TWO tool protocols.
  loop.ts             seed → model → tools → facts → verify → repair.

THE INVARIANT: every tool returns Fact[], never prose. A tool result
therefore enters the same contract as everything else — declared fields,
<not recorded> gaps, a citation id, the verifier. There is no channel by
which a tool can put text in front of the model without it also becoming
checkable evidence. ToolResult has no escape hatch; keep it that way.

Dual protocol, because tool calling is where cheap models diverge most.
Native `tools` is advertised; a line protocol (TOOL:/ARGS:) is parsed
from ordinary text on every response regardless, and the two are merged
and deduped. Line-based, not nested JSON, chosen for the weakest model
expected to run it.

That decision is measured, not assumed. `npm run probe:loki-models`
against live Groq: llama-3.3-70b, llama-3.1-8b-instant, qwen3.6-27b,
allam-2-7b and compound-mini ALL drive the loop — and all five did it
via the TEXT protocol, even where native tool calling was advertised
and supported. A native-only parser would have scored zero on every one.

Loop bounds are observed failure modes, not padding: weak models re-call
a tool that already answered, so 3 rounds; the final round advertises no
tools so the model is forced to answer rather than hang; a repair is kept
only if it reduces violations, so the safety pass cannot degrade a turn.

loki-core prefers the loop, falls back to the gateway (still grounded and
verified) on error or LOKI_TOOL_LOOP=0 — an operational escape hatch, not
a feature flag.

Loop deps are imported lazily so the module stays pure to load, which is
what lets scripts/test/agent-tool-loop.ts run with no DB and no API key:
10 checks over protocol tolerance (bolded keys, fenced ARGS, copied
parens, bare calls, trailing prose), the no-execute boundary, fact
accumulation, bad-argument recovery, and bounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions
github-actions Bot merged commit 538d999 into main Aug 13, 2026
1 check passed
@github-actions
github-actions Bot deleted the feat/loki-tool-loop branch August 13, 2026 11:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant