Compare commits

...

7 Commits

Author SHA1 Message Date
abeatrix 3700d4387d Add snapshot to atif test 2025-11-26 15:41:29 -08:00
abeatrix 3ad742f91a step.reasoning_content 2025-11-25 15:35:30 -08:00
abeatrix fc621e42fd Merge branch 'main' into bee/atif 2025-11-25 15:30:37 -08:00
abeatrix bb5186979d use copy of step 2025-11-25 15:28:07 -08:00
abeatrix 33e6aed48e apply feedback 2025-11-25 15:27:04 -08:00
abeatrix a70015abb8 refactor: ensures assistant message data is captured and saved
Reorganized the stream completion handling to improve code flow:
- Extract `assistantHasContent` check at the beginning for reuse
- Process telemetry and conversation history before setting completion flags
- Handle partial blocks and presentation after core message processing
- Maintain same functionality while improving logical structure

This ensures assistant message data is captured and saved before marking the stream as complete and handling UI updates.
2025-11-25 15:10:49 -08:00
abeatrix 528cf6e78a feat: add ATIF trajectory support with metrics tracking
Add comprehensive ATIF (Agent Trajectory Interchange Format) support for
conversation tracking and metrics collection:

- Add trajectory-manager module for creating, updating, and exporting
  ATIF trajectories with fluent builder API
- Add atif-converter tests for metrics enrichment from api_req_started messages
- Extract ClineMessageModelInfo and ClineTokenMetrics to dedicated metrics module
- Refactor imports to use barrel exports from @/shared/messages
- Pass modelInfo and requestId to stream handler for trajectory tracking

This enables standardized trajectory export and better observability of
model interactions with detailed token usage and cost metrics.
2025-11-25 15:10:05 -08:00
11 changed files with 2749 additions and 28 deletions
+24 -20
View File
@@ -91,7 +91,7 @@ import {
ClineTextContentBlock,
ClineToolResponseContent,
ClineUserContent,
} from "@/shared/messages/content"
} from "@/shared/messages"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import { isInTestMode } from "../../services/test/TestMode"
@@ -2730,6 +2730,8 @@ export class Task {
}]`,
},
],
modelInfo,
id: this.streamHandler.requestId,
})
// update api_req_started to have cancelled and cost, so that we can display the cost of the partial stream
@@ -2997,26 +2999,9 @@ export class Task {
throw new Error("Cline instance aborted")
}
this.taskState.didCompleteReadingStream = true
const assistantHasContent = assistantMessage.length > 0 || this.useNativeToolCalls
// set any blocks to be complete to allow presentAssistantMessage to finish and set userMessageContentReady to true
// (could be a text block that had no subsequent tool uses, or a text block at the very end, or an invalid tool use, etc. whatever the case, presentAssistantMessage relies on these blocks either to be completed or the user to reject a block in order to proceed and eventually set userMessageContentReady to true)
const partialBlocks = this.taskState.assistantMessageContent.filter((block) => block.partial)
partialBlocks.forEach((block) => {
block.partial = false
})
// in case there are native tool calls pending
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
if (partialBlocks.length > 0) {
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
}
// now add to apiconversationhistory
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
let didEndLoop = false
if (assistantMessage.length > 0 || this.useNativeToolCalls) {
if (assistantHasContent) {
const currentMode = this.stateManager.getGlobalSettingsKey("mode")
telemetryService.captureConversationTurnEvent(
this.ulid,
@@ -3084,7 +3069,26 @@ export class Task {
id: requestId,
})
}
}
this.taskState.didCompleteReadingStream = true
// set any blocks to be complete to allow presentAssistantMessage to finish and set userMessageContentReady to true
// (could be a text block that had no subsequent tool uses, or a text block at the very end, or an invalid tool use, etc. whatever the case, presentAssistantMessage relies on these blocks either to be completed or the user to reject a block in order to proceed and eventually set userMessageContentReady to true)
const partialBlocks = this.taskState.assistantMessageContent.filter((block) => block.partial)
partialBlocks.forEach((block) => {
block.partial = false
})
// in case there are native tool calls pending
const partialToolBlocks = toolUseHandler.getPartialToolUsesAsContent()?.map((block) => ({ ...block, partial: false }))
this.processNativeToolCalls(assistantTextOnly, partialToolBlocks)
if (partialBlocks.length > 0) {
await this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
}
let didEndLoop = false
if (assistantHasContent) {
// 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)
+1 -1
View File
@@ -12,7 +12,7 @@ import { DictationSettings } from "./DictationSettings"
import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { ClineMessageModelInfo } from "./messages/content"
import { ClineMessageModelInfo } from "./messages"
import { OnboardingModelGroup } from "./proto/cline/state"
import { Mode, OpenaiReasoningEffort } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
@@ -0,0 +1,80 @@
{
"schema_version": "ATIF-v1.3",
"session_id": "snapshot-test-session",
"agent": {
"name": "cline",
"version": "1.0.0",
"extra": {}
},
"steps": [
{
"step_id": 1,
"timestamp": "2025-01-15T10:00:00Z",
"source": "user",
"message": "Fix the authentication bug"
},
{
"step_id": 2,
"timestamp": "2025-01-15T10:00:05Z",
"source": "agent",
"message": "I'll investigate the issue",
"model_name": "claude-sonnet-4-20250514",
"reasoning_content": "Need to examine the auth code first",
"tool_calls": [
{
"tool_call_id": "call_read_1",
"function_name": "Read",
"arguments": {
"file_path": "/auth/login.ts"
}
}
],
"metrics": {
"prompt_tokens": 1000,
"completion_tokens": 500,
"cached_tokens": 200,
"cost_usd": 0.05
}
},
{
"step_id": 3,
"timestamp": "2025-01-15T10:00:10Z",
"source": "user",
"message": ""
},
{
"step_id": 4,
"timestamp": "2025-01-15T10:00:15Z",
"source": "agent",
"message": "Found the issue. The password validation is missing.",
"model_name": "claude-sonnet-4-20250514",
"tool_calls": [
{
"tool_call_id": "call_edit_1",
"function_name": "Edit",
"arguments": {
"file_path": "/auth/login.ts",
"old_string": "if (username) {",
"new_string": "if (username && password) {"
}
}
],
"metrics": {
"prompt_tokens": 1500,
"completion_tokens": 300,
"cached_tokens": 400,
"cost_usd": 0.03
}
}
],
"notes": "Fixed authentication bug",
"final_metrics": {
"total_prompt_tokens": 2500,
"total_completion_tokens": 800,
"total_cached_tokens": 600,
"total_cost_usd": 0.08,
"total_steps": 4,
"extra": {}
},
"extra": {}
}
@@ -0,0 +1,87 @@
[
{
"role": "user",
"content": "What is the current trading price of Alphabet (GOOGL)?",
"timestamp": "2025-10-11T10:30:00Z"
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The request requires two data points: the current stock price and the latest volume data. I will execute two simultaneous tool calls—one for price and one for volume—to retrieve this information in a single step.",
"signature": "2"
},
{
"type": "text",
"text": "I will search for the current trading price and volume for GOOGL."
},
{
"type": "tool_use",
"id": "call_price_1",
"name": "financial_search",
"input": {
"ticker": "GOOGL",
"metric": "price"
}
},
{
"type": "tool_use",
"id": "call_volume_2",
"name": "financial_search",
"input": {
"ticker": "GOOGL",
"metric": "volume"
}
},
{
"type": "tool_result",
"tool_use_id": "call_price_1",
"content": "GOOGL is currently trading at $185.35 (Close: 10/11/2025)"
},
{
"type": "tool_result",
"tool_use_id": "call_volume_2",
"content": "GOOGL volume: 1.5M shares traded."
}
],
"timestamp": "2025-10-11T10:30:02Z",
"modelInfo": {
"modelId": "gemini-2.5-flash",
"providerId": "unknown",
"reasoningEffort": "medium"
},
"metrics": {
"promptTokens": 520,
"completionTokens": 80,
"cachedTokens": 200,
"totalCost": 0.00045
}
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The previous step retrieved all necessary data. I will now format this into a final conversational response for the user and terminate the task.",
"signature": "3"
},
{
"type": "text",
"text": "As of October 11, 2025, Alphabet (GOOGL) is trading at $185.35 with a volume of 1.5M shares traded."
}
],
"timestamp": "2025-10-11T10:30:05Z",
"modelInfo": {
"modelId": "gemini-2.5-flash",
"providerId": "unknown",
"reasoningEffort": "low"
},
"metrics": {
"promptTokens": 600,
"completionTokens": 44,
"cachedTokens": 0,
"totalCost": 0.00033
}
}
]
File diff suppressed because it is too large Load Diff
+457
View File
@@ -0,0 +1,457 @@
/**
* ATIF Conversion Utilities
*
* Functions for converting between Cline storage format and ATIF trajectory format.
*/
import { Anthropic } from "@anthropic-ai/sdk"
import type { ClineApiReqInfo, ClineMessage } from "../ExtensionMessage"
import type {
ATIF_AGENT_NAME,
ATIF_SCHEMA_VERSION,
ATIFAgentSchema,
ATIFObservationResultSchema,
ATIFStepObject,
ATIFToolCallSchema,
ATIFTrajectory,
} from "./atif"
import {
ClineAssistantThinkingBlock,
type ClineAssistantToolUseBlock,
type ClineContent,
type ClineStorageMessage,
type ClineUserToolResultContentBlock,
} from "./content"
/**
* Enriches API conversation history messages with metrics from UI messages.
* Matches assistant messages to their corresponding api_req_started messages
* to populate the metrics field for ATIF export.
*
* @param apiConversationHistory - The API conversation history messages
* @param clineMessages - The UI messages containing api_req_started with metrics
* @returns A new array of messages with metrics populated
*/
export function enrichMessagesWithMetrics(
apiConversationHistory: ClineStorageMessage[],
clineMessages: ClineMessage[],
): ClineStorageMessage[] {
// Find all api_req_started messages with their metrics
const apiReqMessages = clineMessages
.filter((m) => m.type === "say" && m.say === "api_req_started" && m.text)
.map((m) => {
try {
const info: ClineApiReqInfo = JSON.parse(m.text || "{}")
return {
ts: m.ts,
tokensIn: info.tokensIn,
tokensOut: info.tokensOut,
cacheWrites: info.cacheWrites,
cacheReads: info.cacheReads,
cost: info.cost,
}
} catch {
return null
}
})
.filter((m) => m !== null)
// Create a copy of the conversation history and enrich assistant messages
let apiReqIndex = 0
return apiConversationHistory.map((message) => {
if (message.role === "assistant" && apiReqIndex < apiReqMessages.length) {
const metrics = apiReqMessages[apiReqIndex]
apiReqIndex++
// Only add metrics if we have valid data
if (metrics && typeof metrics.tokensIn === "number" && typeof metrics.tokensOut === "number") {
return {
...message,
metrics: {
promptTokens: metrics.tokensIn,
completionTokens: metrics.tokensOut,
cachedTokens: (metrics.cacheWrites || 0) + (metrics.cacheReads || 0),
totalCost: metrics.cost || 0,
},
}
}
}
return message
})
}
/**
* Options for converting Cline messages to ATIF trajectory
*/
export interface ClineToATIFOptions {
sessionId: string
agentVersion: string
defaultModelName?: string
notes?: string
agentName?: typeof ATIF_AGENT_NAME
schemaVersion?: typeof ATIF_SCHEMA_VERSION
/** Optional: UI messages to extract metrics from */
clineMessages?: ClineMessage[]
}
/**
* Converts an array of ClineStorageMessages to an ATIF trajectory
*/
export function convertClineMessagesToATIF(messages: ClineStorageMessage[], options: ClineToATIFOptions): ATIFTrajectory {
const agent: ATIFAgentSchema = {
name: options.agentName || "cline",
version: options.agentVersion,
model_name: options.defaultModelName,
extra: {},
}
// Enrich messages with metrics if clineMessages are provided
const enrichedMessages = options.clineMessages ? enrichMessagesWithMetrics(messages, options.clineMessages) : messages
// Convert messages to steps
const steps: ATIFStepObject[] = []
let stepId = 1
for (const message of enrichedMessages) {
const step = convertClineMessageToATIFStep(message, stepId)
steps.push(step)
stepId++
}
// Calculate final metrics
const final_metrics = calculateFinalMetrics(steps)
return {
schema_version: options.schemaVersion || "ATIF-v1.3",
session_id: options.sessionId,
agent,
steps,
notes: options.notes,
final_metrics,
extra: {},
}
}
/**
* Converts a single ClineStorageMessage to an ATIF step
*/
export function convertClineMessageToATIFStep(message: ClineStorageMessage, stepId: number): ATIFStepObject {
// Determine source from role
const source = message.role === "assistant" ? "agent" : message.role
// Extract message text
const messageText = extractMessageText(message.content)
// Extract tool calls (for assistant messages)
const tool_calls = message.role === "assistant" ? extractToolCalls(message.content) : undefined
// Extract observations (from tool results in user messages or from message.observation)
// const observation = message.observation || extractObservationFromContent(message.content)
// Build step object
const step: ATIFStepObject = {
step_id: stepId,
timestamp: message.timestamp || new Date().toISOString(),
source,
message: messageText,
// extra: message.extra,
}
// Add agent-specific fields
if (source === "agent") {
if (message.modelInfo?.modelId) {
step.model_name = message.modelInfo.modelId
}
if (message.modelInfo?.reasoningEffort !== undefined) {
step.reasoning_effort = message.modelInfo.reasoningEffort
}
if (message.content && Array.isArray(message.content)) {
const thinkingBlock = message.content.find((b) => b.type === "thinking")
step.reasoning_content = thinkingBlock?.thinking
}
if (tool_calls && tool_calls.length > 0) {
step.tool_calls = tool_calls
}
if (message.metrics) {
step.metrics = {
prompt_tokens: message.metrics.promptTokens || 0,
completion_tokens: message.metrics.completionTokens || 0,
cached_tokens: message.metrics.cachedTokens || 0,
cost_usd: message.metrics.totalCost || 0,
}
}
}
// // Add observation if present
// if (observation && observation.results.length > 0) {
// step.observation = observation
// }
return step
}
/**
* Extracts text content from message content
*/
function extractMessageText(content: string | ClineContent[]): string {
if (typeof content === "string") {
return content
}
// Find the first text block
const textBlock = content.find((block) => block.type === "text")
if (textBlock && "text" in textBlock) {
return textBlock.text
}
return ""
}
/**
* Extracts tool calls from assistant message content
*/
function extractToolCalls(content: string | ClineContent[]): ATIFToolCallSchema[] | undefined {
if (typeof content === "string") {
return undefined
}
const toolCalls: ATIFToolCallSchema[] = []
for (const block of content) {
if (block.type === "tool_use") {
const toolUseBlock = block as ClineAssistantToolUseBlock
toolCalls.push({
tool_call_id: toolUseBlock.id,
function_name: toolUseBlock.name,
arguments: toolUseBlock.input as Record<string, unknown>,
})
}
}
return toolCalls.length > 0 ? toolCalls : undefined
}
/**
* Extracts observation data from message content (tool results)
*/
export function extractObservationFromContent(
content: string | ClineContent[],
): { results: ATIFObservationResultSchema[] } | undefined {
if (typeof content === "string") {
return undefined
}
const results: ATIFObservationResultSchema[] = []
for (const block of content) {
if (block.type === "tool_result") {
const toolResultBlock = block as ClineUserToolResultContentBlock
const resultContent = toolResultBlock.content ? extractToolResultContent(toolResultBlock.content) : ""
results.push({
source_call_id: toolResultBlock.tool_use_id,
content: resultContent,
})
}
}
return results.length > 0 ? { results } : undefined
}
/**
* Extracts text content from tool result content
*/
function extractToolResultContent(content: string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>): string {
if (typeof content === "string") {
return content
}
// Concatenate all text blocks
const textParts: string[] = []
for (const block of content) {
if (block.type === "text") {
textParts.push(block.text)
}
}
return textParts.join("\n")
}
/**
* Calculates aggregate metrics for the entire trajectory
*/
function calculateFinalMetrics(steps: ATIFStepObject[]) {
let total_prompt_tokens = 0
let total_completion_tokens = 0
let total_cached_tokens = 0
let total_cost_usd = 0
for (const step of steps) {
if (step.metrics) {
total_prompt_tokens += step.metrics.prompt_tokens || 0
total_completion_tokens += step.metrics.completion_tokens || 0
total_cached_tokens += step.metrics.cached_tokens || 0
total_cost_usd += step.metrics.cost_usd || 0
}
}
return {
total_prompt_tokens,
total_completion_tokens,
total_cached_tokens,
total_cost_usd,
total_steps: steps.length,
extra: {},
}
}
/**
* Converts an ATIF step back to a ClineStorageMessage
* Useful for importing ATIF trajectories into Cline format
*/
export function convertATIFStepToClineMessage(step: ATIFStepObject): ClineStorageMessage {
// Determine role from source
const role = step.source === "agent" ? "assistant" : "user"
// Build content array
const content: ClineContent[] = []
// Add main message as text block
if (step.message) {
content.push({
type: "text",
text: step.message,
})
}
// Add tool calls (for agent steps)
if (step.tool_calls) {
for (const toolCall of step.tool_calls) {
content.push({
type: "tool_use",
id: toolCall.tool_call_id,
name: toolCall.function_name,
input: toolCall.arguments,
})
}
}
// Add tool results (if observation is present)
if (step.observation) {
for (const result of step.observation.results) {
if (result.source_call_id) {
content.push({
type: "tool_result",
tool_use_id: result.source_call_id,
content: result.content || "",
})
}
}
}
// Build the message
const message: ClineStorageMessage = {
role,
content: content.length === 1 && content[0].type === "text" ? content[0].text : content,
timestamp: step.timestamp,
}
// Add agent-specific fields
if (role === "assistant") {
if (!step.model_name) {
throw new Error("Model name is required for assistant messages")
}
message.modelInfo = {
modelId: step.model_name,
providerId: "unknown", // Provider info not in ATIF
reasoningEffort: step.reasoning_effort,
}
if (step.reasoning_content) {
const thinkingBlock: ClineAssistantThinkingBlock = {
type: "thinking",
thinking: step.reasoning_content,
// TODO: signature should be provided if needed
signature: step.step_id.toString(),
}
if (Array.isArray(message.content)) {
message.content.unshift(thinkingBlock)
} else {
message.content = [thinkingBlock, { type: "text", text: message.content }]
}
}
if (step.metrics) {
message.metrics = {
promptTokens: step.metrics.prompt_tokens || 0,
completionTokens: step.metrics.completion_tokens || 0,
cachedTokens: step.metrics.cached_tokens || 0,
totalCost: step.metrics.cost_usd || 0,
}
}
// message.observation = step.observation
}
return message
}
/**
* Converts an entire ATIF trajectory to an array of ClineStorageMessages
*/
export function convertATIFToClineMessages(trajectory: ATIFTrajectory): ClineStorageMessage[] {
return trajectory.steps.map((step) => convertATIFStepToClineMessage(step))
}
/**
* Validates that a message can be converted to ATIF format
* Returns validation errors if any
*/
export function validateClineMessageForATIF(message: ClineStorageMessage): string[] {
const errors: string[] = []
// Check role
if (!message.role) {
errors.push("Message must have a role")
}
// Check content
if (!message.content) {
errors.push("Message must have content")
}
// Check agent-specific fields are only on assistant messages
if (message.role !== "assistant") {
if (message.modelInfo?.reasoningEffort !== undefined) {
errors.push("reasoning_effort can only be set on assistant messages")
}
// Check for reasoning_content in message.content
if (
Array.isArray(message.content) &&
message.content.some((block: any) => block.type === "reasoning_content" || block.reasoning_content !== undefined)
) {
errors.push("reasoning_content can only be set on assistant messages")
} else if (typeof message.content === "object" && message.content !== null && "reasoning_content" in message.content) {
errors.push("reasoning_content can only be set on assistant messages")
}
if (message.metrics) {
errors.push("metrics can only be set on assistant messages")
}
}
return errors
}
/**
* Serializes an ATIF trajectory to JSON string
*/
export function serializeATIFTrajectory(trajectory: ATIFTrajectory, pretty = true): string {
return JSON.stringify(trajectory, null, pretty ? 2 : 0)
}
/**
* Parses an ATIF trajectory from JSON string
*/
export function parseATIFTrajectory(json: string): ATIFTrajectory {
return JSON.parse(json) as ATIFTrajectory
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Agent Trajectory Interchange Format (ATIF) v1.3 Type Definitions
*
* This file contains TypeScript type definitions for the ATIF specification,
* which provides a standardized format for logging LLM agent interactions.
*
* Specification: https://github.com/harbor-project/rfcs/blob/main/0001-trajectory-format.md
*/
/**
* Root-level ATIF trajectory object
*/
export interface ATIFTrajectory {
schema_version: string // e.g., "ATIF-v1.3"
session_id: string
agent: ATIFAgentSchema
steps: ATIFStepObject[]
notes?: string
final_metrics?: ATIFFinalMetricsSchema
extra?: Record<string, unknown>
}
/**
* Agent configuration schema
*/
export interface ATIFAgentSchema {
name: string // e.g., "cline"
version: string // e.g., "1.0.0"
model_name?: string // Default model for trajectory
extra?: Record<string, unknown>
}
/**
* Aggregate metrics for entire trajectory
*/
export interface ATIFFinalMetricsSchema {
total_prompt_tokens?: number
total_completion_tokens?: number
total_cached_tokens?: number
total_cost_usd?: number
total_steps?: number
extra?: Record<string, unknown>
}
/**
* Individual step in the trajectory
*/
export interface ATIFStepObject {
step_id: number // Ordinal index starting from 1
timestamp?: string // ISO 8601 timestamp
source: "system" | "user" | "agent"
model_name?: string // Only applicable when source is "agent"
reasoning_effort?: string | number // Only applicable when source is "agent"
message: string // Required but can be empty string
reasoning_content?: string // Only applicable when source is "agent"
tool_calls?: ATIFToolCallSchema[] // Only applicable when source is "agent"
observation?: ATIFObservationSchema // Can be present for agent and system steps
metrics?: ATIFMetricsSchema // Only applicable when source is "agent"
extra?: Record<string, unknown>
}
/**
* Tool call schema (function invocation)
*/
export interface ATIFToolCallSchema {
tool_call_id: string
function_name: string
arguments: Record<string, unknown> // Must be valid JSON object, can be empty
}
/**
* Observation schema (environment feedback)
*/
export interface ATIFObservationSchema {
results: ATIFObservationResultSchema[]
}
/**
* Individual observation result
*/
export interface ATIFObservationResultSchema {
source_call_id?: string // Maps to tool_call_id, null for non-tool actions
content?: string // May be omitted when subagent_trajectory_ref is present
subagent_trajectory_ref?: ATIFSubagentTrajectoryRefSchema[]
}
/**
* Subagent trajectory reference
*/
export interface ATIFSubagentTrajectoryRefSchema {
session_id: string
trajectory_path?: string // File path, URL, or database reference
extra?: Record<string, unknown>
}
/**
* Per-step LLM metrics
*/
export interface ATIFMetricsSchema {
prompt_tokens?: number // Total input tokens (includes cached + non-cached)
completion_tokens?: number // Total output tokens
cached_tokens?: number // Subset of prompt_tokens that were cache hits
cost_usd?: number // Monetary cost for this step
completion_token_ids?: number[] // Token IDs for RL training (v1.3)
logprobs?: number[] // Log probabilities for each completion token
extra?: Record<string, unknown> // Provider-specific metrics
}
/**
* Source type for ATIF steps
*/
export type ATIFSourceType = "system" | "user" | "agent"
/**
* Mapping between Cline roles and ATIF sources
*/
export const CLINE_ROLE_TO_ATIF_SOURCE: Record<string, ATIFSourceType> = {
user: "user",
assistant: "agent",
}
/**
* Constants for ATIF format
*/
export const ATIF_SCHEMA_VERSION = "ATIF-v1.3"
export const ATIF_AGENT_NAME = "cline"
+15 -7
View File
@@ -1,13 +1,9 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessageModelInfo, ClineTokenMetrics } from "./metrics"
type ClinePromptInputContent = string
export type ClinePromptInputContent = string
type ClineMessageRole = "user" | "assistant"
export interface ClineMessageModelInfo {
modelId: string
providerId: string
}
export type ClineMessageRole = "user" | "assistant"
export interface ClineReasoningDetailParam {
type: "reasoning.text" | string
@@ -83,6 +79,8 @@ export type ClineContent = ClineUserContent | ClineAssistantContent
* This ensures backward compatibility where the messages were stored in Anthropic format,
* while allowing for additional metadata specific to Cline to avoid unknown fields in Anthropic SDK
* added by ignoring the type checking for those fields.
*
* Extended with ATIF (Agent Trajectory Interchange Format) v1.3 compatible fields.
*/
export interface ClineStorageMessage extends Anthropic.MessageParam {
/**
@@ -97,6 +95,16 @@ export interface ClineStorageMessage extends Anthropic.MessageParam {
* MUST be removed before sending message to any LLM provider.
*/
modelInfo?: ClineMessageModelInfo
/**
* ISO 8601 timestamp indicating when this message occurred
* e.g., "2025-10-16T14:30:00Z"
*/
timestamp?: string
/**
* LLM operational and performance metrics for this message
* Includes token counts, costs.
*/
metrics?: ClineTokenMetrics
}
/**
+58
View File
@@ -0,0 +1,58 @@
/**
* Messages Module - Exports for Cline message types and ATIF trajectory support
*/
// ATIF types
export type {
ATIFAgentSchema,
ATIFFinalMetricsSchema,
ATIFMetricsSchema,
ATIFObservationResultSchema,
ATIFObservationSchema,
ATIFSourceType,
ATIFStepObject,
ATIFSubagentTrajectoryRefSchema,
ATIFToolCallSchema,
ATIFTrajectory,
} from "./atif"
export { ATIF_AGENT_NAME, ATIF_SCHEMA_VERSION, CLINE_ROLE_TO_ATIF_SOURCE } from "./atif"
// ATIF conversion utilities
export type { ClineToATIFOptions } from "./atif-converter"
export {
convertATIFStepToClineMessage,
convertATIFToClineMessages,
convertClineMessagesToATIF,
convertClineMessageToATIFStep,
parseATIFTrajectory,
serializeATIFTrajectory,
validateClineMessageForATIF,
} from "./atif-converter"
// Core content types
export type {
ClineAssistantContent,
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineContent,
ClineDocumentContentBlock,
ClineImageContentBlock,
ClineMessageRole,
ClinePromptInputContent,
ClineReasoningDetailParam,
ClineStorageMessage,
ClineTextContentBlock,
ClineToolResponseContent,
ClineUserContent,
ClineUserToolResultContentBlock,
} from "./content"
export { cleanContentBlock, convertClineStorageToAnthropicMessage, REASONING_DETAILS_PROVIDERS } from "./content"
export type { ClineMessageModelInfo, ClineTokenMetrics } from "./metrics"
// Trajectory management
export {
createTrajectoryBuilder,
readTrajectory,
readTrajectoryFromJSON,
TrajectoryBuilder,
TrajectoryReader,
TrajectoryUpdater,
updateTrajectory,
} from "./trajectory-manager"
+14
View File
@@ -0,0 +1,14 @@
export interface ClineMessageModelInfo {
modelId: string
providerId: string
temperature?: number
maxTokens?: number
reasoningEffort?: string | number
}
export interface ClineTokenMetrics {
promptTokens: number // Total input tokens (includes cached + non-cached)
completionTokens: number // Total output tokens
cachedTokens: number // Subset of prompt_tokens that were cache hits
totalCost: number // Monetary cost for this step
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Trajectory Manager
*
* High-level utilities for managing ATIF trajectories in Cline.
* Provides functions for creating, updating, and exporting trajectories.
*/
import type { ATIFMetricsSchema, ATIFObservationSchema, ATIFStepObject, ATIFTrajectory } from "./atif"
import {
type ClineToATIFOptions,
convertATIFToClineMessages,
convertClineMessagesToATIF,
parseATIFTrajectory,
serializeATIFTrajectory,
} from "./atif-converter"
import type { ClineStorageMessage } from "./content"
/**
* TrajectoryBuilder - Fluent API for building ATIF trajectories
*/
export class TrajectoryBuilder {
private messages: ClineStorageMessage[] = []
private sessionId: string
private agentVersion: string
private defaultModelName?: string
private notes?: string
constructor(sessionId: string, agentVersion: string) {
this.sessionId = sessionId
this.agentVersion = agentVersion
}
/**
* Set the default model name for the trajectory
*/
setDefaultModel(modelName: string): this {
this.defaultModelName = modelName
return this
}
/**
* Add notes to the trajectory
*/
setNotes(notes: string): this {
this.notes = notes
return this
}
/**
* Add a message to the trajectory
*/
addMessage(message: ClineStorageMessage): this {
this.messages.push(message)
return this
}
/**
* Add multiple messages to the trajectory
*/
addMessages(messages: ClineStorageMessage[]): this {
this.messages.push(...messages)
return this
}
/**
* Build the ATIF trajectory
*/
build(): ATIFTrajectory {
const options: ClineToATIFOptions = {
sessionId: this.sessionId,
agentVersion: this.agentVersion,
defaultModelName: this.defaultModelName,
notes: this.notes,
}
return convertClineMessagesToATIF(this.messages, options)
}
/**
* Build and serialize the trajectory to JSON
*/
buildJSON(pretty = true): string {
return serializeATIFTrajectory(this.build(), pretty)
}
}
/**
* TrajectoryUpdater - Utilities for updating existing trajectories
*/
export class TrajectoryUpdater {
private trajectory: ATIFTrajectory
constructor(trajectory: ATIFTrajectory) {
this.trajectory = trajectory
}
/**
* Add a new step to the trajectory
*/
addStep(step: ATIFStepObject): this {
// Ensure step_id is sequential
const lastStepId = this.trajectory.steps[this.trajectory.steps.length - 1]?.step_id || 0
const newStep = { ...step, step_id: lastStepId + 1 }
this.trajectory.steps.push(newStep)
this.updateFinalMetrics()
return this
}
/**
* Update metrics for a specific step
*/
updateStepMetrics(stepId: number, metrics: ATIFMetricsSchema): this {
const step = this.trajectory.steps.find((s) => s.step_id === stepId)
if (step) {
step.metrics = { ...step.metrics, ...metrics }
this.updateFinalMetrics()
}
return this
}
/**
* Add observation to a specific step
*/
addStepObservation(stepId: number, observation: ATIFObservationSchema): this {
const step = this.trajectory.steps.find((s) => s.step_id === stepId)
if (step) {
step.observation = observation
}
return this
}
/**
* Update the trajectory notes
*/
setNotes(notes: string): this {
this.trajectory.notes = notes
return this
}
/**
* Recalculate and update final metrics
*/
private updateFinalMetrics(): void {
let total_prompt_tokens = 0
let total_completion_tokens = 0
let total_cached_tokens = 0
let total_cost_usd = 0
for (const step of this.trajectory.steps) {
if (step.metrics) {
total_prompt_tokens += step.metrics.prompt_tokens || 0
total_completion_tokens += step.metrics.completion_tokens || 0
total_cached_tokens += step.metrics.cached_tokens || 0
total_cost_usd += step.metrics.cost_usd || 0
}
}
this.trajectory.final_metrics = {
total_prompt_tokens,
total_completion_tokens,
total_cached_tokens,
total_cost_usd,
total_steps: this.trajectory.steps.length,
extra: this.trajectory.final_metrics?.extra || {},
}
}
/**
* Get the updated trajectory
*/
getTrajectory(): ATIFTrajectory {
return this.trajectory
}
/**
* Serialize the trajectory to JSON
*/
toJSON(pretty = true): string {
return serializeATIFTrajectory(this.trajectory, pretty)
}
}
/**
* TrajectoryReader - Utilities for reading and querying trajectories
*/
export class TrajectoryReader {
private trajectory: ATIFTrajectory
constructor(trajectory: ATIFTrajectory) {
this.trajectory = trajectory
}
/**
* Load a trajectory from JSON string
*/
static fromJSON(json: string): TrajectoryReader {
return new TrajectoryReader(parseATIFTrajectory(json))
}
/**
* Get the raw trajectory object
*/
getTrajectory(): ATIFTrajectory {
return this.trajectory
}
/**
* Get all steps in the trajectory
*/
getSteps(): ATIFStepObject[] {
return this.trajectory.steps
}
/**
* Get a specific step by ID
*/
getStep(stepId: number): ATIFStepObject | undefined {
return this.trajectory.steps.find((s) => s.step_id === stepId)
}
/**
* Get all steps from a specific source (system/user/agent)
*/
getStepsBySource(source: "system" | "user" | "agent"): ATIFStepObject[] {
return this.trajectory.steps.filter((s) => s.source === source)
}
/**
* Get all agent steps (assistant responses)
*/
getAgentSteps(): ATIFStepObject[] {
return this.getStepsBySource("agent")
}
/**
* Get all user steps
*/
getUserSteps(): ATIFStepObject[] {
return this.getStepsBySource("user")
}
/**
* Get all system steps
*/
getSystemSteps(): ATIFStepObject[] {
return this.getStepsBySource("system")
}
/**
* Get steps that contain tool calls
*/
getStepsWithToolCalls(): ATIFStepObject[] {
return this.trajectory.steps.filter((s) => s.tool_calls && s.tool_calls.length > 0)
}
/**
* Get steps that contain observations
*/
getStepsWithObservations(): ATIFStepObject[] {
return this.trajectory.steps.filter((s) => s.observation && s.observation.results.length > 0)
}
/**
* Get the total cost of the trajectory
*/
getTotalCost(): number {
return this.trajectory.final_metrics?.total_cost_usd || 0
}
/**
* Get the total number of tokens (prompt + completion)
*/
getTotalTokens(): number {
const prompt = this.trajectory.final_metrics?.total_prompt_tokens || 0
const completion = this.trajectory.final_metrics?.total_completion_tokens || 0
return prompt + completion
}
/**
* Get token statistics
*/
getTokenStats(): {
prompt: number
completion: number
cached: number
total: number
} {
return {
prompt: this.trajectory.final_metrics?.total_prompt_tokens || 0,
completion: this.trajectory.final_metrics?.total_completion_tokens || 0,
cached: this.trajectory.final_metrics?.total_cached_tokens || 0,
total: this.getTotalTokens(),
}
}
/**
* Convert trajectory to Cline messages
*/
toClineMessages(): ClineStorageMessage[] {
return convertATIFToClineMessages(this.trajectory)
}
/**
* Get a summary of the trajectory
*/
getSummary(): {
sessionId: string
agentName: string
agentVersion: string
totalSteps: number
userSteps: number
agentSteps: number
systemSteps: number
toolCallCount: number
totalCost: number
totalTokens: number
} {
const agentSteps = this.getAgentSteps()
const toolCallCount = agentSteps.reduce((count, step) => {
return count + (step.tool_calls?.length || 0)
}, 0)
return {
sessionId: this.trajectory.session_id,
agentName: this.trajectory.agent.name,
agentVersion: this.trajectory.agent.version,
totalSteps: this.trajectory.steps.length,
userSteps: this.getUserSteps().length,
agentSteps: agentSteps.length,
systemSteps: this.getSystemSteps().length,
toolCallCount,
totalCost: this.getTotalCost(),
totalTokens: this.getTotalTokens(),
}
}
}
/**
* Helper function to create a new trajectory builder
*/
export function createTrajectoryBuilder(sessionId: string, agentVersion: string): TrajectoryBuilder {
return new TrajectoryBuilder(sessionId, agentVersion)
}
/**
* Helper function to update an existing trajectory
*/
export function updateTrajectory(trajectory: ATIFTrajectory): TrajectoryUpdater {
return new TrajectoryUpdater(trajectory)
}
/**
* Helper function to read a trajectory
*/
export function readTrajectory(trajectory: ATIFTrajectory): TrajectoryReader {
return new TrajectoryReader(trajectory)
}
/**
* Helper function to read a trajectory from JSON
*/
export function readTrajectoryFromJSON(json: string): TrajectoryReader {
return TrajectoryReader.fromJSON(json)
}