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
113 changes: 113 additions & 0 deletions src/acp/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
export const MAX_JSON_PAYLOAD_BYTES = 1024 * 1024
export const MAX_JSON_NESTING_DEPTH = 10

export type JsonPayloadBudgetErrorData = {
kind: 'payload_too_large' | 'payload_too_deep' | 'payload_not_serializable'
retryable: false
label: string
sizeBytes?: number
maxBytes?: number
depth?: number
maxDepth?: number
}

export class JsonPayloadBudgetError extends Error {
readonly code = -32602
readonly data: JsonPayloadBudgetErrorData

constructor(message: string, data: JsonPayloadBudgetErrorData) {
super(message)
this.name = 'JsonPayloadBudgetError'
this.data = data
}
}

function getJsonNestingDepth(
value: unknown,
seen: WeakSet<object> = new WeakSet(),
): number {
if (value === null || typeof value !== 'object') return 0

if (seen.has(value)) {
throw new JsonPayloadBudgetError('JSON payload is not serializable', {
kind: 'payload_not_serializable',
retryable: false,
label: 'payload',
})
}
seen.add(value)

try {
const children = Array.isArray(value)
? value
: Object.values(value as Record<string, unknown>)

if (children.length === 0) return 1
return (
1 + Math.max(...children.map(child => getJsonNestingDepth(child, seen)))
)
} finally {
seen.delete(value)
}
}

export function getJsonPayloadBudget(value: unknown): {
sizeBytes: number
depth: number
} {
let serialized: string
try {
serialized = JSON.stringify(value) ?? 'null'
} catch {
throw new JsonPayloadBudgetError('JSON payload is not serializable', {
kind: 'payload_not_serializable',
retryable: false,
label: 'payload',
})
}

return {
sizeBytes: Buffer.byteLength(serialized, 'utf8'),
depth: getJsonNestingDepth(value),
}
}

export function assertJsonPayloadBudget(
value: unknown,
options?: {
label?: string
maxBytes?: number
maxDepth?: number
},
): void {
const label = options?.label ?? 'payload'
const maxBytes = options?.maxBytes ?? MAX_JSON_PAYLOAD_BYTES
const maxDepth = options?.maxDepth ?? MAX_JSON_NESTING_DEPTH
const budget = getJsonPayloadBudget(value)

if (budget.sizeBytes > maxBytes) {
throw new JsonPayloadBudgetError(
`${label} exceeds maximum serialized size of ${maxBytes} bytes`,
{
kind: 'payload_too_large',
retryable: false,
label,
sizeBytes: budget.sizeBytes,
maxBytes,
},
)
}

if (budget.depth > maxDepth) {
throw new JsonPayloadBudgetError(
`${label} exceeds maximum nesting depth of ${maxDepth}`,
{
kind: 'payload_too_deep',
retryable: false,
label,
depth: budget.depth,
maxDepth,
},
)
}
}
24 changes: 15 additions & 9 deletions src/app/binaryFeedback.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { TextBlock, ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs'
import type {
ContentBlock,
TextBlock,
ToolUseBlock,
} from '@anthropic-ai/sdk/resources/index.mjs'
import type { AssistantMessage, BinaryFeedbackResult } from './query'
import { isEqual, zip } from 'lodash-es'

Expand Down Expand Up @@ -30,23 +34,25 @@ function textContentBlocksEqual(cb1: TextBlock, cb2: TextBlock): boolean {
return cb1.text === cb2.text
}

function contentBlocksEqual(
cb1: TextBlock | ToolUseBlock,
cb2: TextBlock | ToolUseBlock,
): boolean {
function contentBlocksEqual(cb1: ContentBlock, cb2: ContentBlock): boolean {
if (cb1.type !== cb2.type) {
return false
}
if (cb1.type === 'text') {
return textContentBlocksEqual(cb1, cb2 as TextBlock)
}
cb2 = cb2 as ToolUseBlock
return cb1.name === cb2.name && isEqual(cb1.input, cb2.input)
if (cb1.type === 'tool_use') {
const toolUseBlock = cb2 as ToolUseBlock
return (
cb1.name === toolUseBlock.name && isEqual(cb1.input, toolUseBlock.input)
)
}
return isEqual(cb1, cb2)
}

function allContentBlocksEqual(
content1: (TextBlock | ToolUseBlock)[],
content2: (TextBlock | ToolUseBlock)[],
content1: ContentBlock[],
content2: ContentBlock[],
): boolean {
if (content1.length !== content2.length) {
return false
Expand Down
13 changes: 2 additions & 11 deletions src/commands/agents/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import type { AgentConfig } from '@utils/agent/loader'
import { debug as debugLogger } from '@utils/log/debugLogger'
import { logError } from '@utils/log'
import { extractTextFromContent } from '@utils/ai/anthropic'

export type GeneratedAgent = {
identifier: string
Expand Down Expand Up @@ -33,17 +34,7 @@ Make the agent highly specialized and effective for the described use case.`
] as any
const response = await queryModel('main', messages, [systemPrompt])

let responseText = ''
if (typeof response.message?.content === 'string') {
responseText = response.message.content
} else if (Array.isArray(response.message?.content)) {
const textContent = response.message.content.find(
(c: any) => c.type === 'text',
)
responseText = textContent?.text || ''
} else if (response.message?.content?.[0]?.text) {
responseText = response.message.content[0].text
}
const responseText = extractTextFromContent(response.message?.content) ?? ''

if (!responseText) {
throw new Error('No text content in model response')
Expand Down
8 changes: 3 additions & 5 deletions src/commands/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getCodeStyle } from '@utils/config/style'
import { clearTerminal } from '@utils/terminal'
import { resetReminderSession } from '@services/systemReminder'
import { resetFileFreshnessSession } from '@services/fileFreshness'
import { createAnthropicUsage } from '@utils/ai/anthropic'

const COMPRESSION_PROMPT = `Please provide a comprehensive summary of our conversation structured as follows:

Expand Down Expand Up @@ -88,12 +89,9 @@ const compact = {
throw new Error(summary)
}

summaryResponse.message.usage = {
input_tokens: 0,
summaryResponse.message.usage = createAnthropicUsage({
output_tokens: summaryResponse.message.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
})

await clearTerminal()
getMessagesSetter()([])
Expand Down
3 changes: 0 additions & 3 deletions src/entrypoints/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,5 @@ initSentry()
ensurePackagedRuntimeEnv()
ensureYogaWasmPath(import.meta.url)

import * as dontcare from '@anthropic-ai/sdk/shims/node'
Object.keys(dontcare)

installProcessHandlers()
void runCli()
5 changes: 5 additions & 0 deletions src/entrypoints/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Command } from '@commands'
import review from '@commands/review'
import { lastX } from '@utils/text/generators'
import { MACRO } from '@constants/macros'
import { assertJsonPayloadBudget } from '../acp/validation'
type ToolInput = Record<string, unknown>

const state: {
Expand Down Expand Up @@ -74,6 +75,10 @@ export async function startMCPServer(cwd: string): Promise<void> {
}

try {
assertJsonPayloadBudget(args ?? {}, {
label: `MCP tool ${name} arguments`,
})

if (!(await tool.isEnabled())) {
throw new Error(`Tool ${name} is not enabled`)
}
Expand Down
12 changes: 10 additions & 2 deletions src/services/ai/adapters/responsesStreaming.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StreamingEvent } from './base'
import { AssistantMessage } from '@query'
import { setRequestStatus } from '@utils/session/requestStatus'
import { createAnthropicUsage } from '@utils/ai/anthropic'

export async function processResponsesStream(
stream: AsyncGenerator<StreamingEvent>,
Expand Down Expand Up @@ -74,9 +75,16 @@ export async function processResponsesStream(
const assistantMessage: AssistantMessage = {
type: 'assistant',
message: {
id: responseId,
container: null,
model: '<responses-stream>',
role: 'assistant',
content: contentBlocks,
usage: {
stop_details: null,
stop_reason: 'end_turn',
stop_sequence: null,
type: 'message',
usage: createAnthropicUsage({
input_tokens: usage.prompt_tokens ?? 0,
output_tokens: usage.completion_tokens ?? 0,
prompt_tokens: usage.prompt_tokens ?? 0,
Expand All @@ -85,7 +93,7 @@ export async function processResponsesStream(
usage.totalTokens ??
(usage.prompt_tokens || 0) + (usage.completion_tokens || 0),
reasoningTokens: usage.reasoningTokens,
},
}),
},
costUSD: 0,
durationMs: Date.now() - startTime,
Expand Down
54 changes: 23 additions & 31 deletions src/services/ai/llm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import '@anthropic-ai/sdk/shims/node'
import Anthropic, { APIConnectionError, APIError } from '@anthropic-ai/sdk'
import { StreamingEvent } from './adapters/base'
import { AnthropicBedrock } from '@anthropic-ai/bedrock-sdk'
Expand Down Expand Up @@ -74,6 +73,10 @@ import {
NO_CONTENT_MESSAGE,
PROMPT_TOO_LONG_ERROR_MESSAGE,
} from './llmConstants'
import {
createAnthropicUsage,
normalizeAnthropicUsage,
} from '@utils/ai/anthropic'

function isGPT5Model(modelName: string): boolean {
return modelName.startsWith('gpt-5')
Expand Down Expand Up @@ -613,6 +616,7 @@ function convertOpenAIResponseToAnthropic(
input: toolArgs,
name: toolName,
id: toolCall.id?.length > 0 ? toolCall.id : nanoid(),
caller: { type: 'direct' },
})
}
}
Expand Down Expand Up @@ -1417,13 +1421,15 @@ async function queryAnthropicNative(
return {
type: 'text' as const,
text: block.text,
citations: block.citations ?? null,
}
} else if (block.type === 'tool_use') {
return {
type: 'tool_use' as const,
id: block.id,
name: block.name,
input: block.input,
caller: block.caller,
}
}
return block
Expand All @@ -1432,9 +1438,11 @@ async function queryAnthropicNative(
const assistantMessage: AssistantMessage = {
message: {
id: response.id,
container: (response as any).container ?? null,
content,
model: response.model,
role: 'assistant',
stop_details: (response as any).stop_details ?? null,
stop_reason: response.stop_reason,
stop_sequence: response.stop_sequence,
type: 'message',
Expand Down Expand Up @@ -1909,16 +1917,27 @@ function buildAssistantMessageFromUnifiedResponse(
input: toolArgs,
name: toolName,
id: toolCall.id?.length > 0 ? toolCall.id : nanoid(),
caller: { type: 'direct' },
})
}
}

return {
type: 'assistant',
message: {
id:
unifiedResponse.responseId ??
unifiedResponse.id ??
`resp_${Date.now()}`,
container: null,
model: unifiedResponse.model ?? '<unified>',
role: 'assistant',
content: contentBlocks,
usage: {
stop_details: null,
stop_reason: unifiedResponse.stopReason ?? 'end_turn',
stop_sequence: null,
type: 'message',
usage: createAnthropicUsage({
input_tokens:
unifiedResponse.usage?.promptTokens ??
unifiedResponse.usage?.input_tokens ??
Expand Down Expand Up @@ -1951,7 +1970,7 @@ function buildAssistantMessageFromUnifiedResponse(
(unifiedResponse.usage?.completionTokens ??
unifiedResponse.usage?.output_tokens ??
0),
},
}),
},
costUSD: 0,
durationMs: Date.now() - startTime,
Expand All @@ -1961,34 +1980,7 @@ function buildAssistantMessageFromUnifiedResponse(
}

function normalizeUsage(usage?: any) {
if (!usage) {
return {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
}
}

const inputTokens =
usage.input_tokens ?? usage.prompt_tokens ?? usage.inputTokens ?? 0
const outputTokens =
usage.output_tokens ?? usage.completion_tokens ?? usage.outputTokens ?? 0
const cacheReadInputTokens =
usage.cache_read_input_tokens ??
usage.prompt_token_details?.cached_tokens ??
usage.cacheReadInputTokens ??
0
const cacheCreationInputTokens =
usage.cache_creation_input_tokens ?? usage.cacheCreatedInputTokens ?? 0

return {
...usage,
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_read_input_tokens: cacheReadInputTokens,
cache_creation_input_tokens: cacheCreationInputTokens,
}
return normalizeAnthropicUsage(usage)
}

function getModelInputTokenCostUSD(model: string): number {
Expand Down
Loading
Loading