Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/agent/core/domain/agent-events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ export interface AgentEventMap {
callId?: string
error?: string
errorType?: ToolErrorType
lifecycleResult?: unknown
metadata?: Record<string, unknown>
result?: unknown
sessionId: string
Expand Down Expand Up @@ -787,6 +788,7 @@ export interface SessionEventMap {
callId?: string
error?: string
errorType?: ToolErrorType
lifecycleResult?: unknown
metadata?: Record<string, unknown>
result?: unknown
success: boolean
Expand Down
8 changes: 7 additions & 1 deletion src/agent/infra/agent/cipher-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -893,8 +893,14 @@ export class CipherAgent extends BaseAgent implements ICipherAgent {
const data = payload as {sessionId?: string}
if (data.sessionId !== sessionId) return

const streamData = eventName === 'llmservice:toolResult'
? (({lifecycleResult: _lifecycleResult, ...publicData}) => publicData)(
data as {lifecycleResult?: unknown; sessionId?: string},
)
: data

// Add event to queue with name discriminant
eventQueue.push({name: eventName, ...data} as StreamingEvent)
eventQueue.push({name: eventName, ...streamData} as StreamingEvent)

// Close iterator on run:complete
if (eventName === 'run:complete') {
Expand Down
16 changes: 16 additions & 0 deletions src/agent/infra/llm/agent-llm-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ export function buildDateTimePrefix(now: Date = new Date()): string {
return `<dateTime>Current date and time: ${now.toISOString()}</dateTime>\n\n`
}

/**
* Select the bounded curation result carried to lifecycle hooks before display truncation.
* code_exec intentionally exposes only its curate accumulator, never stdout, locals, or return values.
*/
export function selectLifecycleResult(toolName: string, result: unknown, commandType?: string): unknown {
if (commandType !== 'curate') return undefined
if (toolName === 'curate') return result
if (toolName !== 'code_exec' || !result || typeof result !== 'object' || Array.isArray(result)) return undefined

const resultRecord = result as Record<string, unknown>
if (!Object.hasOwn(resultRecord, 'curateResults')) return undefined
return {curateResults: resultRecord.curateResults}
}

/**
* Result of parallel tool execution (before adding to context).
* Contains all information needed to add the result to context in order.
Expand Down Expand Up @@ -1171,6 +1185,7 @@ export class AgentLLMService implements ILLMService {

// Process output (truncation and file saving if needed, with per-command overrides)
const processedOutput = await this.outputProcessor.processStructuredOutput(toolName, result.content, executionContext?.commandType)
const lifecycleResult = selectLifecycleResult(toolName, result.content, executionContext?.commandType)

// Emit truncation event if output was truncated
if (processedOutput.metadata?.truncated) {
Expand All @@ -1191,6 +1206,7 @@ export class AgentLLMService implements ILLMService {
...result.metadata,
...processedOutput.metadata,
},
...(lifecycleResult === undefined ? {} : {lifecycleResult}),
result: processedOutput.content,
success: result.success,
taskId: taskId || undefined,
Expand Down
3 changes: 2 additions & 1 deletion src/agent/infra/tools/implementations/code-exec-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export function createCodeExecTool(sandboxService: ISandboxService): Tool {
}

return {
...(result.curateResults === undefined ? {} : {curateResults: result.curateResults}),
executionTime: result.executionTime,
finalResult: result.finalResult,
locals: result.locals,
Expand All @@ -146,7 +147,7 @@ export function createCodeExecTool(sandboxService: ISandboxService): Tool {
}

return {
...(result.curateResults ? {curateResults: result.curateResults} : {}),
...(result.curateResults === undefined ? {} : {curateResults: result.curateResults}),
executionTime: result.executionTime,
finalResult: result.finalResult,
locals: result.locals,
Expand Down
7 changes: 7 additions & 0 deletions src/server/core/domain/entities/curate-log-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export type CurateLogSummary = {
updated: number
}

export type ReviewIntegrity = {
reason?: string
status: 'unresolved' | 'verified'
}

/**
* Curate-side latency tiers . All optional for back-compat with
* pre-telemetry entries. No `searchMs` — curate has no BM25 search phase.
Expand Down Expand Up @@ -71,6 +76,8 @@ type CurateLogBase = {
operations: CurateLogOperation[]
/** Tokens emitted for the completion across all curate sub-phases. */
outputTokens?: number
/** Whether the structured curation operation channel was validated for this run. */
reviewIntegrity?: ReviewIntegrity
startedAt: number
summary: CurateLogSummary
taskId: string
Expand Down
2 changes: 2 additions & 0 deletions src/server/core/domain/transport/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const ToolResultPayloadSchema = z.object({
callId: z.string().optional(),
error: z.string().optional(),
errorType: ToolErrorTypeSchema.optional(),
lifecycleResult: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
result: z.unknown().optional(),
sessionId: z.string(),
Expand Down Expand Up @@ -746,6 +747,7 @@ export const LlmToolResultEventSchema = z.object({
callId: z.string().optional(),
error: z.string().optional(),
errorType: ToolErrorTypeSchema.optional(),
lifecycleResult: z.unknown().optional(),
metadata: z.record(z.unknown()).optional(),
result: z.unknown().optional(),
sessionId: z.string(),
Expand Down
41 changes: 36 additions & 5 deletions src/server/infra/process/curate-log-handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {CurateLogEntry, CurateLogOperation, CurateLogSummary, CurateLogTiming, CurateUsageRecord} from '../../core/domain/entities/curate-log-entry.js'
import type {CurateLogEntry, CurateLogOperation, CurateLogSummary, CurateLogTiming, CurateUsageRecord, ReviewIntegrity} from '../../core/domain/entities/curate-log-entry.js'
import type {LlmToolResultEvent} from '../../core/domain/transport/schemas.js'
import type {TaskInfo} from '../../core/domain/transport/task-info.js'
import type {ITaskLifecycleHook} from '../../core/interfaces/process/i-task-lifecycle-hook.js'
import type {ICurateLogStore} from '../../core/interfaces/storage/i-curate-log-store.js'

import {extractCurateOperations} from '../../utils/curate-result-parser.js'
import {extractCurateOperationCapture} from '../../utils/curate-result-parser.js'
import {getProjectDataDir} from '../../utils/path-utils.js'
import {transportLog} from '../../utils/process-logger.js'
import {FileCurateLogStore} from '../storage/file-curate-log-store.js'
Expand All @@ -26,6 +26,8 @@ type TaskState = {
* daemon stamps once at the task-create boundary.
*/
reviewDisabled: boolean
reviewIntegrity: ReviewIntegrity
reviewIntegrityFailed: boolean
/** Telemetry from the executor . Set by `setCurateUsage`. */
usage?: CurateUsageRecord
}
Expand Down Expand Up @@ -175,6 +177,7 @@ export class CurateLogHandler implements ITaskLifecycleHook {
...telemetryFields(state.usage),
completedAt: Date.now(),
operations: state.operations,
reviewIntegrity: state.reviewIntegrity,
status: 'cancelled',
summary: computeSummary(state.operations),
}
Expand All @@ -198,6 +201,7 @@ export class CurateLogHandler implements ITaskLifecycleHook {
completedAt: Date.now(),
operations: state.operations,
response: result || undefined,
reviewIntegrity: state.reviewIntegrity,
status: 'completed',
summary: computeSummary(state.operations),
}
Expand Down Expand Up @@ -237,6 +241,7 @@ export class CurateLogHandler implements ITaskLifecycleHook {
...(task.folderPath ? {folders: [task.folderPath]} : {}),
},
operations: [],
reviewIntegrity: {reason: 'No valid curation operation channel captured', status: 'unresolved'},
startedAt: task.createdAt,
status: 'processing',
summary: {added: 0, deleted: 0, failed: 0, merged: 0, updated: 0},
Expand All @@ -248,7 +253,14 @@ export class CurateLogHandler implements ITaskLifecycleHook {
// without a getById round-trip — so completion is never lost even if this initial
// save fails.
const reviewDisabled = task.reviewDisabled ?? false
this.tasks.set(task.taskId, {entry, operations: [], projectPath: task.projectPath, reviewDisabled})
this.tasks.set(task.taskId, {
entry,
operations: [],
projectPath: task.projectPath,
reviewDisabled,
reviewIntegrity: entry.reviewIntegrity!,
reviewIntegrityFailed: false,
})
this.activeTaskCount.set(task.projectPath, (this.activeTaskCount.get(task.projectPath) ?? 0) + 1)

// Fire-and-forget: logId is already known, save is best-effort.
Expand Down Expand Up @@ -277,6 +289,7 @@ export class CurateLogHandler implements ITaskLifecycleHook {
completedAt: Date.now(),
error: errorMessage,
operations: state.operations,
reviewIntegrity: state.reviewIntegrity,
status: 'error',
summary: computeSummary(state.operations),
...telemetryFields(state.usage),
Expand All @@ -293,8 +306,15 @@ export class CurateLogHandler implements ITaskLifecycleHook {
const state = this.tasks.get(taskId)
if (!state) return

const ops = extractCurateOperations(payload)
for (const op of ops) {
const capture = extractCurateOperationCapture(payload)
if (capture.reviewIntegrity?.status === 'unresolved') {
state.reviewIntegrity = capture.reviewIntegrity
state.reviewIntegrityFailed = true
} else if (capture.reviewIntegrity?.status === 'verified' && !state.reviewIntegrityFailed) {
state.reviewIntegrity = capture.reviewIntegrity
}

for (const op of capture.operations) {
if (op.needsReview && op.status === 'success' && !state.reviewDisabled) {
op.reviewStatus = 'pending'
}
Expand All @@ -311,6 +331,17 @@ export class CurateLogHandler implements ITaskLifecycleHook {

state.operations.push(op)
}

if (
!state.reviewDisabled
&& capture.operations.some((op) => op.needsReview === true && op.status === 'success' && op.reviewStatus !== 'pending')
) {
state.reviewIntegrity = {
reason: 'Successful review-required operation was not marked pending',
status: 'unresolved',
}
state.reviewIntegrityFailed = true
}
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/server/infra/process/task-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1803,6 +1803,7 @@ export class TaskRouter {
*/
private routeLlmEvent(eventName: string, data: {[key: string]: unknown; taskId: string}): void {
const {taskId, ...rest} = data
Reflect.deleteProperty(rest, 'lifecycleResult')
const activeTask = this.tasks.get(taskId)
const task = activeTask ?? this.completedTasks.get(taskId)?.task

Expand All @@ -1815,7 +1816,7 @@ export class TaskRouter {
// Only mutates for ACTIVE tasks — grace-period entries already had their
// terminal save persisted by the lifecycle hook.
if (activeTask) {
this.accumulateLlmEvent(taskId, eventName, data)
this.accumulateLlmEvent(taskId, eventName, {taskId, ...rest})
}

// Notify onToolResult hooks only for active tasks
Expand Down
6 changes: 6 additions & 0 deletions src/server/infra/storage/file-curate-log-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ const CurateLogSummaryFileSchema = z.object({
updated: z.number(),
})

const ReviewIntegrityFileSchema = z.object({
reason: z.string().optional(),
status: z.enum(['unresolved', 'verified']),
})

const CurateLogTimingFileSchema = z.object({
llmMs: z.number().optional(),
totalMs: z.number().optional(),
Expand All @@ -52,6 +57,7 @@ const CurateLogEntryBaseSchema = z.object({
inputTokens: z.number().optional(),
operations: z.array(CurateLogOperationFileSchema),
outputTokens: z.number().optional(),
reviewIntegrity: ReviewIntegrityFileSchema.optional(),
startedAt: z.number(),
summary: CurateLogSummaryFileSchema,
taskId: z.string(),
Expand Down
78 changes: 75 additions & 3 deletions src/server/utils/curate-result-parser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {z} from 'zod'

import type {CurateLogOperation} from '../core/domain/entities/curate-log-entry.js'
import type {CurateLogOperation, ReviewIntegrity} from '../core/domain/entities/curate-log-entry.js'
import type {LlmToolResultEvent} from '../core/domain/transport/schemas.js'

// ── Zod schemas ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -34,6 +34,36 @@ export const CurateResultSchema = z.object({
.optional(),
})

const CapturedCurateOperationSchema = CurateOperationSchema.extend({
confidence: z.enum(['high', 'low']),
impact: z.enum(['high', 'low']),
needsReview: z.boolean(),
reason: z.string(),
}).superRefine((operation, context) => {
if (operation.status !== 'success') return
const policyRequiresReview = operation.type === 'DELETE' || operation.impact === 'high'
if (operation.needsReview !== policyRequiresReview) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: 'needsReview contradicts ByteRover 3.16.1 review policy',
path: ['needsReview'],
})
}
})

const CapturedCurateResultSchema = z.object({
applied: z.array(CapturedCurateOperationSchema),
})

const CapturedCodeExecResultSchema = z.object({
curateResults: z.array(CapturedCurateResultSchema).min(1),
})

export type CurateOperationCapture = {
operations: CurateLogOperation[]
reviewIntegrity?: ReviewIntegrity
}

// ── Internal helpers ──────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -114,10 +144,15 @@ export function extractCurateResultFromCodeExec(resultData: Record<string, unkno
* @param filter - Optional predicate to filter individual operations (e.g. success-only)
*/
export function extractCurateOperations(
payload: Pick<LlmToolResultEvent, 'result' | 'toolName'>,
payload: Pick<LlmToolResultEvent, 'lifecycleResult' | 'result' | 'toolName'>,
filter?: (op: CurateLogOperation) => boolean,
): CurateLogOperation[] {
const {result: rawPayload, toolName} = payload
const {lifecycleResult, result: rawPayload, toolName} = payload

if (lifecycleResult !== undefined) {
const capture = extractCurateOperationCapture(payload)
return filter ? capture.operations.filter((op) => filter(op)) : capture.operations
}

// ToolOutputProcessor always stringifies tool output — parse if string
let result: unknown = rawPayload
Expand All @@ -144,3 +179,40 @@ export function extractCurateOperations(
const ops: CurateLogOperation[] = parsed.data.applied
return filter ? ops.filter((op) => filter(op)) : ops
}

/** Validate the internal, pre-truncation curation operation channel. */
export function extractCurateOperationCapture(
payload: Pick<LlmToolResultEvent, 'lifecycleResult' | 'toolName'>,
): CurateOperationCapture {
const {lifecycleResult, toolName} = payload

if (toolName === 'curate') {
const parsed = CapturedCurateResultSchema.safeParse(lifecycleResult)
if (!parsed.success) {
return {
operations: [],
reviewIntegrity: {reason: 'Invalid direct curate lifecycle result', status: 'unresolved'},
}
}

return {operations: parsed.data.applied, reviewIntegrity: {status: 'verified'}}
}

if (toolName === 'code_exec') {
if (lifecycleResult === undefined) return {operations: []}
const parsed = CapturedCodeExecResultSchema.safeParse(lifecycleResult)
if (!parsed.success) {
return {
operations: [],
reviewIntegrity: {reason: 'Invalid code_exec curate lifecycle result', status: 'unresolved'},
}
}

return {
operations: parsed.data.curateResults.flatMap((curateResult) => curateResult.applied),
reviewIntegrity: {status: 'verified'},
}
}

return {operations: []}
}
Loading