groove-dev 0.25.21 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/node_modules/@groove-dev/daemon/src/agent-loop.js +444 -0
  2. package/node_modules/@groove-dev/daemon/src/api.js +104 -5
  3. package/node_modules/@groove-dev/daemon/src/index.js +6 -1
  4. package/node_modules/@groove-dev/daemon/src/llama-server.js +268 -0
  5. package/node_modules/@groove-dev/daemon/src/model-manager.js +411 -0
  6. package/node_modules/@groove-dev/daemon/src/process.js +160 -9
  7. package/node_modules/@groove-dev/daemon/src/providers/codex.js +51 -1
  8. package/node_modules/@groove-dev/daemon/src/providers/gemini.js +3 -2
  9. package/node_modules/@groove-dev/daemon/src/providers/index.js +4 -0
  10. package/node_modules/@groove-dev/daemon/src/providers/local.js +183 -0
  11. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  12. package/node_modules/@groove-dev/daemon/src/tool-executor.js +367 -0
  13. package/node_modules/@groove-dev/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  14. package/node_modules/@groove-dev/gui/dist/assets/index-BQnZrh4f.css +1 -0
  15. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  16. package/node_modules/@groove-dev/gui/src/app.jsx +2 -0
  17. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +7 -2
  18. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  19. package/node_modules/@groove-dev/gui/src/stores/groove.js +7 -1
  20. package/node_modules/@groove-dev/gui/src/views/models.jsx +380 -0
  21. package/package.json +2 -2
  22. package/packages/daemon/src/agent-loop.js +444 -0
  23. package/packages/daemon/src/api.js +104 -5
  24. package/packages/daemon/src/index.js +6 -1
  25. package/packages/daemon/src/llama-server.js +268 -0
  26. package/packages/daemon/src/model-manager.js +411 -0
  27. package/packages/daemon/src/process.js +160 -9
  28. package/packages/daemon/src/providers/codex.js +51 -1
  29. package/packages/daemon/src/providers/gemini.js +3 -2
  30. package/packages/daemon/src/providers/index.js +4 -0
  31. package/packages/daemon/src/providers/local.js +183 -0
  32. package/packages/daemon/src/registry.js +1 -1
  33. package/packages/daemon/src/tool-executor.js +367 -0
  34. package/packages/gui/dist/assets/index-BC2Bhfv0.js +633 -0
  35. package/packages/gui/dist/assets/index-BQnZrh4f.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/src/app.jsx +2 -0
  38. package/packages/gui/src/components/agents/agent-config.jsx +7 -2
  39. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  40. package/packages/gui/src/stores/groove.js +7 -1
  41. package/packages/gui/src/views/models.jsx +380 -0
  42. package/node_modules/@groove-dev/gui/dist/assets/index-B1FkEzF0.js +0 -623
  43. package/node_modules/@groove-dev/gui/dist/assets/index-GYcMwmjs.css +0 -1
  44. package/packages/gui/dist/assets/index-B1FkEzF0.js +0 -623
  45. package/packages/gui/dist/assets/index-GYcMwmjs.css +0 -1
@@ -5,6 +5,7 @@ import { spawn as cpSpawn } from 'child_process';
5
5
  import { createWriteStream, mkdirSync, chmodSync, existsSync, readFileSync, unlinkSync } from 'fs';
6
6
  import { resolve } from 'path';
7
7
  import { getProvider, getInstalledProviders } from './providers/index.js';
8
+ import { AgentLoop } from './agent-loop.js';
8
9
  import { validateAgentConfig } from './validate.js';
9
10
 
10
11
  // Role-specific prompt prefixes — applied during spawn regardless of entry point
@@ -195,8 +196,9 @@ export class ProcessManager {
195
196
  }
196
197
 
197
198
  // Resolve auto model routing before registering
199
+ // Treat missing/null/empty model as 'auto' — GUI sends empty string for "Auto" option
198
200
  let resolvedModel = config.model;
199
- const isAutoRouted = config.model === 'auto';
201
+ const isAutoRouted = !config.model || config.model === 'auto';
200
202
 
201
203
  // Register the agent in the registry
202
204
  const agent = registry.add({
@@ -210,7 +212,7 @@ export class ProcessManager {
210
212
  const { router } = this.daemon;
211
213
  router.setMode(agent.id, 'auto');
212
214
  const rec = router.recommend(agent.id);
213
- if (rec) {
215
+ if (rec?.model?.id) {
214
216
  resolvedModel = rec.model.id;
215
217
  registry.update(agent.id, { model: resolvedModel, routingMode: 'auto', routingReason: rec.reason });
216
218
  }
@@ -316,6 +318,71 @@ For normal file edits within your scope, proceed without review.
316
318
  }
317
319
  }
318
320
 
321
+ // Set up log capture (shared between CLI and agent loop paths)
322
+ const logDir = resolve(this.daemon.grooveDir, 'logs');
323
+ mkdirSync(logDir, { recursive: true });
324
+ const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
325
+ const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
326
+
327
+ // ─── Agent Loop path (local models with built-in agentic runtime) ───
328
+ if (provider.constructor.useAgentLoop) {
329
+ const loopConfig = provider.getLoopConfig(spawnConfig);
330
+ logStream.write(`[${new Date().toISOString()}] GROOVE agent-loop: model=${loopConfig.model} api=${loopConfig.apiBase}\n`);
331
+
332
+ const loop = new AgentLoop({ daemon: this.daemon, agent, loopConfig, logStream });
333
+ this.handles.set(agent.id, { loop, logStream });
334
+ registry.update(agent.id, { status: 'running' });
335
+
336
+ // Record spawn lifecycle event
337
+ if (this.daemon.timeline) {
338
+ this.daemon.timeline.recordEvent('spawn', {
339
+ agentId: agent.id, agentName: agent.name, role: agent.role,
340
+ provider: agent.provider, model: loopConfig.model,
341
+ });
342
+ }
343
+
344
+ // Wire output events — ProcessManager handles subsystem feeding + GUI broadcast
345
+ loop.on('output', (output) => {
346
+ this._handleAgentOutput(agent.id, output);
347
+ });
348
+
349
+ // Wire exit — same lifecycle as CLI agents (timeline, broadcast, journalist, phase2)
350
+ loop.on('exit', ({ code, signal, status }) => {
351
+ logStream.write(`[${new Date().toISOString()}] Agent loop exited: status=${status}\n`);
352
+ logStream.end();
353
+ this.handles.delete(agent.id);
354
+ registry.update(agent.id, { status, pid: null });
355
+
356
+ if (this.daemon.timeline) {
357
+ const agentData = registry.get(agent.id);
358
+ const evtType = status === 'completed' ? 'complete' : status === 'crashed' ? 'crash' : 'kill';
359
+ this.daemon.timeline.recordEvent(evtType, {
360
+ agentId: agent.id, agentName: agent.name, role: agent.role,
361
+ finalTokens: agentData?.tokensUsed || 0, costUsd: agentData?.costUsd || 0,
362
+ });
363
+ }
364
+
365
+ this.daemon.broadcast({ type: 'agent:exit', agentId: agent.id, code: code || 0, signal, status });
366
+ if (this.daemon.integrations) this.daemon.integrations.refreshMcpJson();
367
+ if (status === 'completed' && this.daemon.journalist) this.daemon.journalist.cycle().catch(() => {});
368
+ this._checkPhase2(agent.id);
369
+ });
370
+
371
+ // Wire errors — broadcast to GUI for display
372
+ loop.on('error', ({ message }) => {
373
+ this.daemon.broadcast({
374
+ type: 'agent:output', agentId: agent.id,
375
+ data: { type: 'activity', subtype: 'error', data: message },
376
+ });
377
+ });
378
+
379
+ // Start the agent loop with the fully assembled prompt
380
+ loop.start(spawnConfig.prompt);
381
+ return agent;
382
+ }
383
+
384
+ // ─── CLI Spawn path (Claude Code, Codex, Gemini, Ollama CLI) ────────
385
+
319
386
  // Write MCP config for agent integrations (command/args only, no secrets)
320
387
  // Credentials are injected via process environment below
321
388
  let integrationEnv = {};
@@ -327,12 +394,6 @@ For normal file edits within your scope, proceed without review.
327
394
  const spawnCmd = provider.buildSpawnCommand(spawnConfig);
328
395
  const { command, args, env, stdin: stdinData } = spawnCmd;
329
396
 
330
- // Set up log capture
331
- const logDir = resolve(this.daemon.grooveDir, 'logs');
332
- mkdirSync(logDir, { recursive: true });
333
- const logPath = resolve(logDir, `${sanitizeFilename(agent.name)}.log`);
334
- const logStream = createWriteStream(logPath, { flags: 'a', mode: 0o600 });
335
-
336
397
  // Log the spawn command (mask anything that looks like an API key)
337
398
  const maskArg = (a) => /^(sk-|AIza|key-|token-)/.test(a) ? '***' : a;
338
399
  const safeArgs = args.map((a) => maskArg(a.includes(' ') ? `"${a}"` : a));
@@ -519,6 +580,60 @@ For normal file edits within your scope, proceed without review.
519
580
  return agent;
520
581
  }
521
582
 
583
+ /**
584
+ * Shared output handler for agent loop events.
585
+ * Feeds registry, token tracker, classifier, router, and broadcasts to GUI.
586
+ */
587
+ _handleAgentOutput(agentId, output) {
588
+ const { registry, tokens, classifier, router } = this.daemon;
589
+ const agent = registry.get(agentId);
590
+ if (!agent) return;
591
+
592
+ // Feed classifier for complexity tracking (informs model routing)
593
+ classifier.addEvent(agentId, output);
594
+
595
+ const updates = { lastActivity: new Date().toISOString() };
596
+
597
+ // Token tracking — feed subsystems with full breakdown
598
+ if (output.tokensUsed !== undefined && output.tokensUsed > 0) {
599
+ updates.tokensUsed = agent.tokensUsed + output.tokensUsed;
600
+ tokens.record(agentId, {
601
+ tokens: output.tokensUsed,
602
+ inputTokens: output.inputTokens,
603
+ outputTokens: output.outputTokens,
604
+ cacheReadTokens: output.cacheReadTokens,
605
+ cacheCreationTokens: output.cacheCreationTokens,
606
+ model: output.model,
607
+ estimatedCostUsd: output.estimatedCostUsd,
608
+ });
609
+ const tier = classifier.classify(agentId);
610
+ router.recordUsage(agentId, output.model || agent.model, output.tokensUsed, tier);
611
+ }
612
+
613
+ // Session result data (cost, duration, turns)
614
+ if (output.type === 'result') {
615
+ tokens.recordResult(agentId, {
616
+ costUsd: output.cost, durationMs: output.duration, turns: output.turns,
617
+ });
618
+ if (output.cost) updates.costUsd = (agent.costUsd || 0) + output.cost;
619
+ if (output.duration) updates.durationMs = output.duration;
620
+ if (output.turns) updates.turns = output.turns;
621
+ }
622
+
623
+ // Context window usage (0-1 scale) — drives rotation threshold
624
+ if (output.contextUsage !== undefined) {
625
+ updates.contextUsage = output.contextUsage;
626
+ }
627
+
628
+ // Session ID for resume support
629
+ if (output.sessionId) {
630
+ updates.sessionId = output.sessionId;
631
+ }
632
+
633
+ registry.update(agentId, updates);
634
+ this.daemon.broadcast({ type: 'agent:output', agentId, data: output });
635
+ }
636
+
522
637
  /**
523
638
  * Check if a completed/crashed agent was the last phase 1 agent in a team.
524
639
  * If so, auto-spawn the phase 2 (QC/finisher) agents.
@@ -735,8 +850,19 @@ For normal file edits within your scope, proceed without review.
735
850
  return;
736
851
  }
737
852
 
738
- const { proc, logStream } = handle;
853
+ const { proc, loop, logStream } = handle;
854
+
855
+ // Agent loop path — clean async stop
856
+ if (loop) {
857
+ await loop.stop();
858
+ // Exit handler already fired; finish cleanup
859
+ this.handles.delete(agentId);
860
+ this.daemon.registry.remove(agentId);
861
+ this.daemon.locks.release(agentId);
862
+ return;
863
+ }
739
864
 
865
+ // CLI process path
740
866
  return new Promise((resolveKill) => {
741
867
  // Give the process 5s to exit gracefully
742
868
  const forceTimer = setTimeout(() => {
@@ -764,6 +890,31 @@ For normal file edits within your scope, proceed without review.
764
890
  });
765
891
  }
766
892
 
893
+ /**
894
+ * Send a message to a running agent loop.
895
+ * Returns true if the message was sent, false if the agent doesn't have an active loop.
896
+ */
897
+ async sendMessage(agentId, message) {
898
+ const handle = this.handles.get(agentId);
899
+ if (!handle?.loop) return false;
900
+
901
+ const { loop } = handle;
902
+ if (!loop.running) return false;
903
+
904
+ // Fire and forget — the loop processes the message asynchronously
905
+ // and emits output events that flow through the normal handler
906
+ loop.sendMessage(message).catch(() => {});
907
+ return true;
908
+ }
909
+
910
+ /**
911
+ * Check if an agent is using the agent loop runtime (vs CLI process).
912
+ */
913
+ hasAgentLoop(agentId) {
914
+ const handle = this.handles.get(agentId);
915
+ return !!(handle?.loop);
916
+ }
917
+
767
918
  async killAll() {
768
919
  const ids = Array.from(this.handles.keys());
769
920
  await Promise.all(ids.map((id) => this.kill(id)));
@@ -1,7 +1,10 @@
1
1
  // GROOVE — Codex Provider (OpenAI)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import { execSync } from 'child_process';
4
+ import { execSync, spawn } from 'child_process';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import { homedir } from 'os';
5
8
  import { Provider } from './base.js';
6
9
 
7
10
  export class CodexProvider extends Provider {
@@ -10,6 +13,8 @@ export class CodexProvider extends Provider {
10
13
  static command = 'codex';
11
14
  static authType = 'api-key';
12
15
  static envKey = 'OPENAI_API_KEY';
16
+ // Auth hint — Codex uses its own auth system, not just env vars
17
+ static authHint = 'Codex requires `codex login` — run: echo "YOUR_KEY" | codex login --with-api-key';
13
18
  static models = [
14
19
  { id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', tier: 'heavy', pricing: { input: 0.015, output: 0.06 } },
15
20
  { id: 'gpt-5.4', name: 'GPT-5.4', tier: 'heavy', pricing: { input: 0.005, output: 0.02 } },
@@ -32,6 +37,51 @@ export class CodexProvider extends Provider {
32
37
  return 'npm i -g @openai/codex';
33
38
  }
34
39
 
40
+ /**
41
+ * Check if Codex has valid authentication.
42
+ * Codex uses its own auth at ~/.codex/auth.json (NOT just OPENAI_API_KEY env var).
43
+ * Users must run: codex login (ChatGPT) or: echo "key" | codex login --with-api-key
44
+ */
45
+ /**
46
+ * Auto-login to Codex CLI when user saves an API key in GROOVE.
47
+ * Pipes the key to `codex login --with-api-key` so users don't need
48
+ * to know about Codex's separate auth system.
49
+ */
50
+ static async onKeySet(key) {
51
+ if (!CodexProvider.isInstalled()) return { ok: false, error: 'Codex not installed' };
52
+ return new Promise((res) => {
53
+ const proc = spawn('codex', ['login', '--with-api-key'], {
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ timeout: 15000,
56
+ });
57
+ let stderr = '';
58
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
59
+ proc.stdin.write(key);
60
+ proc.stdin.end();
61
+ proc.on('exit', (code) => {
62
+ res(code === 0
63
+ ? { ok: true, message: 'Codex authenticated via API key' }
64
+ : { ok: false, error: stderr.slice(-200) || `codex login failed (exit ${code})` });
65
+ });
66
+ proc.on('error', (err) => res({ ok: false, error: err.message }));
67
+ setTimeout(() => { try { proc.kill(); } catch {} res({ ok: false, error: 'Timeout' }); }, 15000);
68
+ });
69
+ }
70
+
71
+ static isAuthenticated() {
72
+ const authPath = resolve(homedir(), '.codex', 'auth.json');
73
+ if (!existsSync(authPath)) return { authenticated: false, reason: 'No auth found. Run: codex login' };
74
+ try {
75
+ const auth = JSON.parse(readFileSync(authPath, 'utf8'));
76
+ if (auth.auth_mode === 'chatgpt' && auth.tokens?.id_token) return { authenticated: true, method: 'chatgpt' };
77
+ if (auth.auth_mode === 'api-key' && auth.OPENAI_API_KEY) return { authenticated: true, method: 'api-key' };
78
+ if (auth.OPENAI_API_KEY) return { authenticated: true, method: 'api-key' };
79
+ return { authenticated: false, reason: 'Auth expired or missing. Run: codex login' };
80
+ } catch {
81
+ return { authenticated: false, reason: 'Auth file corrupted. Run: codex login' };
82
+ }
83
+ }
84
+
35
85
  buildSpawnCommand(agent) {
36
86
  // Use 'codex exec' for non-interactive (headless) operation
37
87
  const args = ['exec'];
@@ -40,12 +40,13 @@ export class GeminiProvider extends Provider {
40
40
  // Without this, Gemini in headless mode can only output text
41
41
  args.push('--yolo');
42
42
 
43
- if (agent.prompt) args.push(agent.prompt);
44
-
43
+ // Pass prompt via stdin to avoid OS arg length limits
44
+ // (intro context + role prompt + skill content can be very long)
45
45
  return {
46
46
  command: 'gemini',
47
47
  args,
48
48
  env: agent.apiKey ? { GEMINI_API_KEY: agent.apiKey } : {},
49
+ stdin: agent.prompt || undefined,
49
50
  };
50
51
  }
51
52
 
@@ -5,12 +5,14 @@ import { ClaudeCodeProvider } from './claude-code.js';
5
5
  import { CodexProvider } from './codex.js';
6
6
  import { GeminiProvider } from './gemini.js';
7
7
  import { OllamaProvider } from './ollama.js';
8
+ import { LocalProvider } from './local.js';
8
9
 
9
10
  const providers = {
10
11
  'claude-code': new ClaudeCodeProvider(),
11
12
  'codex': new CodexProvider(),
12
13
  'gemini': new GeminiProvider(),
13
14
  'ollama': new OllamaProvider(),
15
+ 'local': new LocalProvider(),
14
16
  };
15
17
 
16
18
  export function getProvider(name) {
@@ -24,6 +26,8 @@ export function listProviders() {
24
26
  installed: p.constructor.isInstalled(),
25
27
  authType: p.constructor.authType,
26
28
  envKey: p.constructor.envKey || null,
29
+ authHint: p.constructor.authHint || null,
30
+ authStatus: p.constructor.isAuthenticated?.() || null,
27
31
  models: p.constructor.models,
28
32
  installCommand: p.constructor.installCommand(),
29
33
  canHotSwap: p.switchModel ? p.switchModel() : false,
@@ -0,0 +1,183 @@
1
+ // GROOVE — Local Model Provider (Agent Loop Runtime)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // Manages local inference backends (Ollama API, llama-server, any OpenAI-compatible endpoint).
5
+ // Unlike CLI providers (Claude Code, Codex, Gemini), this provider uses GROOVE's built-in
6
+ // agent loop instead of spawning a child process. Set via `useAgentLoop = true`.
7
+
8
+ import { execSync } from 'child_process';
9
+ import { Provider } from './base.js';
10
+ import { OllamaProvider } from './ollama.js';
11
+
12
+ // Context window sizes for models commonly run locally
13
+ // These are the *effective* context sizes used by default (not theoretical max)
14
+ const CONTEXT_WINDOWS = {
15
+ // Qwen 2.5 Coder family — 32K default (can extend to 128K with YaRN)
16
+ 'qwen2.5-coder:7b': 32768,
17
+ 'qwen2.5-coder:14b': 32768,
18
+ 'qwen2.5-coder:32b': 32768,
19
+ 'qwen3-coder-next': 32768,
20
+ // DeepSeek family — large native context
21
+ 'deepseek-r1:7b': 65536,
22
+ 'deepseek-r1:14b': 65536,
23
+ 'deepseek-r1:32b': 65536,
24
+ 'deepseek-coder-v2:16b': 65536,
25
+ // Llama 3.1 — 128K context
26
+ 'llama3.1:8b': 131072,
27
+ 'llama3.1:70b': 131072,
28
+ // Mistral family
29
+ 'mistral:7b': 32768,
30
+ 'mixtral:8x7b': 32768,
31
+ 'codestral': 32768,
32
+ 'devstral-small-2': 32768,
33
+ // Google
34
+ 'gemma4:12b': 32768,
35
+ 'gemma4:26b': 32768,
36
+ 'codegemma': 8192,
37
+ // Microsoft
38
+ 'phi3:mini': 128000,
39
+ 'phi3:medium': 128000,
40
+ };
41
+
42
+ const DEFAULT_CONTEXT_WINDOW = 32768;
43
+
44
+ // Models known to support native tool/function calling via the OpenAI API format
45
+ const TOOL_CALLING_MODELS = new Set([
46
+ 'qwen2.5-coder', 'qwen3-coder-next',
47
+ 'llama3.1', 'llama3.3',
48
+ 'mistral', 'mixtral', 'codestral', 'devstral-small-2',
49
+ 'gemma4',
50
+ 'phi3',
51
+ ]);
52
+
53
+ export class LocalProvider extends Provider {
54
+ static name = 'local';
55
+ static displayName = 'Local Models (Agent Loop)';
56
+ static command = 'ollama';
57
+ static authType = 'local';
58
+ static useAgentLoop = true;
59
+
60
+ static get models() {
61
+ return OllamaProvider.models;
62
+ }
63
+
64
+ static get catalog() {
65
+ return OllamaProvider.catalog;
66
+ }
67
+
68
+ static isInstalled() {
69
+ return LocalProvider._hasOllama() || LocalProvider._hasLlamaServer();
70
+ }
71
+
72
+ static _hasOllama() {
73
+ try {
74
+ execSync('which ollama', { stdio: 'ignore' });
75
+ return true;
76
+ } catch { return false; }
77
+ }
78
+
79
+ static _hasLlamaServer() {
80
+ try {
81
+ execSync('which llama-server', { stdio: 'ignore' });
82
+ return true;
83
+ } catch { return false; }
84
+ }
85
+
86
+ static installCommand() {
87
+ return OllamaProvider.installCommand();
88
+ }
89
+
90
+ static hardwareRequirements() {
91
+ return OllamaProvider.hardwareRequirements();
92
+ }
93
+
94
+ static getSystemHardware() {
95
+ return OllamaProvider.getSystemHardware();
96
+ }
97
+
98
+ static getInstalledModels() {
99
+ return OllamaProvider.getInstalledModels();
100
+ }
101
+
102
+ /**
103
+ * Get configuration for the agent loop runtime.
104
+ * Called by ProcessManager when useAgentLoop is true.
105
+ */
106
+ getLoopConfig(agent) {
107
+ const model = agent.model || 'qwen2.5-coder:7b';
108
+ const contextWindow = this.getContextWindow(model);
109
+
110
+ // Determine API endpoint
111
+ let apiBase = 'http://localhost:11434/v1'; // Ollama's OpenAI-compatible endpoint (default)
112
+
113
+ // Custom endpoint override from agent config or daemon config
114
+ if (agent.apiBase) {
115
+ apiBase = agent.apiBase;
116
+ }
117
+
118
+ return {
119
+ apiBase,
120
+ model,
121
+ contextWindow,
122
+ temperature: 0.1,
123
+ maxResponseTokens: 4096,
124
+ stream: true,
125
+ headers: {},
126
+ apiKey: agent.apiKey || null,
127
+ introContext: agent.introContext || '',
128
+ };
129
+ }
130
+
131
+ getContextWindow(modelId) {
132
+ if (!modelId) return DEFAULT_CONTEXT_WINDOW;
133
+ // Exact match first
134
+ if (CONTEXT_WINDOWS[modelId]) return CONTEXT_WINDOWS[modelId];
135
+ // Prefix match (e.g., 'qwen2.5-coder:7b-q4' matches 'qwen2.5-coder:7b')
136
+ for (const [key, value] of Object.entries(CONTEXT_WINDOWS)) {
137
+ if (modelId.startsWith(key)) return value;
138
+ }
139
+ return DEFAULT_CONTEXT_WINDOW;
140
+ }
141
+
142
+ /**
143
+ * Check if a model supports native tool/function calling through the API.
144
+ * Models without native support would need prompt-based tool injection (future).
145
+ */
146
+ supportsToolCalling(modelId) {
147
+ if (!modelId) return false;
148
+ const base = modelId.split(':')[0];
149
+ return TOOL_CALLING_MODELS.has(base);
150
+ }
151
+
152
+ // --- Provider interface (backward compat) ---
153
+
154
+ buildSpawnCommand(agent) {
155
+ // Not used when useAgentLoop is true, but required by interface
156
+ const model = agent.model || 'qwen2.5-coder:7b';
157
+ return {
158
+ command: 'ollama', args: ['run', model],
159
+ env: { OLLAMA_API_BASE: 'http://localhost:11434' },
160
+ stdin: agent.prompt || undefined,
161
+ };
162
+ }
163
+
164
+ buildHeadlessCommand(prompt, model) {
165
+ const m = model || 'qwen2.5-coder:7b';
166
+ return { command: 'ollama', args: ['run', m], env: {}, stdin: prompt };
167
+ }
168
+
169
+ switchModel() {
170
+ return false; // Needs rotation for model switch
171
+ }
172
+
173
+ parseOutput(line) {
174
+ const trimmed = (line || '').trim();
175
+ if (!trimmed) return null;
176
+ // Try to parse structured log entries from agent loop
177
+ try {
178
+ const entry = JSON.parse(trimmed);
179
+ if (entry.type) return entry;
180
+ } catch { /* plain text */ }
181
+ return { type: 'activity', data: trimmed };
182
+ }
183
+ }
@@ -51,7 +51,7 @@ export class Registry extends EventEmitter {
51
51
  if (!agent) return null;
52
52
 
53
53
  // Only allow known fields to prevent prototype pollution
54
- const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'name', 'routingMode', 'routingReason', 'sessionId', 'skills', 'integrations', 'workingDir', 'effort', 'costUsd', 'durationMs', 'turns', 'inputTokens', 'outputTokens', 'teamId'];
54
+ const SAFE_FIELDS = ['status', 'pid', 'tokensUsed', 'contextUsage', 'lastActivity', 'model', 'provider', 'name', 'routingMode', 'routingReason', 'sessionId', 'skills', 'integrations', 'workingDir', 'effort', 'costUsd', 'durationMs', 'turns', 'inputTokens', 'outputTokens', 'teamId'];
55
55
  for (const key of Object.keys(updates)) {
56
56
  if (SAFE_FIELDS.includes(key)) {
57
57
  agent[key] = updates[key];