Skip to content

Commit e2bd530

Browse files
PurpleDoubleDclaude
andcommitted
feat: agent production release — JSON repair, smart tools, retry, streaming
Production Release: - Remove Beta badge from Agent Mode JSON Correction Fallbacks: - tool-call-repair.ts: repairJson fixes trailing commas, single quotes, missing braces, unquoted keys - extractToolCallsFromContent: extract tool calls when native parsing fails - Applied to all 3 providers (Ollama, OpenAI, Anthropic) - Hermes XML parser uses repairJson instead of strict JSON.parse Intelligent Tool Selection: - tool-selection.ts: keyword-based tool filtering per user message - Reduces tool definitions from 13 to ~4-5 per request - Saves up to 80% of tool-definition tokens (critical for small models) - Applied to both Agent Mode and Codex Retry Logic: - toolRegistry.execute() retries transient errors (timeout, network) - 1 automatic retry for ECONNREFUSED, timed out, fetch failed Thinking Indicator: - "Analyzing..." block shown while chatWithTools processes - Removed when model responds (replaced by actual tool calls) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5382d83 commit e2bd530

11 files changed

Lines changed: 291 additions & 31 deletions

File tree

src/api/hermes-tool-calling.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
*/
1212

1313
import type { AgentToolDef } from '../types/agent-mode'
14+
import { repairJson } from '../lib/tool-call-repair'
1415

1516
// Generic tool shape accepted by the prompt builder
1617
type ToolLike = { name: string; description: string; parameters?: any; inputSchema?: any }
@@ -74,22 +75,22 @@ export function parseHermesToolCalls(output: string): ParsedToolCall[] {
7475

7576
while ((match = regex.exec(output)) !== null) {
7677
const jsonStr = match[1].trim()
77-
try {
78-
const parsed = JSON.parse(jsonStr)
79-
if (parsed.name) {
80-
calls.push({
81-
name: parsed.name,
82-
arguments: parsed.arguments || parsed.parameters || {},
83-
})
84-
}
85-
} catch {
86-
// Try to extract name and arguments with regex fallback
87-
const nameMatch = jsonStr.match(/"name"\s*:\s*"([^"]+)"/)
88-
const argsMatch = jsonStr.match(/"arguments"\s*:\s*(\{[^}]*\})/)
78+
// Try direct parse, then repair
79+
const parsed = repairJson(jsonStr)
80+
if (parsed && parsed.name) {
81+
calls.push({
82+
name: parsed.name,
83+
arguments: parsed.arguments || parsed.parameters || {},
84+
})
85+
} else {
86+
// Last resort regex
87+
const nameMatch = jsonStr.match(/["']?name["']?\s*[:=]\s*["']([^"']+)["']/i)
88+
const argsMatch = jsonStr.match(/["']?arguments["']?\s*[:=]\s*(\{[\s\S]*?\})/i)
8989
if (nameMatch) {
9090
let args = {}
9191
if (argsMatch) {
92-
try { args = JSON.parse(argsMatch[1]) } catch { /* ignore */ }
92+
const repaired = repairJson(argsMatch[1])
93+
if (repaired) args = repaired
9394
}
9495
calls.push({ name: nameMatch[1], arguments: args })
9596
}

src/api/mcp/tool-registry.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,27 @@ export class ToolRegistry {
5959

6060
// ── Execution ─────────────────────────────────────────────────
6161

62-
async execute(name: string, args: Record<string, any>): Promise<string> {
62+
async execute(name: string, args: Record<string, any>, maxRetries = 1): Promise<string> {
6363
const entry = this.tools.get(name)
6464
if (!entry) return `Error: Unknown tool "${name}"`
65-
try {
66-
return await entry.executor(args)
67-
} catch (err) {
68-
const message = err instanceof Error ? err.message : String(err)
69-
return `Error: ${message}`
65+
66+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
67+
try {
68+
const result = await entry.executor(args)
69+
// If result is an error and we have retries left, retry
70+
if (result.startsWith('Error:') && attempt < maxRetries) {
71+
// Only retry on transient errors (timeout, network)
72+
const isTransient = result.includes('timed out') || result.includes('ECONNREFUSED') || result.includes('fetch failed')
73+
if (isTransient) continue
74+
}
75+
return result
76+
} catch (err) {
77+
if (attempt < maxRetries) continue
78+
const message = err instanceof Error ? err.message : String(err)
79+
return `Error: ${message}`
80+
}
7081
}
82+
return `Error: Max retries exceeded for "${name}"`
7183
}
7284

7385
// ── Format Conversion ─────────────────────────────────────────

src/api/providers/anthropic-provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export class AnthropicProvider implements ProviderClient {
195195
id: block.id,
196196
function: {
197197
name: block.name!,
198-
arguments: block.input || {},
198+
arguments: (typeof block.input === 'object' && block.input) ? block.input : {},
199199
},
200200
})
201201
}

src/api/providers/ollama-provider.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import { ProviderError } from './types'
1313
import { isTauri, localFetch, localFetchStream } from '../backend'
1414
import { parseNDJSONStream } from '../stream'
15+
import { repairToolCallArgs, extractToolCallsFromContent } from '../../lib/tool-call-repair'
1516

1617
// ── Ollama-specific types ──────────────────────────────────────
1718

@@ -152,10 +153,18 @@ export class OllamaProvider implements ProviderClient {
152153
}
153154

154155
const data = await res.json()
155-
const toolCalls: ToolCall[] = (data.message?.tool_calls || []).map((tc: any) => ({
156-
function: { name: tc.function.name, arguments: tc.function.arguments },
156+
let toolCalls: ToolCall[] = (data.message?.tool_calls || []).map((tc: any) => ({
157+
function: { name: tc.function.name, arguments: repairToolCallArgs(tc.function.arguments) },
157158
}))
158159

160+
// If no tool calls found but content looks like a tool call, try to extract
161+
if (toolCalls.length === 0 && data.message?.content) {
162+
const extracted = extractToolCallsFromContent(data.message.content)
163+
if (extracted.length > 0) {
164+
toolCalls = extracted.map(tc => ({ function: tc }))
165+
}
166+
}
167+
159168
return {
160169
content: data.message?.content || '',
161170
thinking: data.message?.thinking || '',

src/api/providers/openai-provider.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
} from './types'
1616
import { ProviderError } from './types'
1717
import { parseSSEStream } from '../sse'
18+
import { repairJson } from '../../lib/tool-call-repair'
1819

1920
// ── OpenAI API Types ───────────────────────────────────────────
2021

@@ -315,7 +316,8 @@ export class OpenAIProvider implements ProviderClient {
315316
try {
316317
return JSON.parse(args)
317318
} catch {
318-
return {}
319+
const repaired = repairJson(args)
320+
return repaired && typeof repaired === 'object' ? repaired : {}
319321
}
320322
}
321323

src/components/chat/ChatView.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export function ChatView() {
235235
)}
236236
</button>
237237

238-
{/* Agent Mode (Beta) */}
238+
{/* Agent Mode */}
239239
{FEATURE_FLAGS.AGENT_MODE && (
240240
<div className={
241241
'flex items-center gap-1 px-2 py-0.5 rounded border transition-colors text-[0.55rem] ' +
@@ -246,10 +246,7 @@ export function ChatView() {
246246
: 'border-gray-200 dark:border-white/[0.06] text-gray-500')
247247
}>
248248
<Bot size={10} />
249-
<div className="flex flex-col items-start leading-none">
250-
<span className="text-[0.35rem] text-amber-400 font-bold uppercase tracking-widest">Beta</span>
251-
<span>Agent</span>
252-
</div>
249+
<span>Agent</span>
253250
<AgentModeToggle />
254251
</div>
255252
)}

src/components/chat/__tests__

Whitespace-only changes.

src/hooks/useAgentChat.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { buildExtractionPrompt, parseExtractionResponse } from '../lib/memory-ex
2222
import { useAgentWorkflowStore } from '../stores/agentWorkflowStore'
2323
import { WorkflowEngine } from '../lib/workflow-engine'
2424
import type { AgentBlock, AgentToolCall, OllamaChatMessage } from '../types/agent-mode'
25+
import { selectRelevantTools } from '../lib/tool-selection'
2526
import type { ChatMessage, ToolCall, ToolDefinition } from '../api/providers/types'
2627
import type { StepResult, WorkflowEngineCallbacks } from '../types/agent-workflows'
2728

@@ -100,6 +101,11 @@ export function useAgentChat() {
100101
useChatStore.getState().updateMessageAgentBlocks(convId, msgId, blocksRef.current)
101102
}
102103

104+
function removeBlock(convId: string, msgId: string, blockId: string) {
105+
blocksRef.current = blocksRef.current.filter(b => b.id !== blockId)
106+
useChatStore.getState().updateMessageAgentBlocks(convId, msgId, blocksRef.current)
107+
}
108+
103109
function updateLastBlock(convId: string, msgId: string, updates: Partial<AgentBlock>) {
104110
const blocks = [...blocksRef.current]
105111
const last = blocks[blocks.length - 1]
@@ -344,10 +350,25 @@ export function useAgentChat() {
344350
) as ChatMessage[]
345351

346352
if (strategy === 'native') {
347-
// ── Native tool calling (works with Ollama, OpenAI, Anthropic) ──
348-
const tools: ToolDefinition[] = toolRegistry.toOpenAITools(permissions)
353+
// Show thinking indicator while model processes
354+
const thinkingBlockId = uuid()
355+
addBlock(convId!, assistantMessage.id, {
356+
id: thinkingBlockId, phase: 'thinking', content: 'Analyzing...',
357+
timestamp: Date.now(),
358+
})
359+
360+
// Intelligent tool selection — only include relevant tools
361+
const lastUserMsg = agentMessages.filter(m => m.role === 'user').pop()?.content || ''
362+
const relevantDefs = selectRelevantTools(lastUserMsg, toolRegistry.getAll(), permissions)
363+
const tools: ToolDefinition[] = relevantDefs.map(t => ({
364+
type: 'function' as const,
365+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
366+
}))
349367
const turn = await provider.chatWithTools(modelToUse, agentMessages, tools, chatOptions)
350368

369+
// Remove thinking indicator
370+
removeBlock(convId!, assistantMessage.id, thinkingBlockId)
371+
351372
toolCalls = turn.toolCalls
352373
turnContent = turn.content || ''
353374
// Native thinking field from Ollama

src/hooks/useCodex.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { buildHermesToolPrompt, buildHermesToolResult, parseHermesToolCalls, str
1212
import { chatNonStreaming } from '../api/agents'
1313
import type { CodexEvent } from '../types/codex'
1414
import type { AgentBlock, AgentToolCall } from '../types/agent-mode'
15+
import { selectRelevantTools } from '../lib/tool-selection'
1516
import type { ChatMessage, ToolCall, ToolDefinition } from '../api/providers/types'
1617

1718
const CODEX_SYSTEM_PROMPT = `You are Codex, an autonomous coding agent inside Locally Uncensored. You execute coding tasks by reading files, writing code, and running shell commands. You MUST use tools to interact with the filesystem — never guess file contents.
@@ -137,7 +138,12 @@ export function useCodex() {
137138
}
138139

139140
if (strategy === 'native') {
140-
const tools: ToolDefinition[] = toolRegistry.toOpenAITools(permissions)
141+
const lastUserMsg = messages.filter(m => m.role === 'user').pop()?.content || ''
142+
const relevantDefs = selectRelevantTools(lastUserMsg, toolRegistry.getAll(), permissions)
143+
const tools: ToolDefinition[] = relevantDefs.map(t => ({
144+
type: 'function' as const,
145+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
146+
}))
141147
const turn = await provider.chatWithTools(modelToUse, messages, tools, chatOptions)
142148
toolCalls = turn.toolCalls
143149
turnContent = turn.content || ''

src/lib/tool-call-repair.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Tool Call Repair — fixes broken JSON from local LLMs.
3+
*
4+
* Common issues:
5+
* - Trailing commas in JSON objects/arrays
6+
* - Single quotes instead of double quotes
7+
* - Missing closing braces/brackets
8+
* - Unquoted property names
9+
* - Extra text before/after JSON
10+
* - Escaped quotes inside strings
11+
*/
12+
13+
/**
14+
* Attempt to repair broken JSON from a tool call.
15+
* Returns parsed object or null if unfixable.
16+
*/
17+
export function repairJson(raw: string): any | null {
18+
// 1. Try direct parse first
19+
try { return JSON.parse(raw) } catch {}
20+
21+
let fixed = raw.trim()
22+
23+
// 2. Extract JSON from surrounding text (model might wrap it)
24+
const jsonMatch = fixed.match(/\{[\s\S]*\}/)
25+
if (jsonMatch) fixed = jsonMatch[0]
26+
27+
// 3. Fix single quotes → double quotes (but not inside strings)
28+
fixed = fixed.replace(/'/g, '"')
29+
30+
// 4. Fix trailing commas
31+
fixed = fixed.replace(/,\s*([}\]])/g, '$1')
32+
33+
// 5. Fix unquoted keys: { key: "value" } → { "key": "value" }
34+
fixed = fixed.replace(/(\{|,)\s*([a-zA-Z_]\w*)\s*:/g, '$1"$2":')
35+
36+
// 6. Fix missing closing braces
37+
const openBraces = (fixed.match(/\{/g) || []).length
38+
const closeBraces = (fixed.match(/\}/g) || []).length
39+
for (let i = 0; i < openBraces - closeBraces; i++) fixed += '}'
40+
41+
const openBrackets = (fixed.match(/\[/g) || []).length
42+
const closeBrackets = (fixed.match(/\]/g) || []).length
43+
for (let i = 0; i < openBrackets - closeBrackets; i++) fixed += ']'
44+
45+
// 7. Try parse again
46+
try { return JSON.parse(fixed) } catch {}
47+
48+
// 8. Last resort: try to extract key-value pairs with regex
49+
try {
50+
const nameMatch = raw.match(/["']?name["']?\s*[:=]\s*["']([^"']+)["']/i)
51+
const argsMatch = raw.match(/["']?arguments["']?\s*[:=]\s*(\{[^}]*\})/i)
52+
if (nameMatch) {
53+
let args = {}
54+
if (argsMatch) {
55+
try { args = JSON.parse(argsMatch[1].replace(/'/g, '"')) } catch {}
56+
}
57+
return { name: nameMatch[1], arguments: args }
58+
}
59+
} catch {}
60+
61+
return null
62+
}
63+
64+
/**
65+
* Repair tool call arguments that might be a string instead of object.
66+
*/
67+
export function repairToolCallArgs(args: any): Record<string, any> {
68+
if (typeof args === 'object' && args !== null) return args
69+
if (typeof args === 'string') {
70+
const parsed = repairJson(args)
71+
if (parsed && typeof parsed === 'object') return parsed
72+
}
73+
return {}
74+
}
75+
76+
/**
77+
* Extract tool calls from model content when native tool calling fails.
78+
* Looks for JSON patterns that look like tool calls.
79+
*/
80+
export function extractToolCallsFromContent(content: string): { name: string; arguments: Record<string, any> }[] {
81+
const calls: { name: string; arguments: Record<string, any> }[] = []
82+
83+
// Pattern 1: {"name": "tool_name", "arguments": {...}}
84+
const pattern1 = /\{\s*"(?:name|tool|function)"\s*:\s*"([^"]+)"\s*,\s*"(?:arguments|args|parameters|input)"\s*:\s*(\{[^}]*\})\s*\}/gi
85+
let match
86+
while ((match = pattern1.exec(content)) !== null) {
87+
const args = repairJson(match[2])
88+
if (args) calls.push({ name: match[1], arguments: args })
89+
}
90+
91+
// Pattern 2: tool_name(arg1, arg2) — function call syntax
92+
if (calls.length === 0) {
93+
const pattern2 = /\b(web_search|web_fetch|file_read|file_write|file_list|file_search|shell_execute|code_execute|system_info|process_list|screenshot)\s*\(\s*([^)]*)\)/gi
94+
while ((match = pattern2.exec(content)) !== null) {
95+
const argStr = match[2].trim()
96+
let args: Record<string, any> = {}
97+
if (argStr) {
98+
// Try to parse as JSON
99+
const parsed = repairJson(`{${argStr}}`)
100+
if (parsed) args = parsed
101+
else {
102+
// Simple single-argument: treat as the first required param
103+
args = { query: argStr.replace(/^["']|["']$/g, '') }
104+
}
105+
}
106+
calls.push({ name: match[1], arguments: args })
107+
}
108+
}
109+
110+
return calls
111+
}

0 commit comments

Comments
 (0)