Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7,797 changes: 7,797 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions pending-items.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Pending Items — `fix/hallucination-and-icons`

**Branch:** `fix/hallucination-and-icons` off `master`
**Base commit:** `e405fa9`
**Issue:** [#101](https://github.com/hellodk/champ/issues/101)

---

## What changed

### 1. Agentic hallucination guard (issue #101)

**Root cause:** Models without native tool calling (`supportsToolUse() === false`) imitate the narration style from `PROMPT_BASED_BASE_INSTRUCTIONS` (e.g. `> Reading auth.ts...`, `---Result of reading documentation:`) but never emit `<tool_call>` XML. `parseToolCallsFromText` only accepts exact XML or Qwen tokens, so nothing executes and fabricated "results" stream as plain text.

**Fix — 3 files changed:**

| File | Change |
|------|--------|
| `src/providers/prompt-based-tools.ts` | Added `hasFabricatedNarration()` — regex-based detection of fake narration markers (`---Result of`, `> Reading...`, `# Result of`, `---Output of`) |
| `src/agent/agent-controller.ts` | After prompt-based parse, if `parsed.length === 0` && `hasFabricatedNarration()` && iteration budget remains, emit corrective user turn telling model to emit `<tool_call>` XML, then `continue` loop |
| `src/providers/__tests__/prompt-based-tools.test.ts` | **New file** — tests for `parseToolCallsFromText`, `extractPreToolText`, `extractTextContent`, and `hasFabricatedNarration` |

**Corrective turn content:**
```
⚠️ You narrated a tool result without calling a tool.
You MUST emit a <tool_call> XML block to actually run the tool.
Do NOT describe results you haven't seen yet.
Call the tool now.
```

**AC coverage:**
- ✅ AC #1 — Corrective-turn loop: model gets another chance to emit real `<tool_call>`
- ✅ AC #2 — Fabricated narration is detected and not presented as tool output
- ✅ AC #3 — Cache context bleed: ResponseCache only stores on first iteration (no tools yet), corrective turn uses `continue` so cache is unaffected

---

### 2. Replace emoji icons with VS Code codicons

**Root cause:** Empty-state prompt cards use childish emoji (🔍🐛✨📖❓🔎📜🔗🗺️♻️🧪🚀) instead of the standard codicons already loaded in the webview.

**Fix — 3 files changed:**

| File | Change |
|------|--------|
| `webview-ui/static/main.js` | Replaced emoji in `EMPTY_STATE_PROMPTS` with codicon names; updated `renderEmptyState()` to render `<i class="codicon codicon-{name}">` |
| `src/ui/empty-state-prompts.ts` | Replaced emoji with matching codicon names (test-only module) |
| `src/ui/__tests__/onboarding.test.ts` | Existing tests unchanged — `typeof p.icon === "string"` still passes for codicon names |

**Icon mapping:**

| Mode | Old emoji | New codicon |
|------|-----------|-------------|
| agent | 🔍 | `search` |
| agent | 🐛 | `bug` |
| agent | ✨ | `add` |
| agent | 📖 | `book` |
| ask | ❓ | `question` |
| ask | 🔎 | `search` |
| ask | 📜 | `git-commit` |
| ask | 🔗 | `link` |
| plan | 🗺️ | `project` |
| plan | ♻️ | `refresh` |
| plan | 🧪 | `beaker` |
| plan | 🚀 | `rocket` |

---

## Verification

- ✅ `pnpm run test:unit` — 169 files, 1523 passed, 5 skipped
- ✅ `pnpm run check-types` — clean (0 errors)
- ✅ `pnpm run lint` — 10 pre-existing warnings, 0 errors, 0 new warnings
- ✅ New test file `src/providers/__tests__/prompt-based-tools.test.ts` added

---

## Files changed

```
src/agent/agent-controller.ts | 32 +++
src/providers/prompt-based-tools.ts | 21 +
src/providers/__tests__/prompt-based-tools.test.ts | NEW
src/ui/empty-state-prompts.ts | 24 +-
webview-ui/static/main.js | 26 +-
```

---

## Not changed (intentionally)

- `webview-ui/dist/` — built artifact, regenerated by `pnpm run build:webview`
- `ResponseCache` keying — already correct (SHA256 of messages + tools, only on iteration 0)
- `system-prompt-builder.ts` — narration instructions kept as-is; the guard catches the failure mode without needing prompt changes
- `src/ui/__tests__/onboarding.test.ts` — existing tests pass without changes

---

## Next steps

1. Review the diff on `fix/hallucination-and-icons`
2. Merge to `master` when approved
3. Rebuild webview: `pnpm run build:webview` (copies `static/main.js` → `dist/main.js`)
32 changes: 32 additions & 0 deletions src/agent/agent-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
parseToolCallsFromText,
extractTextContent,
extractPreToolText,
hasFabricatedNarration,
type MalformedToolCall,
} from "../providers/prompt-based-tools";
import { SecretScanner } from "../safety/secret-scanner";
Expand Down Expand Up @@ -749,6 +750,37 @@ export class AgentController {
const parsed = parseToolCallsFromText(assistantText, (info) =>
malformedCalls.push(info),
);

// ── Hallucination guard (issue #101) ────────────────────────────
// If no tool calls were parsed but the text contains fabricated
// narration (e.g. "---Result of reading documentation:"), the
// model is narrating fake tool results without actually calling
// any tool. Issue a corrective user turn and continue the loop
// so the model gets a chance to emit a real <tool_call>.
if (
parsed.length === 0 &&
hasFabricatedNarration(assistantText) &&
iteration < maxIterations - 1
) {
this.emit({
type: "text",
text: "\n*(Waiting for tool call…)*\n",
});
this.history.push({
role: "assistant",
content: assistantText,
});
this.history.push({
role: "user",
content:
"⚠️ You narrated a tool result without calling a tool. " +
"You MUST emit a <tool_call> XML block to actually run the tool. " +
"Do NOT describe results you haven't seen yet. " +
"Call the tool now.",
});
continue;
}

const promptToolStart = Date.now();
for (const call of parsed) {
pendingToolCalls.push(call);
Expand Down
132 changes: 124 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export async function activate(

const stubProvider = createStubProvider("not-configured");
const inlineProviderRef: { current: LLMProvider } = { current: stubProvider };
let lastProviderError: string | null = null; // Cache provider load error for webview-ready re-broadcast
const agentController = new AgentController(
stubProvider,
toolRegistry,
Expand Down Expand Up @@ -1037,6 +1038,10 @@ export async function activate(
);
}
broadcastMetrics();
// Reset status bar from spinning "thinking" back to ready after every turn.
if (statusBarItem && inlineProviderRef.current.name !== "not-configured") {
setStatusReady(inlineProviderRef.current);
}
if (sessionAnalytics) {
lastAnalyticsReport = sessionAnalytics.toReport();
}
Expand All @@ -1057,6 +1062,10 @@ export async function activate(
chatViewProvider.onStreamError((error) => {
metrics?.recordFailure(error);
broadcastMetrics();
// Reset status bar from spinning "thinking" back to ready on error too.
if (statusBarItem && inlineProviderRef.current.name !== "not-configured") {
setStatusReady(inlineProviderRef.current);
}
saveActiveSession();
});
// When the webview resolves, re-broadcast all state that may have
Expand Down Expand Up @@ -1101,6 +1110,20 @@ export async function activate(
modelName: provider.config.model,
available,
});
} else if (provider.name === "not-configured" && lastProviderError) {
// Webview resolved after provider init failed. Broadcast the cached error.
chatViewProvider?.broadcastProviderStatus({
state: "error",
errorMessage: lastProviderError,
available: [],
});
} else {
// Provider is still loading (not-configured, no error yet).
// Broadcast loading so the header doesn't stay stuck.
chatViewProvider?.broadcastProviderStatus({
state: "loading",
available: [],
});
}
broadcastSessionList();
broadcastMcpStatus();
Expand Down Expand Up @@ -3344,6 +3367,70 @@ export async function activate(
});
newProvider = rateLimited;
}

// Probe connectivity for local providers (ollama, llamacpp, vllm).
// This surfaces connectivity errors during init, not on first chat message.
const probeLocalProvider = async (): Promise<void> => {
// Unwrap all provider wrappers to get the actual underlying provider.
let providerToProbe: LLMProvider = newProvider;

// Unwrap RateLimitedProvider
if (providerToProbe instanceof RateLimitedProvider) {
providerToProbe = (providerToProbe as any).inner;
}

// Unwrap FallbackProvider to get the first provider in the chain
if (providerToProbe instanceof FallbackProvider) {
providerToProbe = (providerToProbe as any).providers[0];
}

const providerName = providerToProbe.name.toLowerCase();
if (
!["ollama", "llamacpp", "vllm", "openai-compatible"].includes(
providerName,
)
)
return;

// Extract baseUrl from config using the actual underlying provider name
const providerConfig =
yamlConfig?.providers?.[
providerName as keyof typeof yamlConfig.providers
];
const baseUrl = (providerConfig as any)?.baseUrl;
if (!baseUrl) return;

// Use provider-specific endpoints that actually exist
const endpoints: Record<string, string> = {
ollama: "/api/tags", // Lists available models
llamacpp: "/v1/models", // OpenAI-compatible endpoint
vllm: "/v1/models", // OpenAI-compatible endpoint
"openai-compatible": "/v1/models",
};
const endpoint = endpoints[providerName] || "/v1/models";
const url = `${baseUrl.replace(/\/$/, "")}${endpoint}`;

try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3_000);
const response = await fetch(url, {
signal: controller.signal,
method: "GET",
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
throw new Error(
`Cannot reach ${providerName} at ${baseUrl} (tried ${endpoint}) — ${errMsg}`,
);
}
};

await probeLocalProvider();

// Apply mode and userRules from YAML if present.
if (yamlConfig?.agent?.defaultMode) {
agentController.setMode(yamlConfig.agent.defaultMode);
Expand Down Expand Up @@ -3384,6 +3471,7 @@ export async function activate(

inlineProvider.setProvider(newProvider);
inlineProviderRef.current = newProvider;
lastProviderError = null; // Clear cached error on successful load
// If YAML configures a separate autocomplete provider or model, wire it.
if (
yamlConfig?.autocomplete?.provider &&
Expand Down Expand Up @@ -3590,6 +3678,7 @@ export async function activate(
persistentRunner = baseRunner;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
lastProviderError = message; // Cache for onWebviewReady re-broadcast if webview isn't ready yet
setStatusError(message);
chatViewProvider?.broadcastProviderStatus({
state: "error",
Expand Down Expand Up @@ -3783,22 +3872,49 @@ export async function activate(
// sidebar icon appears instantly; the user can open it and see
// "loading..." while the provider and sessions come online.
//
// The entire init is wrapped in a 10-second timeout so that if any
// The entire init is wrapped in a 5-second timeout so that if any
// step hangs (SecretStorage, file I/O, provider factory), the user
// sees an error with retry instead of infinite "loading...".
const INIT_TIMEOUT_MS = 10_000;
// sees an error quickly with retry instead of infinite "loading...".
const INIT_TIMEOUT_MS = 5_000;

// Helper: Race a promise against a timeout, return null if timeout.
const withTimeout = <T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T | null> =>
Promise.race([
promise.then((v) => v),
new Promise<null>((resolve) =>
setTimeout(() => {
console.warn(
`Champ: ${label} timed out after ${timeoutMs}ms, continuing without it`,
);
resolve(null);
}, timeoutMs),
),
]);

void (async () => {
try {
await Promise.race([
(async () => {
// 0. Await memory bank load so cross-session facts are available immediately.
// 0. Load memory banks with individual timeouts (don't block on hanging reads).
if (memoryBank) {
await memoryBank.load();
broadcastMemoryBadge();
const result = await withTimeout(
memoryBank.load(),
2_000,
"memoryBank.load()",
);
if (result !== null) broadcastMemoryBadge();
}
await globalMemoryBank.load();
await withTimeout(
globalMemoryBank.load(),
2_000,
"globalMemoryBank.load()",
);

// 1. Load provider (reads YAML, creates provider, auto-detects models).
// 1. Load provider (reads YAML, creates provider, auto-detects models, checks connectivity).
await loadProvider();
})(),
new Promise<never>((_, reject) =>
Expand Down
Loading
Loading