mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
feat(antigravity): align tool parsing, MCP attribution, and activity classification (#1620)
* feat(antigravity): align tool parsing, MCP attribution, and activity classification Extends Antigravity tool classification and MCP normalization: - Classify manage_task as planning and search_web/read_url_content as exploration in TASK_TOOLS/SEARCH_TOOLS. - Recognize invoke_subagent in hasAgentSpawn alongside Agent in parser.ts to categorize delegation turns. - Implement and export normalizeAntigravityToolCall to normalize both eager mcp_<server>_<tool> and lazy call_mcp_tool into canonical mcp__<server>__<tool>. - Connect toolDisplayName to normalizeAntigravityToolCall. - Bump ANTIGRAVITY_CACHE_VERSION to 7 and PROVIDER_PARSE_VERSIONS.antigravity to worktree-project-grouping-v7 so existing cached sessions re-parse and gain eager MCP attribution. - Add comprehensive unit tests in classifier and provider suites. * refactor(antigravity): map tool names in the provider, not the shared classifier; daily cache bump --------- Co-authored-by: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
This commit is contained in:
parent
5be059702f
commit
1e83611289
4 changed files with 290 additions and 14 deletions
|
|
@ -243,7 +243,10 @@ import type { DateRange, ProjectSummary } from './types.js'
|
|||
// v47: Copilot chat-session journals read request-level promptTokens, repairing
|
||||
// missing input usage and input-only calls. Re-derive settled days from the
|
||||
// corrected session cache; calls and cost only rise.
|
||||
export const DAILY_CACHE_VERSION = 47
|
||||
// v48: #1620 Antigravity manage_task/search_web/read_url_content/invoke_subagent
|
||||
// reclassify turns. Calls and cost are unchanged, but settled antigravity
|
||||
// category totals need re-derivation.
|
||||
export const DAILY_CACHE_VERSION = 48
|
||||
const MIN_SUPPORTED_VERSION = 28
|
||||
|
||||
/// Providers whose per-day CALL COUNT means something different at
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function conversationRoots(): readonly AntigravityConversationRoot[] {
|
|||
},
|
||||
]
|
||||
}
|
||||
const CACHE_VERSION = 6
|
||||
const CACHE_VERSION = 7
|
||||
export const ANTIGRAVITY_CACHE_VERSION = CACHE_VERSION
|
||||
export const ANTIGRAVITY_LEGACY_CACHE_FILE = 'antigravity-results.json'
|
||||
export function antigravityCacheFileName(version = CACHE_VERSION): string {
|
||||
|
|
@ -829,6 +829,56 @@ function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string {
|
|||
|
||||
const SKILL_MD_PATTERN = /(?:^|[\\/])([^\\/]+)[\\/]SKILL\.md$/i
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
invoke_subagent: 'Agent',
|
||||
manage_task: 'TodoWrite',
|
||||
search_web: 'WebSearch',
|
||||
read_url_content: 'WebFetch',
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Antigravity tool calls to the canonical Codeburn format (`mcp__<server>__<tool>`).
|
||||
*
|
||||
* Antigravity emits MCP tool calls in two distinct shapes:
|
||||
* 1. Eager: `mcp_<server>_<tool>` (single underscore delimiter between prefix, server, and tool).
|
||||
* 2. Lazy: `call_mcp_tool` with JSON arguments `{ ServerName: "...", ToolName: "..." }`.
|
||||
*
|
||||
* Both are normalized to `mcp__<server>__<tool>` so they match `extractMcpTools`,
|
||||
* attribute properly to MCP inventory, and deduplicate cleanly in dashboards.
|
||||
*/
|
||||
export function normalizeAntigravityToolCall(toolName: string, args?: Record<string, unknown> | null): string {
|
||||
if (!toolName) return ''
|
||||
|
||||
if (toolName === 'call_mcp_tool') {
|
||||
const rawServer = args?.['ServerName'] ?? args?.['server_name'] ?? args?.['serverName'] ?? args?.['server'] ?? args?.['Server']
|
||||
const server = typeof rawServer === 'string' ? rawServer.trim() : ''
|
||||
const rawTool = args?.['ToolName'] ?? args?.['tool_name'] ?? args?.['toolName'] ?? args?.['tool'] ?? args?.['Tool']
|
||||
const tool = typeof rawTool === 'string' ? rawTool.trim() : ''
|
||||
if (server && tool) {
|
||||
return `mcp__${server}__${tool}`
|
||||
}
|
||||
return 'call_mcp_tool'
|
||||
}
|
||||
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return toolName
|
||||
}
|
||||
|
||||
if (toolName.startsWith('mcp_')) {
|
||||
const rest = toolName.slice(4)
|
||||
// ponytail: first-underscore split misattributes servers whose names contain '_'; upgrade: longest-prefix match against mcp_config.json server keys
|
||||
const sep = rest.indexOf('_')
|
||||
if (sep > 0 && sep < rest.length - 1) {
|
||||
const server = rest.slice(0, sep)
|
||||
const tool = rest.slice(sep + 1)
|
||||
return `mcp__${server}__${tool}`
|
||||
}
|
||||
return toolName
|
||||
}
|
||||
|
||||
return toolName
|
||||
}
|
||||
|
||||
function extractAntigravityToolFromStep(metadataBytes: Uint8Array, turn: TurnTools): void {
|
||||
const fields = parseProtoFields(metadataBytes)
|
||||
for (const field of fields) {
|
||||
|
|
@ -848,13 +898,7 @@ function extractAntigravityToolFromStep(metadataBytes: Uint8Array, turn: TurnToo
|
|||
}
|
||||
|
||||
if (toolName === 'call_mcp_tool') {
|
||||
const server = typeof args?.['ServerName'] === 'string' ? args['ServerName'].trim() : ''
|
||||
const tool = typeof args?.['ToolName'] === 'string' ? args['ToolName'].trim() : ''
|
||||
if (server && tool) {
|
||||
turn.tools.push(`mcp__${server}__${tool}`)
|
||||
} else {
|
||||
turn.tools.push('call_mcp_tool')
|
||||
}
|
||||
turn.tools.push(normalizeAntigravityToolCall(toolName, args))
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -881,7 +925,7 @@ function extractAntigravityToolFromStep(metadataBytes: Uint8Array, turn: TurnToo
|
|||
}
|
||||
|
||||
if (toolName === 'invoke_subagent') {
|
||||
turn.tools.push(toolName)
|
||||
turn.tools.push(toolNameMap[toolName])
|
||||
if (Array.isArray(args?.['Subagents'])) {
|
||||
for (const sa of args['Subagents']) {
|
||||
if (sa && typeof sa === 'object') {
|
||||
|
|
@ -898,7 +942,7 @@ function extractAntigravityToolFromStep(metadataBytes: Uint8Array, turn: TurnToo
|
|||
continue
|
||||
}
|
||||
|
||||
turn.tools.push(toolName)
|
||||
turn.tools.push(toolNameMap[toolName] ?? normalizeAntigravityToolCall(toolName, args))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1642,7 +1686,7 @@ export function createAntigravityProvider(): Provider {
|
|||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return rawTool
|
||||
return normalizeAntigravityToolCall(rawTool)
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
|
|
|
|||
|
|
@ -565,7 +565,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// token floor on every read, so they must re-parse once for the real dollars
|
||||
// to land.
|
||||
warp: 'worktree-project-grouping-v1-est-cost-billing-cost-v1',
|
||||
antigravity: 'worktree-project-grouping-v6',
|
||||
antigravity: 'worktree-project-grouping-v7',
|
||||
// pr-attribution-v1: the parser now reads the `message`/`part` tables for
|
||||
// per-turn user prompt text and the GitHub PR URLs it references. Cached
|
||||
// ZCode sessions hold empty userMessage turns and no session prLinks, so
|
||||
|
|
|
|||
|
|
@ -18,11 +18,43 @@ import {
|
|||
parseAntigravityServerInfoFromLine,
|
||||
recordAntigravityStatusLinePayload,
|
||||
shouldReparseAntigravitySource,
|
||||
normalizeAntigravityToolCall,
|
||||
} from '../../src/providers/antigravity.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
import { classifyTurn } from '../../src/classifier.js'
|
||||
import type { ParsedApiCall, ParsedTurn } from '../../src/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
// Mirrors the parsed-call -> turn shape src/parser.ts builds (cachedCallToApiCall).
|
||||
function turnFromCall(call: ParsedProviderCall): ParsedTurn {
|
||||
const apiCall: ParsedApiCall = {
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
usage: {
|
||||
inputTokens: call.inputTokens,
|
||||
outputTokens: call.outputTokens,
|
||||
cacheCreationInputTokens: call.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: call.cacheReadInputTokens,
|
||||
cachedInputTokens: call.cachedInputTokens,
|
||||
reasoningTokens: call.reasoningTokens,
|
||||
webSearchRequests: call.webSearchRequests,
|
||||
},
|
||||
costUSD: call.costUSD,
|
||||
tools: call.tools,
|
||||
mcpTools: call.tools.filter(t => t.startsWith('mcp__')),
|
||||
skills: call.skills ?? [],
|
||||
subagentTypes: call.subagentTypes ?? [],
|
||||
hasAgentSpawn: call.tools.includes('Agent'),
|
||||
hasPlanMode: call.tools.includes('EnterPlanMode'),
|
||||
speed: call.speed,
|
||||
timestamp: call.timestamp,
|
||||
bashCommands: call.bashCommands,
|
||||
deduplicationKey: call.deduplicationKey,
|
||||
}
|
||||
return { userMessage: '', assistantCalls: [apiCall], timestamp: call.timestamp, sessionId: call.sessionId }
|
||||
}
|
||||
|
||||
type CurrentCliFixture = {
|
||||
conversationId: string
|
||||
rows: Array<{ idx: number; hex: string }>
|
||||
|
|
@ -802,7 +834,7 @@ describe('antigravity provider helpers', () => {
|
|||
'run_command',
|
||||
'mcp__dart-mcp-server__analyze_files',
|
||||
'view_file',
|
||||
'invoke_subagent',
|
||||
'Agent',
|
||||
'find_by_name',
|
||||
'write_to_file',
|
||||
])
|
||||
|
|
@ -812,6 +844,135 @@ describe('antigravity provider helpers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('normalizes eager mcp_<server>_<tool> calls from steps table', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
await withTempAntigravityHome('codeburn-antigravity-eager-mcp-', async (tempHome) => {
|
||||
const fixture = JSON.parse(await readFile(
|
||||
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
|
||||
'utf-8',
|
||||
)) as CurrentCliFixture
|
||||
|
||||
const conversationsDir = join(tempHome, '.gemini', 'antigravity', 'conversations')
|
||||
await mkdir(conversationsDir, { recursive: true })
|
||||
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
|
||||
const varint = (n: number): number[] => {
|
||||
const out: number[] = []
|
||||
let v = n
|
||||
while (v > 0x7f) { out.push((v & 0x7f) | 0x80); v = Math.floor(v / 128) }
|
||||
out.push(v)
|
||||
return out
|
||||
}
|
||||
const tag = (field: number, wire: number): number[] => varint(field * 8 + wire)
|
||||
const lenField = (field: number, bytes: number[]): number[] => [...tag(field, 2), ...varint(bytes.length), ...bytes]
|
||||
const strField = (field: number, str: string): number[] => lenField(field, Array.from(Buffer.from(str, 'utf-8')))
|
||||
|
||||
const encodeToolStepMetadata = (toolName: string, argsJson?: string): Buffer => {
|
||||
const toolCallSub = [
|
||||
...strField(1, 'call_test_123'),
|
||||
...strField(2, toolName),
|
||||
...(argsJson !== undefined ? strField(3, argsJson) : []),
|
||||
]
|
||||
return Buffer.from(lenField(4, toolCallSub))
|
||||
}
|
||||
|
||||
const withStepIndices = (fixtureHex: string, indices: number[]): Buffer => {
|
||||
const rest = Buffer.from(fixtureHex, 'hex').subarray(4)
|
||||
const packed = Buffer.from(indices.flatMap(n => varint(n)))
|
||||
const field2 = Buffer.from([...tag(2, 2), ...varint(packed.length), ...packed])
|
||||
return Buffer.concat([field2, rest])
|
||||
}
|
||||
|
||||
createCurrentAntigravityCliDb(dbPath, fixture)
|
||||
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath) as TestDb
|
||||
try {
|
||||
db.prepare('UPDATE gen_metadata SET data = ? WHERE idx = 0').run(
|
||||
withStepIndices(fixture.rows[0]!.hex, [1, 2]),
|
||||
)
|
||||
db.exec('CREATE TABLE steps (idx integer PRIMARY KEY, step_type integer, metadata blob)')
|
||||
const stmt = db.prepare('INSERT INTO steps (idx, step_type, metadata) VALUES (?, ?, ?)')
|
||||
stmt.run(0, 15, null)
|
||||
stmt.run(1, 38, encodeToolStepMetadata('mcp_context7_resolve-library-id'))
|
||||
stmt.run(2, 38, encodeToolStepMetadata('call_mcp_tool', JSON.stringify({ ServerName: 'context7', ToolName: 'resolve-library-id' })))
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const calls = await collectAntigravityCalls({ path: dbPath, project: 'antigravity', provider: 'antigravity' })
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls[0]!.tools).toEqual([
|
||||
'mcp__context7__resolve-library-id',
|
||||
'mcp__context7__resolve-library-id',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('maps manage_task, search_web, read_url_content and invoke_subagent to canonical tools that classify', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
await withTempAntigravityHome('codeburn-antigravity-tool-map-', async (tempHome) => {
|
||||
const fixture = JSON.parse(await readFile(
|
||||
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
|
||||
'utf-8',
|
||||
)) as CurrentCliFixture
|
||||
|
||||
const varint = (n: number): number[] => {
|
||||
const out: number[] = []
|
||||
let v = n
|
||||
while (v > 0x7f) { out.push((v & 0x7f) | 0x80); v = Math.floor(v / 128) }
|
||||
out.push(v)
|
||||
return out
|
||||
}
|
||||
const tag = (field: number, wire: number): number[] => varint(field * 8 + wire)
|
||||
const lenField = (field: number, bytes: number[]): number[] => [...tag(field, 2), ...varint(bytes.length), ...bytes]
|
||||
const strField = (field: number, str: string): number[] => lenField(field, Array.from(Buffer.from(str, 'utf-8')))
|
||||
const encodeToolStepMetadata = (toolName: string, argsJson: string): Buffer =>
|
||||
Buffer.from(lenField(4, [...strField(1, 'call_test_123'), ...strField(2, toolName), ...strField(3, argsJson)]))
|
||||
const withStepIndices = (fixtureHex: string, indices: number[]): Buffer => {
|
||||
const rest = Buffer.from(fixtureHex, 'hex').subarray(4)
|
||||
const packed = Buffer.from(indices.flatMap(n => varint(n)))
|
||||
return Buffer.concat([Buffer.from([...tag(2, 2), ...varint(packed.length), ...packed]), rest])
|
||||
}
|
||||
|
||||
const callWithTools = async (name: string, toolNames: string[]): Promise<ParsedProviderCall> => {
|
||||
const dir = join(tempHome, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const dbPath = join(dir, `${fixture.conversationId}.db`)
|
||||
createCurrentAntigravityCliDb(dbPath, fixture)
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath) as TestDb
|
||||
try {
|
||||
db.prepare('UPDATE gen_metadata SET data = ? WHERE idx = 0').run(
|
||||
withStepIndices(fixture.rows[0]!.hex, toolNames.map((_, i) => i + 1)),
|
||||
)
|
||||
db.exec('CREATE TABLE steps (idx integer PRIMARY KEY, step_type integer, metadata blob)')
|
||||
const stmt = db.prepare('INSERT INTO steps (idx, step_type, metadata) VALUES (?, ?, ?)')
|
||||
stmt.run(0, 15, null)
|
||||
toolNames.forEach((tool, i) => stmt.run(i + 1, 38, encodeToolStepMetadata(tool, '{}')))
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
const calls = await collectAntigravityCalls({ path: dbPath, project: 'antigravity', provider: 'antigravity' })
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
return calls[0]!
|
||||
}
|
||||
|
||||
const planning = await callWithTools('planning', ['manage_task'])
|
||||
expect(planning.tools).toEqual(['TodoWrite'])
|
||||
expect(classifyTurn(turnFromCall(planning)).category).toBe('planning')
|
||||
|
||||
const exploration = await callWithTools('exploration', ['search_web', 'read_url_content'])
|
||||
expect(exploration.tools).toEqual(['WebSearch', 'WebFetch'])
|
||||
expect(classifyTurn(turnFromCall(exploration)).category).toBe('exploration')
|
||||
|
||||
const delegation = await callWithTools('delegation', ['invoke_subagent', 'search_web'])
|
||||
expect(delegation.tools).toEqual(['Agent', 'WebSearch'])
|
||||
expect(classifyTurn(turnFromCall(delegation)).category).toBe('delegation')
|
||||
})
|
||||
})
|
||||
|
||||
it('gracefully handles missing steps table in legacy DB', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
|
|
@ -1109,3 +1270,71 @@ describe('antigravity provider helpers', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('normalizeAntigravityToolCall', () => {
|
||||
it('formats call_mcp_tool with PascalCase ServerName and ToolName as mcp__<server>__<tool>', () => {
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', { ServerName: 'context7', ToolName: 'resolve-library-id' }))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
})
|
||||
|
||||
it('formats call_mcp_tool with snake_case keys', () => {
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', { server_name: 'context7', tool_name: 'resolve-library-id' }))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
})
|
||||
|
||||
it('falls back to call_mcp_tool when args are missing or empty', () => {
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', null)).toBe('call_mcp_tool')
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', {})).toBe('call_mcp_tool')
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', { ServerName: '' })).toBe('call_mcp_tool')
|
||||
})
|
||||
|
||||
it('normalizes eager mcp_<server>_<tool> into mcp__<server>__<tool>', () => {
|
||||
expect(normalizeAntigravityToolCall('mcp_context7_resolve-library-id'))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
expect(normalizeAntigravityToolCall('mcp_dart-mcp-server_analyze_files'))
|
||||
.toBe('mcp__dart-mcp-server__analyze_files')
|
||||
})
|
||||
|
||||
it('preserves already canonical mcp__<server>__<tool>', () => {
|
||||
expect(normalizeAntigravityToolCall('mcp__context7__resolve-library-id'))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
})
|
||||
|
||||
it('leaves non-mcp tools unchanged', () => {
|
||||
expect(normalizeAntigravityToolCall('run_command')).toBe('run_command')
|
||||
expect(normalizeAntigravityToolCall('view_file')).toBe('view_file')
|
||||
expect(normalizeAntigravityToolCall('manage_task')).toBe('manage_task')
|
||||
expect(normalizeAntigravityToolCall('search_web')).toBe('search_web')
|
||||
})
|
||||
|
||||
it('splits eager mcp names at the first underscore, misattributing servers with underscores', () => {
|
||||
expect(normalizeAntigravityToolCall('mcp_github_mcp_server_create_issue'))
|
||||
.toBe('mcp__github__mcp_server_create_issue')
|
||||
})
|
||||
|
||||
it('leaves incomplete mcp prefixes unchanged', () => {
|
||||
expect(normalizeAntigravityToolCall('mcp_')).toBe('mcp_')
|
||||
expect(normalizeAntigravityToolCall('mcp_noservertool')).toBe('mcp_noservertool')
|
||||
})
|
||||
it('formats call_mcp_tool with camelCase keys', () => {
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', { serverName: 'context7', toolName: 'resolve-library-id' }))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
})
|
||||
|
||||
it('formats call_mcp_tool with short server/tool keys', () => {
|
||||
expect(normalizeAntigravityToolCall('call_mcp_tool', { server: 'context7', tool: 'resolve-library-id' }))
|
||||
.toBe('mcp__context7__resolve-library-id')
|
||||
})
|
||||
|
||||
it('handles empty or falsy toolName safely', () => {
|
||||
expect(normalizeAntigravityToolCall('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('antigravity provider toolDisplayName', () => {
|
||||
it('normalizes eager and canonical MCP tools to canonical display names', () => {
|
||||
const provider = createAntigravityProvider()
|
||||
expect(provider.toolDisplayName('mcp_context7_resolve-library-id')).toBe('mcp__context7__resolve-library-id')
|
||||
expect(provider.toolDisplayName('mcp__context7__resolve-library-id')).toBe('mcp__context7__resolve-library-id')
|
||||
expect(provider.toolDisplayName('run_command')).toBe('run_command')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue