diff --git a/README.md b/README.md index 493ef48..d2f4075 100644 --- a/README.md +++ b/README.md @@ -98,12 +98,13 @@ Note: even models with genuine native tool-calling support occasionally emit a t ## Config -Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`) > persisted config file > defaults. +Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCODE_BASE_URL`, `LOCODE_CONTEXT_WINDOW`, `LOCODE_MAX_ITERATIONS`) > persisted config file > defaults. ```sh locode config set backend ollama locode config set model qwen3-coder:30b locode config set contextWindow 32768 # fallback size when auto-detection fails +locode config set maxIterations 40 # max tool calls per turn before locode gives up (default 25) locode config get locode config path ``` diff --git a/package.json b/package.json index def1321..529a82d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "locode", - "version": "0.1.0", + "version": "0.2.0", "description": "Agentic coding CLI for local models served via Ollama and LM Studio", "type": "module", "bin": { diff --git a/src/agent/session.ts b/src/agent/session.ts index ba7abab..3bd8a04 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import type OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { ToolCallMode } from "../backend/capabilityProbe.js"; -import { DEFAULT_CONTEXT_WINDOW } from "../config/defaults.js"; +import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS } from "../config/defaults.js"; import type { SessionRecord } from "../persistence/sessionStore.js"; import { PermissionManager } from "../permissions/permissionManager.js"; import type { ConfirmFn } from "../permissions/types.js"; @@ -49,6 +49,7 @@ export function createSession( tools: ToolDef[] = TOOLS, contextWindow: number = DEFAULT_CONTEXT_WINDOW, contextWindowIsEstimate: boolean = true, + maxIterations: number = DEFAULT_MAX_ITERATIONS, ): Session { const toolset = buildToolSet(tools); const messages: ChatCompletionMessageParam[] = [{ role: "system", content: buildSystemPrompt(toolset.tools, mode) }]; @@ -60,7 +61,7 @@ export function createSession( cwd, mode, messages, - maxIterations: 8, + maxIterations, permissions: new PermissionManager(), confirm, toolset, @@ -82,6 +83,7 @@ export function createSessionFromRecord( tools: ToolDef[] = TOOLS, contextWindow: number = DEFAULT_CONTEXT_WINDOW, contextWindowIsEstimate: boolean = true, + maxIterations: number = DEFAULT_MAX_ITERATIONS, ): Session { const toolset = buildToolSet(tools); const messages: ChatCompletionMessageParam[] = [ @@ -96,7 +98,7 @@ export function createSessionFromRecord( cwd, mode: record.mode, messages, - maxIterations: 8, + maxIterations, permissions: new PermissionManager(), confirm, toolset, diff --git a/src/cli.ts b/src/cli.ts index 77118e7..a09216b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ function addBackendOptions(cmd: Command): Command { program .name("locode") .description("Agentic coding CLI for local models via Ollama and LM Studio") - .version("0.1.0"); + .version("0.2.0"); addBackendOptions(program) .option("-m, --model ", "model name as known to the backend") @@ -108,20 +108,20 @@ configCmd configCmd .command("set ") - .description("Persist a config value (backend, model, baseUrl, contextWindow)") + .description("Persist a config value (backend, model, baseUrl, contextWindow, maxIterations)") .action((key: string, value: string) => { - if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow") { - console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow`); + if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow" && key !== "maxIterations") { + console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxIterations`); process.exit(1); } const stored = loadStoredConfig(); - if (key === "contextWindow") { + if (key === "contextWindow" || key === "maxIterations") { const n = Number(value); if (!Number.isFinite(n) || n <= 0) { - console.error(`contextWindow must be a positive number, got "${value}".`); + console.error(`${key} must be a positive number, got "${value}".`); process.exit(1); } - stored.contextWindow = n; + stored[key] = n; } else { stored[key] = value; } diff --git a/src/config/config.ts b/src/config/config.ts index 283f76f..5ec00d6 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,4 +1,4 @@ -import { DEFAULT_CONTEXT_WINDOW, KNOWN_BACKENDS, type BackendName } from "./defaults.js"; +import { DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_ITERATIONS, KNOWN_BACKENDS, type BackendName } from "./defaults.js"; import { loadStoredConfig } from "./store.js"; export class ConfigError extends Error {} @@ -41,3 +41,12 @@ export function resolveContextWindowDefault(): number { if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow; return DEFAULT_CONTEXT_WINDOW; } + +/** Max tool calls allowed per turn before locode gives up. */ +export function resolveMaxIterations(): number { + const stored = loadStoredConfig(); + const envValue = Number(process.env.LOCODE_MAX_ITERATIONS); + if (Number.isFinite(envValue) && envValue > 0) return envValue; + if (typeof stored.maxIterations === "number" && stored.maxIterations > 0) return stored.maxIterations; + return DEFAULT_MAX_ITERATIONS; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 91a442d..b12b353 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -11,3 +11,7 @@ export type BackendName = keyof typeof KNOWN_BACKENDS; /** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts) * and the user hasn't configured one — a conservative size common among smaller local models. */ export const DEFAULT_CONTEXT_WINDOW = 8192; + +/** Max tool calls per turn before locode gives up rather than looping forever. 25 gives real + * multi-file tasks room to breathe; still bounded so a genuinely stuck model fails fast. */ +export const DEFAULT_MAX_ITERATIONS = 25; diff --git a/src/config/store.ts b/src/config/store.ts index 60456d5..8eff76c 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -8,6 +8,8 @@ export interface StoredConfig { baseUrl?: string; /** Fallback context window size (in tokens) used when it can't be auto-detected from the backend. */ contextWindow?: number; + /** Max tool calls allowed per turn before locode gives up rather than looping forever. */ + maxIterations?: number; } const paths = envPaths("locode", { suffix: "" }); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 836c175..d202ca3 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -32,7 +32,7 @@ export async function connectMcpServer(name: string, config: McpServerConfig): P stderr: "pipe", }); - const client = new Client({ name: "locode", version: "0.1.0" }); + const client = new Client({ name: "locode", version: "0.2.0" }); await client.connect(transport); const { tools } = await client.listTools(); return { name, client, transport, tools: tools as McpToolInfo[] }; diff --git a/src/ui/ink/App.tsx b/src/ui/ink/App.tsx index 73efed7..bfaeb8f 100644 --- a/src/ui/ink/App.tsx +++ b/src/ui/ink/App.tsx @@ -14,6 +14,7 @@ import type { ToolCallMode } from "../../backend/capabilityProbe.js"; import { setCachedMode } from "../../backend/capabilityCache.js"; import { resolveContextWindow } from "../../backend/contextWindow.js"; import { resolveToolCallMode } from "../../backend/resolveMode.js"; +import { resolveMaxIterations } from "../../config/config.js"; import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js"; import { getMcpStatuses } from "../../mcp/manager.js"; import type { PermissionDecision, PermissionMode } from "../../permissions/types.js"; @@ -150,6 +151,7 @@ export function App({ [...TOOLS, ...mcpTools], contextWindow.value, contextWindow.isEstimate, + resolveMaxIterations(), ); push({ kind: "banner", cwd, model, backend: baseURLRef.current }); setPhase("input"); @@ -191,6 +193,7 @@ export function App({ [...TOOLS, ...mcpTools], contextWindow.value, contextWindow.isEstimate, + resolveMaxIterations(), ); push({ kind: "banner",