-
Notifications
You must be signed in to change notification settings - Fork 2
feat(agent): local-model room agent (Ollama -> GroupMind, offline-capable) #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+272
−2
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
b10ea3e
feat(agent): local-model room agent (Ollama -> GroupMind, cloud/relay…
ThinkOffApp 5ab0a57
feat(agent): strip <think> reasoning + default to the Mini's qwen3.6 MoE
ThinkOffApp 01fa385
feat(agent): default to qwen3.6 MoE Q3_K_M + keep_alive unload
ThinkOffApp File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| #!/usr/bin/env node | ||
| // SPDX-License-Identifier: AGPL-3.0-only | ||
| // | ||
| // iak-local-agent — put a local (Ollama) model into a GroupMind room. | ||
| // | ||
| // Runs a small poll loop: read the room, and when a message @mentions this | ||
| // agent's handle, prompt the local model over Ollama's HTTP API and post the | ||
| // reply back — so an on-device model becomes a first-class room participant. | ||
| // | ||
| // It fails over between endpoints: it reads/writes cloud GroupMind when the | ||
| // internet is up, and the LAN GroupMind Local relay when it isn't. Point mm and | ||
| // mb agents at the same relay, flip the net off, and the local pair keeps | ||
| // talking — no cloud, no API keys, models running on-device. | ||
| // | ||
| // Zero runtime deps (global fetch + built-ins), matching the rest of IAK. | ||
| // | ||
| // Config (via runAgent(config) or env for the CLI): | ||
| // handle this agent's @handle (it never replies to itself) | ||
| // room room slug | ||
| // cloud { baseUrl, apiKey } cloud GroupMind (preferred when up) | ||
| // relay { baseUrl, token? } LAN relay (failover) | ||
| // ollama { url, model } e.g. http://127.0.0.1:11434, gpt-oss:20b | ||
| // systemPrompt persona / instructions for the model | ||
| // respondTo 'mention' (default) or 'all' | ||
| // pollMs poll interval (default 4000) | ||
| // maxContext how many recent messages to feed the model (default 8) | ||
|
|
||
| const MENTION = (handle) => new RegExp(`(^|[^\\w])@?${handle.replace(/^@/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); | ||
|
|
||
| export function mentions(body, handle) { | ||
| return MENTION(handle).test(String(body || '')); | ||
| } | ||
|
|
||
| export function sameHandle(a, b) { | ||
| return String(a || '').replace(/^@/, '').toLowerCase() === String(b || '').replace(/^@/, '').toLowerCase(); | ||
| } | ||
|
|
||
| // Ask the local Ollama model for a reply. Returns trimmed text ('' on empty). | ||
| export async function generateReply({ url, model, systemPrompt, context, keepAlive }) { | ||
| const res = await fetch(`${url.replace(/\/$/, '')}/api/chat`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| model, | ||
| stream: false, | ||
| // Unload the model after idle so a big local model (e.g. the Mini's 17GB | ||
| // MoE) doesn't permanently pin RAM the machine's other services need. | ||
| keep_alive: keepAlive || '5m', | ||
| messages: [ | ||
| ...(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []), | ||
| { role: 'user', content: context }, | ||
| ], | ||
| }), | ||
| signal: AbortSignal.timeout(180_000), | ||
| }); | ||
| if (!res.ok) throw new Error(`ollama HTTP ${res.status}: ${(await res.text()).slice(0, 120)}`); | ||
| const d = await res.json(); | ||
| // Strip thinking-model output (<think>...</think>) so room replies read clean — | ||
| // the fleet's local models (e.g. qwen3.6 MoE) are reasoning models. | ||
| return String(d?.message?.content || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim(); | ||
| } | ||
|
|
||
| function headersFor(ep) { | ||
| const h = { 'Content-Type': 'application/json' }; | ||
| const key = ep.apiKey || ep.token; | ||
| if (key) { h['X-API-Key'] = key; h['Authorization'] = `Bearer ${key}`; } | ||
| return h; | ||
| } | ||
|
|
||
| async function fetchMessages(ep, room, limit) { | ||
| const res = await fetch(`${ep.baseUrl.replace(/\/$/, '')}/rooms/${encodeURIComponent(room)}/messages?limit=${limit}`, | ||
| { headers: headersFor(ep), signal: AbortSignal.timeout(8000) }); | ||
| if (!res.ok) throw new Error(`fetch HTTP ${res.status}`); | ||
| const d = await res.json(); | ||
| return d?.messages || []; | ||
| } | ||
|
|
||
| async function postMessage(ep, room, body) { | ||
| const res = await fetch(`${ep.baseUrl.replace(/\/$/, '')}/messages`, | ||
| { method: 'POST', headers: headersFor(ep), body: JSON.stringify({ room, body }), signal: AbortSignal.timeout(8000) }); | ||
| if (!res.ok) throw new Error(`post HTTP ${res.status}`); | ||
| return res.json().catch(() => ({})); | ||
| } | ||
|
|
||
| // Try cloud first, fall back to relay. Returns { ep, messages } or null. | ||
| async function readWithFailover(cfg, log) { | ||
| const eps = [cfg.cloud, cfg.relay].filter((e) => e && e.baseUrl); | ||
| for (const ep of eps) { | ||
| try { | ||
| const messages = await fetchMessages(ep, cfg.room, cfg.limit); | ||
| return { ep, messages }; | ||
| } catch (e) { | ||
| log(`read via ${ep.baseUrl} failed (${e.message}); trying next`); | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| // One poll cycle. `state` carries { seen:Set, primed:bool }. Injectable deps | ||
| // (read/generate/post) make it unit-testable without a live room or model. | ||
| export async function tick(cfg, state, deps) { | ||
| const read = deps?.read || (() => readWithFailover(cfg, deps.log)); | ||
| const generate = deps?.generate || generateReply; | ||
| const post = deps?.post || postMessage; | ||
| const log = deps?.log || (() => {}); | ||
|
|
||
| const r = await read(); | ||
| if (!r || !r.messages) return; | ||
| const chronological = [...r.messages].reverse(); // room returns newest-first | ||
| for (const m of chronological) { | ||
| if (state.seen.has(m.id)) continue; | ||
| state.seen.add(m.id); | ||
| if (!state.primed) continue; // ignore history on the first cycle | ||
| if (sameHandle(m.from, cfg.handle)) continue; // never reply to ourselves | ||
| if ((cfg.respondTo || 'mention') === 'mention' && !mentions(m.body, cfg.handle)) continue; | ||
| try { | ||
| const context = `${m.from}: ${m.body}`; | ||
| const reply = await generate({ ...cfg.ollama, systemPrompt: cfg.systemPrompt, context }); | ||
| if (reply) { await post(r.ep, cfg.room, reply); log(`replied to ${m.from} (${reply.length} chars)`); } | ||
| } catch (e) { | ||
| log(`reply failed for ${m.id}: ${e.message}`); | ||
| } | ||
| } | ||
| state.primed = true; | ||
| } | ||
|
|
||
| export async function runAgent(cfg) { | ||
| const log = cfg.logger || ((m) => process.stderr.write(`[iak-local-agent:${cfg.handle}] ${m}\n`)); | ||
| const full = { limit: cfg.maxContext || 8, pollMs: cfg.pollMs || 4000, ...cfg }; | ||
| const state = { seen: new Set(), primed: false }; | ||
| log(`up — model=${cfg.ollama?.model} room=${cfg.room} respondTo=${cfg.respondTo || 'mention'}`); | ||
| // eslint-disable-next-line no-constant-condition | ||
| while (true) { | ||
| try { await tick(full, state, { log }); } catch (e) { log(`tick error: ${e.message}`); } | ||
| await new Promise((r) => setTimeout(r, full.pollMs)); | ||
| } | ||
| } | ||
|
|
||
| // CLI: config from IAK dogfood config + env overrides. | ||
| if (import.meta.url === `file://${process.argv[1]}`) { | ||
| const { readFileSync } = await import('node:fs'); | ||
| const cfgPath = process.env.IAK_CONFIG || '/Users/petrus/ide-agent-kit/config/dogfood.json'; | ||
| let base = {}; | ||
| try { base = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { /* fall back to env */ } | ||
| runAgent({ | ||
| handle: process.env.AGENT_HANDLE || 'mm-local', | ||
| room: process.env.AGENT_ROOM || base?.poller?.room || base?.mcp?.confirmations?.room || 'thinkoff-development', | ||
| cloud: { baseUrl: 'https://groupmind.one/api/v1', apiKey: process.env.AGENT_KEY || base?.poller?.api_key }, | ||
| relay: process.env.RELAY_URL ? { baseUrl: process.env.RELAY_URL, token: process.env.IAK_RELAY_TOKEN } : undefined, | ||
| ollama: { | ||
| url: process.env.OLLAMA_URL || 'http://127.0.0.1:11434', | ||
| model: process.env.OLLAMA_MODEL || 'hf.co/unsloth/Qwen3.6-35B-A3B-GGUF:UD-Q3_K_M', | ||
| keepAlive: process.env.OLLAMA_KEEP_ALIVE || '5m', | ||
| }, | ||
| systemPrompt: process.env.AGENT_SYSTEM | ||
| || `You are ${process.env.AGENT_HANDLE || 'mm-local'}, a local on-device model running on a Mac mini in the ThinkOff fleet room. Be concise and useful. You run fully offline via Ollama.`, | ||
| respondTo: process.env.AGENT_RESPOND_TO || 'mention', | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import test from 'node:test'; | ||
| import http from 'node:http'; | ||
| import { once } from 'node:events'; | ||
| import { mentions, sameHandle, generateReply, tick } from '../scripts/local-agent.mjs'; | ||
|
|
||
| test('mentions detects @handle and ignores non-mentions', () => { | ||
| assert.equal(mentions('hey @mm-local can you help', 'mm-local'), true); | ||
| assert.equal(mentions('hey @mm-local can you help', '@mm-local'), true); | ||
| assert.equal(mentions('MM-LOCAL please', 'mm-local'), true); | ||
| assert.equal(mentions('talking about mm-locality here', 'mm-local'), false); | ||
| assert.equal(mentions('nothing to see', 'mm-local'), false); | ||
| }); | ||
|
|
||
| test('sameHandle is @- and case-insensitive', () => { | ||
| assert.equal(sameHandle('@MM-Local', 'mm-local'), true); | ||
| assert.equal(sameHandle('mm-local', '@mm-local'), true); | ||
| assert.equal(sameHandle('@other', 'mm-local'), false); | ||
| }); | ||
|
|
||
| test('generateReply POSTs to ollama /api/chat and returns the content', async () => { | ||
| let received = null; | ||
| const server = http.createServer((req, res) => { | ||
| let b = ''; | ||
| req.on('data', (c) => (b += c)); | ||
| req.on('end', () => { | ||
| received = JSON.parse(b); | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ message: { content: ' hi from the local model ' } })); | ||
| }); | ||
| }); | ||
| server.listen(0); | ||
| await once(server, 'listening'); | ||
| try { | ||
| const url = `http://127.0.0.1:${server.address().port}`; | ||
| const out = await generateReply({ url, model: 'qwen3.6:27b', systemPrompt: 'be nice', context: '@p: hello' }); | ||
| assert.equal(out, 'hi from the local model'); // trimmed | ||
| assert.equal(received.model, 'qwen3.6:27b'); | ||
| assert.equal(received.stream, false); | ||
| assert.equal(received.messages[0].role, 'system'); | ||
| assert.equal(received.messages[1].content, '@p: hello'); | ||
| } finally { | ||
| server.close(); | ||
| await once(server, 'close'); | ||
| } | ||
| }); | ||
|
|
||
| test('generateReply strips <think> reasoning from thinking models', async () => { | ||
| const server = http.createServer((req, res) => { | ||
| let b = ''; | ||
| req.on('data', (c) => (b += c)); | ||
| req.on('end', () => { | ||
| res.writeHead(200, { 'Content-Type': 'application/json' }); | ||
| res.end(JSON.stringify({ message: { content: '<think>let me reason about this\nstep by step</think>\n\nThe answer is 42.' } })); | ||
| }); | ||
| }); | ||
| server.listen(0); | ||
| await once(server, 'listening'); | ||
| try { | ||
| const out = await generateReply({ url: `http://127.0.0.1:${server.address().port}`, model: 'm', context: 'q' }); | ||
| assert.equal(out, 'The answer is 42.', 'thinking stripped, only the answer remains'); | ||
| } finally { | ||
| server.close(); | ||
| await once(server, 'close'); | ||
| } | ||
| }); | ||
|
|
||
| test('tick: skips history, then replies to a mention (not self, not non-mention)', async () => { | ||
| const posts = []; | ||
| const cfg = { room: 'r', handle: 'mm-local', ollama: { url: 'x', model: 'm' }, respondTo: 'mention' }; | ||
| const deps = { | ||
| generate: async ({ context }) => `echo:${context}`, | ||
| post: async (ep, room, body) => { posts.push({ room, body }); }, | ||
| log: () => {}, | ||
| }; | ||
| const state = { seen: new Set(), primed: false }; | ||
|
|
||
| // tick 1: one historical message that DOES mention us — must be skipped (priming) | ||
| deps.read = async () => ({ ep: { baseUrl: 'x' }, messages: [{ id: '1', from: '@petrus', body: '@mm-local hi' }] }); | ||
| await tick(cfg, state, deps); | ||
| assert.equal(posts.length, 0, 'history is not answered'); | ||
|
|
||
| // tick 2: a NEW mention -> reply; plus self + non-mention that must be ignored | ||
| deps.read = async () => ({ | ||
| ep: { baseUrl: 'x' }, | ||
| messages: [ // newest-first, as the API returns | ||
| { id: '4', from: '@other', body: 'unrelated chatter' }, | ||
| { id: '3', from: '@mm-local', body: '@mm-local self echo loop' }, | ||
| { id: '2', from: '@petrus', body: '@mm-local what is 2+2' }, | ||
| { id: '1', from: '@petrus', body: '@mm-local hi' }, | ||
| ], | ||
| }); | ||
| await tick(cfg, state, deps); | ||
| assert.equal(posts.length, 1, 'exactly one reply (the new mention)'); | ||
| assert.equal(posts[0].body, 'echo:@petrus: @mm-local what is 2+2'); | ||
| }); | ||
|
|
||
| test('tick: respondTo=all replies to any new non-self message', async () => { | ||
| const posts = []; | ||
| const cfg = { room: 'r', handle: 'mm-local', ollama: { url: 'x', model: 'm' }, respondTo: 'all' }; | ||
| const state = { seen: new Set(), primed: true }; // already primed | ||
| const deps = { | ||
| read: async () => ({ ep: { baseUrl: 'x' }, messages: [{ id: '9', from: '@petrus', body: 'no mention here' }] }), | ||
| generate: async () => 'sure', | ||
| post: async (ep, room, body) => { posts.push(body); }, | ||
| log: () => {}, | ||
| }; | ||
| await tick(cfg, state, deps); | ||
| assert.equal(posts.length, 1); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In offline/relay mode with
respondTo: 'all', this POST omitsfrom: cfg.handle, soscripts/local-relay.mjsstores the agent's own replies asfrom: "unknown"; on the next poll they no longer matchsameHandle(m.from, cfg.handle)and the agent replies to its own previous relay message indefinitely. Include the configured handle when posting to the local relay so the self guard still works.Useful? React with 👍 / 👎.