mirror of
https://github.com/cline/cline.git
synced 2026-09-16 06:32:31 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c98724ea93 | ||
|
|
1b77541104 | ||
|
|
1810f4d1ed | ||
|
|
c2b2bc0799 | ||
|
|
dfb33ec3c6 |
@@ -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
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user