Compare commits

...

1 Commits

Author SHA1 Message Date
pashpashpash ca033dfdd3 harbor-compliant record functionality for cli and core 2025-11-09 16:19:23 -08:00
3 changed files with 177 additions and 1 deletions
+8 -1
View File
@@ -31,6 +31,7 @@ var (
settings []string
yolo bool
oneshot bool
record bool // Harbor episode recording
)
func main() {
@@ -151,6 +152,11 @@ see the manual page: man cline`,
yolo = true
}
// Set environment variable for episode recording if --record flag is used
if record {
os.Setenv("CLINE_RECORD_EPISODES", "true")
}
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
Images: images,
Files: files,
@@ -175,6 +181,7 @@ see the manual page: man cline`,
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
rootCmd.Flags().BoolVarP(&record, "record", "r", false, "record episodes for Harbor integration")
rootCmd.AddCommand(cli.NewTaskCommand())
rootCmd.AddCommand(cli.NewInstanceCommand())
@@ -345,4 +352,4 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
}
return content.String(), nil
}
}
+139
View File
@@ -0,0 +1,139 @@
import { Anthropic } from "@anthropic-ai/sdk"
import crypto from "crypto"
import * as fs from "fs/promises"
import * as path from "path"
export interface EpisodeData {
input: Anthropic.Messages.MessageParam[]
model: string
provider: string
temperature?: number
response: {
text: string
toolUses: any[]
}
startTime: Date
usage: {
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
}
totalCost?: number
}
/**
* Records API request/response pairs as episodes for Harbor evaluation framework.
* Episodes are stored in /logs/agent/episode-N/ format with:
* - debug.json: LiteLLM-compatible request/response data
* - response.txt: Assistant's text response
* - prompt.txt: Latest user message
*
* Environment variables:
* - CLINE_RECORD_EPISODES=true: Enable recording
* - CLINE_EPISODE_LOGS_DIR=/path: Custom logs directory (default: /logs/agent)
* - CLINE_EPISODE_USE_TASK_ID_FOLDER=true: Nest episodes under taskId subfolder
*/
export class EpisodeRecorder {
private enabled: boolean
private logsDir: string
private useTaskIdFolder: boolean
constructor(taskId?: string) {
// Check environment variables
this.enabled = process.env.CLINE_RECORD_EPISODES === "true"
const baseDir = process.env.CLINE_EPISODE_LOGS_DIR || "/logs/agent"
this.useTaskIdFolder = process.env.CLINE_EPISODE_USE_TASK_ID_FOLDER === "true"
// If useTaskIdFolder is true and we have a taskId, nest under taskId
this.logsDir = this.useTaskIdFolder && taskId ? path.join(baseDir, taskId) : baseDir
}
/**
* Gets the next episode number by counting existing episode-* directories.
* Uses filesystem as source of truth for robustness (survives crashes).
*/
private async getNextEpisodeNumber(): Promise<number> {
try {
const entries = await fs.readdir(this.logsDir, { withFileTypes: true })
const episodeNumbers = entries
.filter((e) => e.isDirectory() && e.name.startsWith("episode-"))
.map((e) => parseInt(e.name.replace("episode-", "")))
.filter((n) => !isNaN(n))
return episodeNumbers.length > 0 ? Math.max(...episodeNumbers) + 1 : 0
} catch {
// Directory doesn't exist yet
return 0
}
}
/**
* Records an episode (API request/response pair) to disk.
* Fails silently to never interrupt the task.
*/
async recordEpisode(data: EpisodeData): Promise<void> {
if (!this.enabled) {
return
}
try {
// Ensure logs directory exists
await fs.mkdir(this.logsDir, { recursive: true })
const episodeNum = await this.getNextEpisodeNumber()
const episodeDir = path.join(this.logsDir, `episode-${episodeNum}`)
await fs.mkdir(episodeDir, { recursive: true })
// Create debug.json in Harbor/LiteLLM format
const debugData = {
litellm_trace_id: "None",
litellm_call_id: crypto.randomUUID(),
input: data.input,
model: data.model,
messages: data.input, // Duplicate for LiteLLM compatibility
optional_params: {
temperature: data.temperature ?? 0,
},
start_time: data.startTime.toISOString().replace("T", " ").replace("Z", ""),
original_response: JSON.stringify({
model: data.model,
type: "message",
role: "assistant",
content: [{ type: "text", text: data.response.text }, ...data.response.toolUses],
usage: {
input_tokens: data.usage.inputTokens,
output_tokens: data.usage.outputTokens,
cache_creation_input_tokens: data.usage.cacheWriteTokens,
cache_read_input_tokens: data.usage.cacheReadTokens,
},
}),
// Metadata
provider: data.provider,
cost_usd: data.totalCost,
}
await fs.writeFile(path.join(episodeDir, "debug.json"), JSON.stringify(debugData, null, 2))
// Write response.txt
await fs.writeFile(path.join(episodeDir, "response.txt"), data.response.text)
// Write prompt.txt (last user message)
const lastUserMsg = [...data.input].reverse().find((m) => m.role === "user")
if (lastUserMsg) {
const promptText = Array.isArray(lastUserMsg.content)
? lastUserMsg.content
.filter((b) => b.type === "text")
.map((b) => (b as any).text)
.join("\n\n")
: lastUserMsg.content
await fs.writeFile(path.join(episodeDir, "prompt.txt"), promptText)
}
} catch (error) {
// Never crash the task - just log the error
console.error("Failed to record episode:", error)
}
}
}
+30
View File
@@ -196,6 +196,7 @@ export class Task {
private diffViewProvider: DiffViewProvider
public checkpointManager?: ICheckpointManager
private clineIgnoreController: ClineIgnoreController
private episodeRecorder?: import("@core/episode/EpisodeRecorder").EpisodeRecorder
private toolExecutor: ToolExecutor
/**
* Whether the task is using native tool calls.
@@ -541,6 +542,12 @@ export class Task {
this.clearActiveHookExecution.bind(this),
this.getActiveHookExecution.bind(this),
)
// Initialize episode recorder for Harbor integration
if (process.env.CLINE_RECORD_EPISODES === "true") {
const { EpisodeRecorder } = require("@core/episode/EpisodeRecorder")
this.episodeRecorder = new EpisodeRecorder(this.taskId)
}
}
// Communicate with webview
@@ -2718,6 +2725,7 @@ export class Task {
let reasoningMessage = ""
const reasoningDetails = []
const antThinkingContent: (Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock)[] = []
const apiRequestStartTime = new Date() // For episode recording
this.taskState.isStreaming = true
let didReceiveUsageChunk = false
try {
@@ -3039,6 +3047,28 @@ export class Task {
})
}
// Record episode for Harbor integration (if enabled)
if (this.episodeRecorder) {
await this.episodeRecorder.recordEpisode({
input: this.messageStateHandler.getApiConversationHistory(),
model: model.id,
provider: providerId,
temperature: 0,
response: {
text: assistantTextOnly,
toolUses: toolUseBlocks,
},
startTime: apiRequestStartTime,
usage: {
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
},
totalCost,
})
}
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
// in case the content blocks finished
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)