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
21 changes: 20 additions & 1 deletion src/api/routers/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../db/repositories/runsRepository.js';
import { publishCancelCommand } from '../../queue/cancel.js';
import { isAnalysisRunning } from '../../triggers/shared/debug-status.js';
import { parseLlmResponse } from '../../utils/llmResponseParser.js';
import { logger } from '../../utils/logging.js';
import { protectedProcedure, router, superAdminProcedure } from '../trpc.js';
import { verifyProjectOrgAccess } from './_shared/projectAccess.js';
Expand Down Expand Up @@ -116,7 +117,25 @@ export const runsRouter = router({
if (!ctx.effectiveOrgId) throw new TRPCError({ code: 'UNAUTHORIZED' });
await verifyProjectOrgAccess(run.projectId, ctx.effectiveOrgId);
}
return listLlmCallsMeta(input.runId);
const raw = await listLlmCallsMeta(input.runId);
const calls = raw.map((c) => {
const { toolNames, textPreview } = parseLlmResponse(c.response);
return {
id: c.id,
runId: c.runId,
callNumber: c.callNumber,
inputTokens: c.inputTokens,
outputTokens: c.outputTokens,
cachedTokens: c.cachedTokens,
costUsd: c.costUsd,
durationMs: c.durationMs,
model: c.model,
createdAt: c.createdAt,
toolNames,
textPreview,
};
});
return { engine: run.engine ?? 'unknown', calls };
}),

getLlmCall: protectedProcedure
Expand Down
1 change: 1 addition & 0 deletions src/db/repositories/llmCallsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function listLlmCallsMeta(runId: string) {
durationMs: agentRunLlmCalls.durationMs,
model: agentRunLlmCalls.model,
createdAt: agentRunLlmCalls.createdAt,
response: agentRunLlmCalls.response,
})
.from(agentRunLlmCalls)
.where(eq(agentRunLlmCalls.runId, runId))
Expand Down
290 changes: 290 additions & 0 deletions src/utils/llmResponseParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/**
* Normalizes LLM call response payloads from different engines into a canonical structure.
*
* Engine formats:
* - Claude Code / OpenCode: JSON array of content blocks [{type, ...}]
* - Codex: JSON object {turn, text?, tools?: string[], usage?}
* - LLMist: raw text with !!!GADGET_START:ToolName / !!!GADGET_END markup
*
* NOTE: This file is mirrored at web/src/lib/llm-response-parser.ts. Keep both in sync.
*/

export type ParsedBlock =
| { kind: 'text'; text: string }
| { kind: 'tool_use'; name: string; inputSummary: string }
| { kind: 'thinking'; text: string };

export interface ParsedLlmResponse {
blocks: ParsedBlock[];
/**
* Tool names in invocation order, one entry per call (NOT deduplicated).
* Callers that need unique names should use `new Set(toolNames)`.
* Includes duplicates so that ×N badge counts reflect actual call frequency.
*/
toolNames: string[];
/** First text block truncated to ~120 chars, empty string if none */
textPreview: string;
}

const GADGET_START = '!!!GADGET_START:';
const GADGET_END = '!!!GADGET_END';
const GADGET_ARG = '!!!ARG:';

// Args to prefer when building an inputSummary for LLMist gadget calls
const PRIORITY_ARGS = [
'command',
'filename',
'path',
'file_path',
'workItemId',
'query',
'comment',
];

/** Accumulator for one in-progress LLMist gadget call */
interface GadgetAccum {
name: string;
args: Record<string, string>;
currentArgKey: string | null;
currentArgLines: string[];
}

/** Mutable state threaded through the LLMist line-by-line parser */
interface LlmistState {
blocks: ParsedBlock[];
toolNames: string[];
current: GadgetAccum | null;
preGadgetLines: string[];
foundFirstGadget: boolean;
textPreview: string;
}

/** Truncate a string to maxLen chars, appending `…` if truncated */
function truncate(s: string, maxLen: number): string {
return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s;
}

/** Flush the current in-progress arg value into accum.args */
function flushGadgetArg(accum: GadgetAccum): void {
if (accum.currentArgKey !== null) {
accum.args[accum.currentArgKey] = accum.currentArgLines.join('\n').trim();
accum.currentArgKey = null;
accum.currentArgLines = [];
}
}

/** Finalize a completed gadget call and push it onto the output arrays */
function finalizeGadget(accum: GadgetAccum, blocks: ParsedBlock[], toolNames: string[]): void {
flushGadgetArg(accum);
const { name, args } = accum;

// Build inputSummary: scan priority args first, then fall back to first arg
let inputSummary = '';
for (const key of PRIORITY_ARGS) {
const val = args[key];
if (typeof val === 'string' && val) {
inputSummary = truncate(val.replace(/\n/g, ' ').trim(), 100);
break;
}
}
if (!inputSummary) {
const firstVal = Object.values(args)[0];
if (firstVal) inputSummary = truncate(firstVal.replace(/\n/g, ' ').trim(), 80);
}

blocks.push({ kind: 'tool_use', name, inputSummary });
toolNames.push(name); // one entry per invocation, not deduplicated
}

function handleGadgetStart(line: string, s: LlmistState): void {
if (!s.foundFirstGadget) {
s.foundFirstGadget = true;
const pre = s.preGadgetLines.join('\n').trim();
if (pre) {
s.blocks.push({ kind: 'text', text: pre });
s.textPreview = truncate(pre, 120);
}
}
if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames);
const name = line.slice(GADGET_START.length).split(':')[0].trim();
s.current = { name, args: {}, currentArgKey: null, currentArgLines: [] };
}

function handleGadgetEnd(s: LlmistState): void {
if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames);
s.current = null;
}

function handleGadgetArg(line: string, s: LlmistState): void {
if (!s.current) return;
flushGadgetArg(s.current);
s.current.currentArgKey = line.slice(GADGET_ARG.length).trim();
s.current.currentArgLines = [];
}

function processLlmistLine(line: string, s: LlmistState): void {
if (line.startsWith(GADGET_START)) {
handleGadgetStart(line, s);
} else if (line.startsWith(GADGET_END)) {
handleGadgetEnd(s);
} else if (line.startsWith(GADGET_ARG)) {
handleGadgetArg(line, s);
} else if (s.current !== null && s.current.currentArgKey !== null) {
s.current.currentArgLines.push(line);
} else if (!s.foundFirstGadget) {
s.preGadgetLines.push(line);
}
}

/**
* Build a human-readable summary of a tool's input object.
* Prefers well-known field names over raw JSON stringification.
*/
function summarizeInput(name: string, input: unknown): string {
if (!input || typeof input !== 'object') return '';
const obj = input as Record<string, unknown>;

switch (name) {
case 'Read':
case 'Write':
case 'Edit':
if (typeof obj.file_path === 'string') return truncate(obj.file_path, 100);
break;
case 'Glob':
case 'Grep':
if (typeof obj.pattern === 'string') return truncate(obj.pattern, 100);
break;
case 'Bash':
if (typeof obj.command === 'string')
return truncate(obj.command.replace(/\n/g, ' ').trim(), 100);
break;
case 'WebFetch':
if (typeof obj.url === 'string') return truncate(obj.url, 100);
break;
case 'WebSearch':
if (typeof obj.query === 'string') return truncate(obj.query, 100);
break;
}

try {
return truncate(JSON.stringify(input), 80);
} catch {
return '';
}
}

/** Process one Claude Code content block; returns a textPreview candidate or null */
function processClaudeBlock(
block: Record<string, unknown>,
blocks: ParsedBlock[],
toolNames: string[],
): string | null {
if (block.type === 'text' && typeof block.text === 'string') {
blocks.push({ kind: 'text', text: block.text });
return block.text;
}
if (block.type === 'tool_use' && typeof block.name === 'string') {
const name = block.name;
blocks.push({ kind: 'tool_use', name, inputSummary: summarizeInput(name, block.input) });
toolNames.push(name); // one entry per invocation, not deduplicated
} else if (block.type === 'thinking' && typeof block.thinking === 'string') {
blocks.push({ kind: 'thinking', text: block.thinking });
}
return null;
}

/** Parse LLMist format: raw text with !!!GADGET_START:Name / !!!ARG:key / !!!GADGET_END markers */
function parseLlmistResponse(rawResponse: string): ParsedLlmResponse {
const s: LlmistState = {
blocks: [],
toolNames: [],
current: null,
preGadgetLines: [],
foundFirstGadget: false,
textPreview: '',
};

for (const line of rawResponse.split('\n')) {
processLlmistLine(line, s);
}
if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames);

return { blocks: s.blocks, toolNames: s.toolNames, textPreview: s.textPreview };
}

/** Parse Claude Code / OpenCode format: JSON array of typed content blocks */
function parseClaudeCodeBlocks(parsed: unknown[]): ParsedLlmResponse {
const blocks: ParsedBlock[] = [];
const toolNames: string[] = [];
let textPreview = '';

for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const candidate = processClaudeBlock(item as Record<string, unknown>, blocks, toolNames);
if (candidate !== null && !textPreview) {
textPreview = truncate(candidate, 120);
}
}

return { blocks, toolNames, textPreview };
}

/** Parse Codex format: {turn, text?, tools?: string[], usage?} */
function parseCodexPayload(parsed: Record<string, unknown>): ParsedLlmResponse {
const blocks: ParsedBlock[] = [];
const toolNames: string[] = [];
let textPreview = '';

if (typeof parsed.text === 'string' && parsed.text) {
blocks.push({ kind: 'text', text: parsed.text });
textPreview = truncate(parsed.text, 120);
}

if (Array.isArray(parsed.tools)) {
for (const name of parsed.tools) {
if (typeof name === 'string') {
blocks.push({ kind: 'tool_use', name, inputSummary: '' });
toolNames.push(name);
}
}
}

return { blocks, toolNames, textPreview };
}

export function parseLlmResponse(rawResponse: string | null | undefined): ParsedLlmResponse {
if (!rawResponse) return { blocks: [], toolNames: [], textPreview: '' };

// LLMist: raw text with gadget call markup
if (rawResponse.includes(GADGET_START)) {
return parseLlmistResponse(rawResponse);
}

let parsed: unknown;
try {
parsed = JSON.parse(rawResponse);
} catch {
// Unparseable — treat as raw text
return {
blocks: [{ kind: 'text', text: rawResponse }],
toolNames: [],
textPreview: truncate(rawResponse, 120),
};
}

// Claude Code / OpenCode: array of content blocks
if (Array.isArray(parsed)) {
return parseClaudeCodeBlocks(parsed);
}

// Codex: object with tools array and/or text
if (
parsed !== null &&
typeof parsed === 'object' &&
('tools' in (parsed as object) || 'text' in (parsed as object))
) {
return parseCodexPayload(parsed as Record<string, unknown>);
}

return { blocks: [], toolNames: [], textPreview: '' };
}
7 changes: 4 additions & 3 deletions tests/integration/db/runsRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ describe('runsRepository (integration)', () => {
});

describe('listLlmCallsMeta', () => {
it('returns calls metadata without request/response bodies', async () => {
it('returns calls metadata with response but without request body', async () => {
const id = await createRun({
projectId: 'test-project',
agentType: 'implementation',
Expand All @@ -343,9 +343,10 @@ describe('runsRepository (integration)', () => {
const meta = await listLlmCallsMeta(id);
expect(meta).toHaveLength(1);
expect(meta[0].inputTokens).toBe(100);
// listLlmCallsMeta does not return request/response
// listLlmCallsMeta excludes the large request body but includes
// the response so the router can extract toolNames/textPreview
expect('request' in meta[0]).toBe(false);
expect('response' in meta[0]).toBe(false);
expect(meta[0].response).toBe('big response body');
});
});

Expand Down
Loading
Loading