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
467 changes: 467 additions & 0 deletions docs/IMPLEMENTATION_PLAN.md

Large diffs are not rendered by default.

75 changes: 5 additions & 70 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@
"command": "champ.resumeTeamRun",
"title": "Champ: Resume Team Run"
},
{
"command": "champ.configureProvider",
"title": "Champ: Configure Provider",
"category": "Champ"
},
{
"command": "champ.openTeamBuilder",
"title": "Champ: Open Team Builder"
Expand Down Expand Up @@ -252,81 +257,11 @@
"configuration": {
"title": "Champ",
"properties": {
"champ.provider": {
"type": "string",
"default": "claude",
"enum": [
"claude",
"openai",
"gemini",
"ollama",
"llamacpp",
"vllm",
"openai-compatible"
],
"markdownDescription": "Active LLM provider. Edit **`.champ/config.yaml`** for full control (YAML overrides these settings)."
},
"champ.claude.model": {
"type": "string",
"default": "claude-sonnet-4-20250514",
"markdownDescription": "Claude model ID. API key is stored securely — run **Champ: Set API Key** to configure it."
},
"champ.openai.model": {
"type": "string",
"default": "gpt-4o",
"markdownDescription": "OpenAI model ID. API key is stored securely — run **Champ: Set API Key** to configure it."
},
"champ.gemini.model": {
"type": "string",
"default": "gemini-2.0-flash",
"markdownDescription": "Gemini model ID. API key is stored securely — run **Champ: Set API Key** to configure it."
},
"champ.ollama.baseUrl": {
"type": "string",
"default": "http://localhost:11434",
"markdownDescription": "Ollama server base URL. Default is local; set to a remote address if Ollama runs on another machine."
},
"champ.ollama.model": {
"type": "string",
"default": "",
"markdownDescription": "Ollama model tag. Must match an installed model — run `ollama pull <model>` to install."
},
"champ.llamacpp.baseUrl": {
"type": "string",
"default": "http://localhost:8080/v1",
"markdownDescription": "llama.cpp server base URL **including `/v1`** (e.g. `http://localhost:8080/v1`). Set the model via `.champ/config.yaml`."
},
"champ.vllm.baseUrl": {
"type": "string",
"default": "http://localhost:8000/v1",
"markdownDescription": "vLLM server base URL **including `/v1`** (e.g. `http://localhost:8000/v1`). Set the model via `.champ/config.yaml`."
},
"champ.vllm.apiKey": {
"type": "string",
"markdownDescription": "vLLM API key (if your server requires authentication). Store securely via **Champ: Set API Key** command."
},
"champ.openaiCompatible.baseUrl": {
"type": "string",
"markdownDescription": "Base URL for an OpenAI-compatible server **including `/v1`**."
},
"champ.openaiCompatible.model": {
"type": "string"
},
"champ.openaiCompatible.apiKey": {
"type": "string",
"markdownDescription": "API key for the OpenAI-compatible server. Store securely via **Champ: Set API Key** command."
},
"champ.yoloMode": {
"type": "boolean",
"default": false,
"markdownDescription": "Skip approval prompts for terminal commands and file edits. **Use with caution.**"
},
"champ.configSource": {
"type": "string",
"enum": ["auto", "workspace-yaml", "user-yaml", "settings"],
"default": "auto",
"markdownDescription": "Single source of truth for Champ's LLM configuration.\n\n- **auto** — workspace `.champ/config.yaml` if present, else `~/.champ/config.yaml`, else these settings (historical precedence)\n- **workspace-yaml / user-yaml / settings** — use ONLY that source; others are ignored\n\nInspect the running result with **Champ: Show Effective Config**."
},
"champ.autoFix.enabled": {
"type": "boolean",
"default": true,
Expand Down
43 changes: 42 additions & 1 deletion src/agent/agent-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
extractTextContent,
extractPreToolText,
hasFabricatedNarration,
TOOL_CALL_START_PREFIX,
buildPrefillAssistantMessage,
type MalformedToolCall,
} from "../providers/prompt-based-tools";
import { SecretScanner } from "../safety/secret-scanner";
Expand Down Expand Up @@ -554,6 +556,13 @@ export class AgentController {
if (options.abortSignal?.aborted) break;
iterationRan = true;

// Assistant-prefill (#121): on prompt-based continuation turns (there
// is at least one tool registered and we have already run at least one
// iteration), a trailing assistant seed makes the model begin its next
// output inside the <tool_call> XML format instead of narrating.
const prefillNeeded =
usePromptBased && iteration > 0 && allTools.length > 0;

// Emit iteration start event so the UI can display live progress.
this.emitIterationStart(
iteration,
Expand Down Expand Up @@ -670,12 +679,29 @@ export class AgentController {
break;
}

const stream = activeProvider.chat(messagesToSend, {
// Append the assistant-prefill seed on continuation turns (#121).
// Kept as a separate list so `messagesToSend` (the cache key) stays
// stable. Iteration 0 is never prefilled, so cache behaviour is
// unchanged; continuation turns are never cached anyway.
const requestMessages = prefillNeeded
? [...messagesToSend, buildPrefillAssistantMessage()]
: messagesToSend;

const stream = activeProvider.chat(requestMessages, {
// Native tool defs only when the provider says it supports them.
tools: usePromptBased ? undefined : allTools,
abortSignal: options.abortSignal,
taskHint: "coding",
});

// The backend continues from the seed we appended, so the buffered
// assistant text must start there too — otherwise the streamed output
// is just the tail of the tag and the assembled tool call would be
// missing its opener.
if (prefillNeeded) {
assistantText = TOOL_CALL_START_PREFIX;
}

let errorOccurred = false;
for await (const delta of stream) {
if (delta.type === "text" && delta.text) {
Expand Down Expand Up @@ -751,6 +777,21 @@ export class AgentController {
malformedCalls.push(info),
);

// Assistant-prefill answered with prose (#121): if the seeded
// continuation turn produced a plain text reply instead of a
// complete tool call, drop the dangling seed so it never leaks
// into history or the UI. The parse above already ran on the
// seeded text, so this only fires when no call was completed —
// the seed is pure noise at that point.
if (
prefillNeeded &&
parsed.length === 0 &&
malformedCalls.length === 0 &&
assistantText.startsWith(TOOL_CALL_START_PREFIX)
) {
assistantText = assistantText.slice(TOOL_CALL_START_PREFIX.length);
}

// ── Hallucination guard (issue #101) ────────────────────────────
// If no tool calls were parsed but the text contains fabricated
// narration (e.g. "---Result of reading documentation:"), the
Expand Down
2 changes: 2 additions & 0 deletions src/agent/tool-calling-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ export class ToolCallingLoop {
const stream = this.provider.chat(history, {
tools: toolDefs.length > 0 ? toolDefs : undefined,
abortSignal: this.context.abortSignal,
// Deterministic decode profile for tool turns (ticket #120).
taskHint: "toolcall",
});

for await (const delta of stream) {
Expand Down
1 change: 1 addition & 0 deletions src/completion/inline-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export class ChampInlineCompletionProvider {
temperature: 0.2,
maxTokens: 256,
stop: DEFAULT_STOP,
taskHint: "completion",
})) {
if (controller.signal.aborted) return [];

Expand Down
125 changes: 110 additions & 15 deletions src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ export interface ProviderConfig {
apiKey?: string;
/** Opt-in to native OpenAI tool calling (openai-compatible spec servers). */
supportsTools?: boolean;
/** Opt-in to JSON-constrained generation on chat requests (#121). */
structuredOutput?: boolean;
/** Ask the backend to keep its prompt/KV cache warm (#121). */
cachePrompt?: boolean;
/**
* Cap the effective context window for this provider (tokens). Never raise
* above what config pins, even if the runtime advertises more. Ticket #119.
*/
contextWindow?: number;
/** Per-provider decode overrides (ticket #120). Overrides win over the task profile. */
options?: {
temperature?: number;
topP?: number;
topK?: number;
minP?: number;
repeatPenalty?: number;
presencePenalty?: number;
frequencyPenalty?: number;
seed?: number;
stop?: string[];
};
}

export interface AutocompleteConfig {
Expand Down Expand Up @@ -416,6 +437,89 @@ export class ConfigLoader {
pc.supportsTools = c.supportsTools;
}
}
if ("structuredOutput" in c) {
// Opt-in to JSON-constrained generation (#121). Rejected on
// tool-call turns; the XML tool prompt needs free text.
if (typeof c.structuredOutput !== "boolean") {
pushError(`providers.${name}.structuredOutput must be a boolean`);
} else {
pc.structuredOutput = c.structuredOutput;
}
}
if ("cachePrompt" in c) {
// Ask the backend to keep its prompt/KV cache warm (#121).
if (typeof c.cachePrompt !== "boolean") {
pushError(`providers.${name}.cachePrompt must be a boolean`);
} else {
pc.cachePrompt = c.cachePrompt;
}
}
if ("contextWindow" in c) {
const cw = c as Record<string, unknown>;
if (
typeof cw.contextWindow !== "number" ||
cw.contextWindow <= 0 ||
!Number.isInteger(cw.contextWindow)
) {
pushError(
`providers.${name}.contextWindow must be a positive integer`,
);
} else {
pc.contextWindow = cw.contextWindow as number;
}
}
if ("options" in c) {
const opt = c as { options?: unknown };
if (typeof opt.options !== "object" || opt.options === null) {
pushError(`providers.${name}.options must be an object`);
} else {
const o = opt.options as Record<string, unknown>;
const out: NonNullable<ProviderConfig["options"]> = {};
const numField = (
key: keyof typeof out,
min: number,
max: number,
hint?: string,
): void => {
if (key in o) {
if (
typeof o[key as string] !== "number" ||
(o[key as string] as number) < min ||
(o[key as string] as number) > max
) {
pushError(
`providers.${name}.options.${String(key)} must be a number${
hint ? ` ${hint}` : ""
}`,
);
return;
}
out[key] = o[key as string] as never;
}
};
numField("temperature", 0, 2);
numField("topP", 0, 1);
numField("topK", 0, Number.MAX_SAFE_INTEGER);
numField("minP", 0, 1);
numField("repeatPenalty", 0, Number.MAX_SAFE_INTEGER);
numField("presencePenalty", -2, 2);
numField("frequencyPenalty", -2, 2);
numField("seed", 0, Number.MAX_SAFE_INTEGER);
if ("stop" in o) {
if (
!Array.isArray(o.stop) ||
o.stop.some((s) => typeof s !== "string")
) {
pushError(
`providers.${name}.options.stop must be an array of strings`,
);
} else {
out.stop = o.stop as string[];
}
}
pc.options = out;
}
}
result.providers[name as ProviderName] = pc;
}
}
Expand Down Expand Up @@ -1336,13 +1440,9 @@ export class ConfigLoader {
// Layered resolution with explicit single source (issue #115)
// ---------------------------------------------------------------------------

export type ConfigSource = "auto" | "workspace-yaml" | "user-yaml" | "settings";
export type ConfigSource = "auto" | "workspace-yaml" | "user-yaml";

export type ConfigLayer =
| "workspace-yaml"
| "user-yaml"
| "settings"
| "default";
export type ConfigLayer = "workspace-yaml" | "user-yaml" | "default";

export interface LayeredInput {
/** Raw text of <workspace>/.champ/config.yaml (null if absent). */
Expand Down Expand Up @@ -1393,12 +1493,7 @@ export function resolveLayered(input: LayeredInput): LayeredResult {
let wsText = input.workspaceText ?? null;
let userText = input.userText ?? null;

if (input.source === "settings") {
if (wsText) ignoredSources.push("workspace-yaml");
if (userText) ignoredSources.push("user-yaml");
wsText = null;
userText = null;
} else if (input.source === "workspace-yaml") {
if (input.source === "workspace-yaml") {
if (userText) ignoredSources.push("user-yaml");
userText = null;
} else if (input.source === "user-yaml") {
Expand All @@ -1411,12 +1506,12 @@ export function resolveLayered(input: LayeredInput): LayeredResult {

if (input.source === "workspace-yaml" && !wsText) {
throw new Error(
"Invalid YAML or missing file: workspace .champ/config.yaml not found but champ.configSource=workspace-yaml",
"Invalid YAML or missing file: workspace .champ/config.yaml not found but source=workspace-yaml",
);
}
if (input.source === "user-yaml" && !userText) {
throw new Error(
"Invalid YAML or missing file: ~/.champ/config.yaml not found but champ.configSource=user-yaml",
"Invalid YAML or missing file: ~/.champ/config.yaml not found but source=user-yaml",
);
}

Expand All @@ -1426,7 +1521,7 @@ export function resolveLayered(input: LayeredInput): LayeredResult {
if (!ws && !user) {
return {
config: null,
usedSource: "settings",
usedSource: "default",
ignoredSources,
conflict: false,
origins: {},
Expand Down
Loading
Loading