Compare commits

...
Author SHA1 Message Date
Arafatkatze c98724ea93 Followup changes 2026-02-06 19:41:00 -08:00
Arafatkatze 1b77541104 refactor(cli): remove thinking budget and reasoning effort flags
Removes the `--thinking-budget` and `--reasoning-effort` options from the CLI to simplify task configuration. The thinking logic has been reverted to rely solely on the boolean `--thinking` flag, which applies a fixed budget of 1024 tokens when enabled.

This commit also:
- Updates `PROMPT_MANIFEST_CHANGES.md` to reflect the removal of these CLI options.
- Removes accidental test text from `README.md`.
2026-02-06 19:12:14 -08:00
Arafatkatze 1810f4d1ed feat: add optional prompt artifact dumps and update docs 2026-02-06 14:24:45 -08:00
Arafatkatze c2b2bc0799 feat: add prompt manifest and prompt override CLI controls
Introduce prompt profile/override system for reproducible prompt runs:

- Add CLI flags: --prompt-profile, --prompt-file, --thinking-budget,
  --reasoning-effort to both task and interactive commands
- Resolve prompt override metadata (profile id, file contents, SHA-256
  fingerprint) at task runtime from env vars
- Extend SystemPromptContext with active profile metadata fields
- Inject prompt-file instructions via existing user-instructions path
- Emit one-time info messages at first API request with prompt identity
  (profile, fingerprint, variant, provider, model)
- Add PROMPT_MANIFEST_CHANGES.md documenting the design and usage
2026-02-06 14:10:49 -08:00
Arafatkatze dfb33ec3c6 fixing readme 2026-02-06 11:58:30 -08:00
3 changed files with 258 additions and 126 deletions
+66
View File
@@ -0,0 +1,66 @@
# Prompt Artifact Changes
## Goal
Make it easy to:
1. Know exactly which prompt was used for a run.
2. Keep run-to-run comparisons reproducible.
## High-Level Flow
```mermaid
flowchart LR
U["User runs cline"] --> TASK["Task prompt resolver"]
TASK --> MAN["Build Prompt Manifest\nvariant + fingerprint metadata"]
TASK --> CTX["SystemPromptContext"]
CTX --> BUILDER["PromptBuilder (existing)"]
BUILDER --> API["Provider API createMessage"]
MAN --> LOG["Emit one-time info messages\nat first request"]
TASK --> ART["Optional prompt artifacts\nmanifest + system prompt"]
```
## What Changed
### 1) Task runtime: one-time prompt identity signal
File: `src/core/task/index.ts`
- At first API request only, emits info messages with:
- selected prompt variant family
- final system prompt fingerprint
- provider and model
### 2) Optional prompt artifact dump (exact prompt capture)
File: `src/core/task/index.ts`
- Added optional artifact writing to persist exactly what prompt was sent.
- Guarded by env flag:
- `CLINE_WRITE_PROMPT_ARTIFACTS=1` (also accepts `true`/`yes`)
- Optional output dir:
- `CLINE_PROMPT_ARTIFACT_DIR=/path/to/dir`
- defaults to `<cwd>/.cline-prompt-artifacts`
- On first request of a task run, writes:
- `<basename>.manifest.json`
- `<basename>.system_prompt.md`
## Practical Usage
### Persist exact prompts as artifacts
```bash
CLINE_WRITE_PROMPT_ARTIFACTS=1 \
CLINE_PROMPT_ARTIFACT_DIR=./prompt-artifacts \
cline task "Fix failing tests"
```
## Reproducibility Impact
For each run, you now get an explicit runtime identity signal that can be logged with your eval/benchmark metadata:
- System prompt fingerprint
- Variant family
- Provider/model
And with artifact dumping enabled, you can inspect the exact rendered prompt text for audit/debugging.
## Validation
- Type-check passed: `npm run -s check-types`
- CLI build passed: `npm run -s cli:build`
- Manual E2E against OpenRouter (`anthropic/claude-opus-4.5`) verified:
- profile-v1 and profile-v2 runs produce distinct output files
- manifest/profile/system prompt artifact files are written
+4 -4
View File
@@ -32,12 +32,12 @@ import { CliCommentReviewController } from "./controllers/CliCommentReviewContro
import { CliWebviewProvider } from "./controllers/CliWebviewProvider"
import { restoreConsole } from "./utils/console"
import { printInfo, printWarning } from "./utils/display"
import { selectOutputMode } from "./utils/mode-selection"
import { parseImagesFromInput, processImagePaths } from "./utils/parser"
import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path"
import { readStdinIfPiped } from "./utils/piped"
import { runPlainTextTask } from "./utils/plain-text-task"
import { applyProviderConfig } from "./utils/provider-config"
import { selectOutputMode } from "./utils/mode-selection"
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
import { initializeCliContext } from "./vscode-context"
@@ -168,7 +168,7 @@ async function runTaskInPlainTextMode(
imageDataUrls: taskConfig.imageDataUrls,
verbose: options.verbose,
jsonOutput: options.json,
timeoutSeconds: options.timeout ? parseInt(options.timeout, 10) : undefined,
timeoutSeconds: options.timeout ? Number.parseInt(options.timeout, 10) : undefined,
})
// Cleanup
@@ -447,8 +447,8 @@ async function listHistory(options: { config?: string; limit?: number; page?: nu
const taskHistory = StateManager.get().getGlobalStateKey("taskHistory") || []
// Sort by timestamp (newest first) before pagination
const sortedHistory = [...taskHistory].sort((a: any, b: any) => (b.ts || 0) - (a.ts || 0))
const limit = typeof options.limit === "string" ? parseInt(options.limit, 10) : options.limit || 10
const initialPage = typeof options.page === "string" ? parseInt(options.page, 10) : options.page || 1
const limit = typeof options.limit === "string" ? Number.parseInt(options.limit, 10) : options.limit || 10
const initialPage = typeof options.page === "string" ? Number.parseInt(options.page, 10) : options.page || 1
const totalCount = sortedHistory.length
const totalPages = Math.ceil(totalCount / limit)
+188 -122
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { ApiHandler, ApiProviderInfo, buildApiHandler } from "@core/api"
import { ApiStream } from "@core/api/transform/stream"
@@ -66,12 +67,13 @@ import { isClaude4PlusModelFamily, isGPT5ModelFamily, isLocalModel, isNextGenMod
import { arePathsEqual, getDesktopDir } from "@utils/path"
import { filterExistingFiles } from "@utils/tabFiltering"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
import Mutex from "p-mutex"
import pWaitFor from "p-wait-for"
import * as path from "path"
import { ulid } from "ulid"
import type { SystemPromptContext } from "@/core/prompts/system-prompt"
import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { getSystemPrompt, PromptRegistry } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
import {
@@ -215,7 +217,7 @@ export class Task {
* Example: We don't add noToolsUsed response when native tool call is used
* because of the expected format from the tool calls is different.
*/
private useNativeToolCalls: boolean = false
private useNativeToolCalls = false
private streamHandler: StreamResponseHandler
private terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
@@ -611,64 +613,62 @@ export class Task {
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
throw new Error("Current ask promise was ignored 1")
} else {
// this is a new partial message, so add it with partial state
// this.askResponse = undefined
// this.askResponseText = undefined
// this.askResponseImages = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
partial,
})
await this.postStateToWebview()
throw new Error("Current ask promise was ignored 2")
}
} else {
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
// this is a new partial message, so add it with partial state
// this.askResponse = undefined
// this.askResponseText = undefined
// this.askResponseImages = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
partial,
})
await this.postStateToWebview()
throw new Error("Current ask promise was ignored 2")
}
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
/*
/*
Bug for the history books:
In the webview we use the ts as the chatrow key for the virtuoso list. Since we would update this ts right at the end of streaming, it would cause the view to flicker. The key prop has to be stable otherwise react has trouble reconciling items between renders, causing unmounting and remounting of components (flickering).
The lesson here is if you see flickering when rendering lists, it's likely because the key prop is not stable.
So in this case we must make sure that the message ts is never altered after first setting it.
*/
askTs = lastMessage.ts
this.taskState.lastMessageTs = askTs
// lastMessage.ts = askTs
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial: false,
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
} else {
// this is a new partial=false message, so add it like normal
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
})
await this.postStateToWebview()
}
askTs = lastMessage.ts
this.taskState.lastMessageTs = askTs
// lastMessage.ts = askTs
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
text,
partial: false,
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
} else {
// this is a new partial=false message, so add it like normal
this.taskState.askResponse = undefined
this.taskState.askResponseText = undefined
this.taskState.askResponseImages = undefined
this.taskState.askResponseFiles = undefined
askTs = Date.now()
this.taskState.lastMessageTs = askTs
await this.messageStateHandler.addToClineMessages({
ts: askTs,
type: "ask",
ask: type,
text,
})
await this.postStateToWebview()
}
} else {
// this is a new non-partial message, so add it like normal
@@ -751,60 +751,42 @@ export class Task {
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage)
return undefined
} else {
// this is a new partial message, so add it with partial state
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
partial,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
} else {
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
// updateClineMessage emits the change event and saves to disk
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial: false,
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
return undefined
} else {
// this is a new partial=false message, so add it like normal
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
// this is a new partial message, so add it with partial state
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
partial,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
} else {
// this is a new non-partial message, so add it like normal
// partial=false means its a complete version of a previously partial message
if (isUpdatingPreviousPartial) {
// this is the complete version of a previously partial message, so replace the partial with the complete version
this.taskState.lastMessageTs = lastMessage.ts
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
// updateClineMessage emits the change event and saves to disk
await this.messageStateHandler.updateClineMessage(lastIndex, {
text,
images,
files,
partial: false,
})
// await this.postStateToWebview()
const protoMessage = convertClineMessageToProto(lastMessage)
await sendPartialMessageEvent(protoMessage) // more performant than an entire postStateToWebview
return undefined
}
// this is a new partial=false message, so add it like normal
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
@@ -819,6 +801,20 @@ export class Task {
await this.postStateToWebview()
return sayTs
}
// this is a new non-partial message, so add it like normal
const sayTs = Date.now()
this.taskState.lastMessageTs = sayTs
await this.messageStateHandler.addToClineMessages({
ts: sayTs,
type: "say",
say: type,
text,
images,
files,
modelInfo,
})
await this.postStateToWebview()
return sayTs
}
async sayAndCreateMissingParamError(toolName: ClineDefaultTool, paramName: string, relPath?: string) {
@@ -1355,19 +1351,18 @@ export class Task {
// For now a task never 'completes'. This will only happen if the user hits max requests and denies resetting the count.
//this.say("task_completed", `Task completed. Total API usage cost: ${totalCost}`)
break
} else {
// this.say(
// "tool",
// "Cline responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..."
// )
nextUserContent = [
{
type: "text",
text: formatResponse.noToolsUsed(this.useNativeToolCalls),
},
]
this.taskState.consecutiveMistakeCount++
}
// this.say(
// "tool",
// "Cline responded with only text blocks but has not called attempt_completion yet. Forcing him to continue with task..."
// )
nextUserContent = [
{
type: "text",
text: formatResponse.noToolsUsed(this.useNativeToolCalls),
},
]
this.taskState.consecutiveMistakeCount++
}
}
@@ -1631,6 +1626,58 @@ export class Task {
return { model, providerId, customPrompt, mode }
}
private async writePromptArtifacts(params: {
promptVariantFamily: string
systemPromptFingerprint: string
systemPrompt: string
providerInfo: ApiProviderInfo
}): Promise<void> {
const enabledFlag = process.env.CLINE_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
const enabled = enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes"
if (!enabled) {
return
}
try {
const configuredDir = process.env.CLINE_PROMPT_ARTIFACT_DIR?.trim()
const artifactDir = configuredDir
? path.isAbsolute(configuredDir)
? configuredDir
: path.resolve(this.cwd, configuredDir)
: path.resolve(this.cwd, ".cline-prompt-artifacts")
await fs.mkdir(artifactDir, { recursive: true })
const safeTs = new Date().toISOString().replace(/[:.]/g, "-")
const baseName = `task-${this.taskId}-req-${this.taskState.apiRequestCount}-${safeTs}`
const manifestPath = path.join(artifactDir, `${baseName}.manifest.json`)
const systemPromptPath = path.join(artifactDir, `${baseName}.system_prompt.md`)
const manifest = {
taskId: this.taskId,
ulid: this.ulid,
ts: new Date().toISOString(),
cwd: this.cwd,
provider: params.providerInfo.providerId,
model: params.providerInfo.model.id,
mode: params.providerInfo.mode,
promptVariantFamily: params.promptVariantFamily,
systemPromptFingerprint: params.systemPromptFingerprint,
}
const writes: Promise<unknown>[] = [
fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8"),
fs.writeFile(systemPromptPath, params.systemPrompt, "utf8"),
]
await Promise.all(writes)
await this.say("info", `Prompt artifacts written: ${manifestPath}`)
} catch (error) {
Logger.error("Failed to write prompt artifacts:", error)
}
}
private getApiRequestIdSafe(): string | undefined {
const apiLike = this.api as Partial<{
getLastRequestId: () => string | undefined
@@ -1848,6 +1895,26 @@ export class Task {
const { systemPrompt, tools } = await getSystemPrompt(promptContext)
this.useNativeToolCalls = !!tools?.length
if (this.taskState.apiRequestCount === 1) {
const promptVariantFamily = PromptRegistry.getInstance().getModelFamily(promptContext)
const systemPromptFingerprint = createHash("sha256").update(systemPrompt).digest("hex").slice(0, 12)
await this.say(
"info",
[
`System prompt variant=${promptVariantFamily}`,
`fingerprint=${systemPromptFingerprint}`,
`provider=${providerInfo.providerId}`,
`model=${providerInfo.model.id}`,
].join(" | "),
)
await this.writePromptArtifacts({
promptVariantFamily,
systemPromptFingerprint,
systemPrompt,
providerInfo,
})
}
const contextManagementMetadata = await this.contextManager.getNewContextMessagesAndMetadata(
this.messageStateHandler.getApiConversationHistory(),
@@ -2190,7 +2257,7 @@ export class Task {
}
}
async recursivelyMakeClineRequests(userContent: ClineContent[], includeFileDetails: boolean = false): Promise<boolean> {
async recursivelyMakeClineRequests(userContent: ClineContent[], includeFileDetails = false): Promise<boolean> {
// Check abort flag at the very start to prevent any execution after cancellation
if (this.taskState.abort) {
throw new Error("Task instance aborted")
@@ -3037,7 +3104,7 @@ export class Task {
async loadContext(
userContent: ClineContent[],
includeFileDetails: boolean = false,
includeFileDetails = false,
useCompactPrompt = false,
): Promise<[ClineContent[], string, boolean]> {
let needsClinerulesFileCheck = false
@@ -3261,12 +3328,11 @@ export class Task {
const primary = this.workspaceManager?.getPrimaryRoot()
const primaryName = this.getPrimaryWorkspaceName(primary)
return `\n\n# Current Working Directory (Primary: ${primaryName}) Files\n`
} else {
return `\n\n# Current Working Directory (${this.cwd.toPosix()}) Files\n`
}
return `\n\n# Current Working Directory (${this.cwd.toPosix()}) Files\n`
}
async getEnvironmentDetails(includeFileDetails: boolean = false) {
async getEnvironmentDetails(includeFileDetails = false) {
const host = await HostProvider.env.getHostVersion({})
let details = ""