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
114 changes: 114 additions & 0 deletions src/core/task/TaskApiRequestAttempt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { ApiStream } from "@core/api/transform/stream"
import { recordSuccessfulModelProviderPreset } from "@core/models/modelProviderPresets"
import type { ApiProvider } from "@shared/api"
import type { DiracApiReqCancelReason } from "@shared/ExtensionMessage"
import { TaskStatus } from "@shared/ExtensionMessage"
import { removeProviderBoundaryMetadataFromMessage } from "@shared/messages/content"
import type { DiracStorageMessage } from "@shared/messages/content"
import { StreamingMetricsManager } from "./StreamingMetricsManager"
import { buildApiRequestParams } from "./TaskRequestBuilder"
import { handleApiRequestError } from "./TaskRequestOutcome"
import { appendQueuedSteeringToNextApiRequest } from "./TaskSteering"
import type { TaskRequestLoopContext } from "./TaskRequestLoop"

export async function* attemptApiRequest(
ctx: TaskRequestLoopContext,
previousApiReqIndex: number,
lastApiReqIndex: number,
shouldCompact?: boolean,
): ApiStream {
const { systemPrompt, toolSnapshot, contextManagementMetadata, providerInfo } = await buildApiRequestParams(ctx, {
previousApiReqIndex,
shouldCompact,
})
const { model, providerId } = providerInfo

const metricsManager = new StreamingMetricsManager(ctx.messageStateHandler, lastApiReqIndex, ctx.api)

const finalizeApiReqMsg = async (cancelReason?: DiracApiReqCancelReason, streamingFailedMessage?: string) => {
await metricsManager.updateApiReqMsgFromMetrics(cancelReason, streamingFailedMessage)
await ctx.messageStateHandler.updateDiracMessage(lastApiReqIndex, {})
ctx.taskState.isApiRequestActive = false
ctx.taskState.activeVoiceStreamId = undefined
}

const abortStream = async (cancelReason: DiracApiReqCancelReason, streamingFailedMessage?: string) => {
ctx.taskState.didFinishAbortingStream = true
await finalizeApiReqMsg(cancelReason, streamingFailedMessage)
ctx.taskState.isApiRequestActive = false
ctx.taskState.activeVoiceStreamId = undefined
}

await appendQueuedSteeringToNextApiRequest(ctx.steeringContext, contextManagementMetadata.truncatedConversationHistory)

const providerDispatch = await ctx.apiConversationManager.prepareProviderConversationDispatch({
systemPrompt,
tools: toolSnapshot.nativeTools,
truncatedMessages: contextManagementMetadata.truncatedConversationHistory as DiracStorageMessage[],
providerId,
modelId: model.id,
})

if (ctx.taskState.abort) throw new Error("Task instance aborted")

const stream = ctx.api.createMessage(
systemPrompt,
providerDispatch.messages.map(removeProviderBoundaryMetadataFromMessage),
toolSnapshot.nativeTools,
providerDispatch.options,
)
const iterator = stream[Symbol.asyncIterator]()

try {
ctx.taskState.status = TaskStatus.WAITING_FOR_API

ctx.taskState.isWaitingForFirstChunk = true
const firstChunk = await iterator.next()
ctx.taskState.isWaitingForFirstChunk = false

if (firstChunk.done) {
await finalizeApiReqMsg()
return
}

yield firstChunk.value

for await (const chunk of iterator) {
if (ctx.taskState.abort) {
await abortStream("user_cancelled")
return
}

if (chunk.type === "usage") {
metricsManager.updateFromChunk(chunk)
yield chunk
continue
}

yield chunk
}

recordSuccessfulModelProviderPreset(
ctx.stateManager,
providerId as ApiProvider,
model.id,
model.info,
providerInfo.mode,
)
await finalizeApiReqMsg()
} catch (error) {
const shouldRetry = await handleApiRequestError(ctx, {
error,
previousApiReqIndex,
lastApiReqIndex,
shouldCompact,
model,
providerId,
metricsManager,
})
if (shouldRetry) {
yield* attemptApiRequest(ctx, previousApiReqIndex, lastApiReqIndex, shouldCompact)
}
return
}
}
114 changes: 114 additions & 0 deletions src/core/task/TaskMistakeLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { formatResponse } from "@core/formatResponse"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import { CardStatus } from "@shared/ExtensionMessage"
import { DiracAskResponse } from "@shared/WebviewMessage"
import { DiracContent, type DiracUserContent } from "@shared/messages/content"
import type { StateManager } from "../storage/StateManager"
import type { TaskMessenger } from "./TaskMessenger"
import type { TaskState } from "./TaskState"
import { ToolSkippedByUserMessage } from "./tools/types/ToolSkippedByUserMessage"

export interface TaskMistakeLimitContext {
taskState: TaskState
stateManager: StateManager
taskMessenger: TaskMessenger
}

export async function handleMistakeLimitReached(
ctx: TaskMistakeLimitContext,
userContent: DiracContent[],
): Promise<{ didEndLoop: boolean; userContent: DiracContent[] }> {
if (ctx.taskState.consecutiveMistakeCount < ctx.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")) {
return { didEndLoop: false, userContent }
}

// In yolo mode, don't wait for user input - fail the task
if (ctx.stateManager.getGlobalSettingsKey("yoloModeToggled")) {
const errorMessage =
`[YOLO MODE] Task failed: Too many consecutive mistakes (${ctx.taskState.consecutiveMistakeCount}). ` +
`The model may not be capable enough for this task. Consider using a more capable model.`
const card = await ctx.taskMessenger.createCard({
status: CardStatus.ERROR,
header: "Task Failed",
body: errorMessage,
})
await card.finalize(CardStatus.ERROR)
// End the task loop with failure
return { didEndLoop: true, userContent } // didEndLoop = true, signals task completion/failure
}

const autoApprovalSettings = ctx.stateManager.getGlobalSettingsKey("autoApprovalSettings")
if (autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Error",
message: "Dirac is having trouble. Would you like to continue the task?",
})
}

const cardHandle = await ctx.taskMessenger.createCard({
header: "Mistake Limit Reached",
body: `Tool use failure. Can potentially be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`,
requireFeedback: true,
feedbackPlaceholder: "Provide guidance to Dirac...",
})
let response: DiracAskResponse
let text: string | undefined
let images: string[] | undefined
let files: string[] | undefined
try {
const result = await cardHandle.waitForInteraction()
response = result.response
text = result.text
images = result.images
files = result.files
} catch (error) {
if (error instanceof ToolSkippedByUserMessage) {
await cardHandle.finalize(CardStatus.SKIPPED)
ctx.taskState.pendingUserMessage = error.userMessage
ctx.taskState.pendingUserImages = error.userImages
ctx.taskState.pendingUserFiles = error.userFiles
ctx.taskState.consecutiveMistakeCount = 0
return { didEndLoop: false, userContent }
}
throw error
}

await cardHandle.finalize(CardStatus.SUCCESS)

if (response === DiracAskResponse.MESSAGE) {
// Display the user's message in the chat UI
await ctx.taskMessenger.upsertText(text || "", false, images, files, "user")

// This userContent is for the *next* API call.
const feedbackUserContent: DiracUserContent[] = []
feedbackUserContent.push({
type: "text",
isUserInput: true,
text: formatResponse.tooManyMistakes(text),
})

if (images && images.length > 0) {
feedbackUserContent.push(...formatResponse.imageBlocks(images))
}

let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}

if (fileContentString) {
feedbackUserContent.push({
type: "text",
text: fileContentString,
})
}

userContent = feedbackUserContent
}

ctx.taskState.consecutiveMistakeCount = 0
ctx.taskState.apiErrorRetryAttempts = 0
ctx.taskState.emptyResponseRetryAttempts = 0
return { didEndLoop: false, userContent }
}
125 changes: 125 additions & 0 deletions src/core/task/TaskPromptArtifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Logger } from "@shared/services/Logger"
import fs from "fs/promises"
import * as path from "path"
import type { StateManager } from "../storage/StateManager"

export interface TaskPromptArtifactsContext {
taskId: string
cwd: string
stateManager: StateManager
}

export async function writePromptMetadataArtifacts(
ctx: TaskPromptArtifactsContext,
params: {
systemPrompt: string
providerInfo: { providerId?: string; modelId?: string }
tools?: any[]
fullHistory?: any[]
deletedRange?: [number, number]
},
): Promise<void> {
const enabledSetting = ctx.stateManager.getGlobalSettingsKey("writePromptMetadataEnabled")
const enabledFlag = process.env.DIRAC_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
const enabled =
enabledSetting || enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes" || process.env.IS_DEV === "true"
if (!enabled) {
return
}

try {
// Env var is OS-level (user-controlled, safe to allow absolute); workspace setting is the exfiltration vector.
const envDir = process.env.DIRAC_PROMPT_ARTIFACT_DIR?.trim()
const settingDir = ctx.stateManager.getGlobalSettingsKey("writePromptMetadataDirectory")?.trim()
const cwdResolved = path.resolve(ctx.cwd)
// Setting-configured dirs must resolve under cwd to prevent workspace settings from exfiltrating prompts.
// Only validate the setting when no env var is provided — env takes precedence and is trusted.
if (!envDir && settingDir) {
const resolved = path.isAbsolute(settingDir) ? path.resolve(settingDir) : path.resolve(ctx.cwd, settingDir)
if (resolved !== cwdResolved && !resolved.startsWith(cwdResolved + path.sep)) {
Logger.warn(`[Task ${ctx.taskId}] writePromptMetadataDirectory outside cwd rejected: ${resolved}`)
return
}
}
const configuredDir = envDir || settingDir
const artifactDir = configuredDir
? path.isAbsolute(configuredDir)
? path.resolve(configuredDir)
: path.resolve(ctx.cwd, configuredDir)
: path.resolve(ctx.cwd, ".dirac-prompt-artifacts")

await fs.mkdir(artifactDir, { recursive: true })
// Defense-in-depth: re-check the boundary after mkdir resolves any symlinks in the path,
// so a workspace-planted symlink can't exfiltrate prompts outside cwd.
// Only enforced for setting-derived paths — env vars are OS-level and may legitimately point anywhere.
let writeDir = artifactDir
if (!envDir) {
const realArtifactDir = await fs.realpath(artifactDir)
if (realArtifactDir !== cwdResolved && !realArtifactDir.startsWith(cwdResolved + path.sep)) {
Logger.warn(`[Task ${ctx.taskId}] artifact dir resolves outside cwd (symlink?), rejected: ${realArtifactDir}`)
return
}
writeDir = realArtifactDir
}
// Ensure the artifact dir is git-ignored so debug dumps don't get committed.
const gitignorePath = path.join(writeDir, ".gitignore")
await fs.writeFile(gitignorePath, "*\n!.gitignore\n", "utf8").catch(() => {})

const debugPath = path.join(writeDir, `task-${ctx.taskId}-debug.md`)

let markdown = `## System Prompt\n\n${params.systemPrompt}\n\n`

if (params.tools) {
markdown += `## Tools\n\n\`\`\`json\n${JSON.stringify(params.tools, null, 2)}\n\`\`\`\n\n`
}

if (params.fullHistory) {
markdown += `## Conversation History\n\n`
const [deletedStart, deletedEnd] = params.deletedRange || [-1, -1]

for (let i = 0; i < params.fullHistory.length; i++) {
const message = params.fullHistory[i]
const isTruncated = i >= deletedStart && i <= deletedEnd

markdown += `### [${message.role.toUpperCase()}]${isTruncated ? " [TRUNCATED]" : ""}\n`

if (typeof message.content === "string") {
markdown += `${message.content}\n\n`
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
markdown += `**Text:** ${block.call_id ? `(\`call_id: ${block.call_id}\`)` : ""}\n${block.text}\n\n`
} else if (block.type === "thinking") {
markdown += `**Thinking:** ${block.call_id ? `(\`call_id: ${block.call_id}\`)` : ""}\n${block.thinking}\n\n`
} else if (block.type === "redacted_thinking") {
markdown += `**Thinking:** [Redacted] ${block.call_id ? `(\`call_id: ${block.call_id}\`)` : ""}\n\n`
} else if (block.type === "tool_use") {
markdown += `**Tool Use:** \`${block.name}\` (\`id: ${block.id}\`, \`call_id: ${block.call_id}\`)\n`
markdown += `\`\`\`json\n${JSON.stringify(block.input, null, 2)}\n\`\`\`\n\n`
} else if (block.type === "tool_result") {
markdown += `**Tool Result:** (\`${block.tool_use_id}\`)\n`
if (typeof block.content === "string") {
markdown += `${block.content}\n\n`
} else if (Array.isArray(block.content)) {
for (const contentBlock of block.content) {
if (contentBlock.type === "text") {
markdown += `${contentBlock.text}\n\n`
} else if (contentBlock.type === "image") {
markdown += `[Image: ${contentBlock.source?.type}]\n\n`
}
}
}
} else if (block.type === "image") {
markdown += `[Image: ${block.source?.type}]\n\n`
}
}
}
markdown += "---\n\n"
}
}

await fs.writeFile(debugPath, markdown, "utf8")
} catch (error) {
Logger.error("Failed to write prompt metadata artifacts:", error)
}
}
Loading