From 78eb0c98ce2d1fe9f51a24f404fbd39093c7bb56 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 13 Aug 2026 19:29:42 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=AD=20refactor:=20Resolve=20Activity?= =?UTF-8?q?=20Phase=20Position=20Once=20Per=20Boundary=20(#14807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(api): make anchor construction exhaustive Bounded anchors were built in two places, each spreading one side and hand-picking the rest, so any field nobody named was dropped silently and nothing failed until a boundary landed badly. That already cost agentId and then unresolvedToolStartIndex in consecutive review rounds, and mergedAgentIds was never added to the demotion path at all. Both constructors now assign an AnchorFields literal, mapped over keyof Required, so adding a field to TrackedActivity is a type error at both sites until its anchor semantics are decided. No behavior change: every field resolves to what the hand-picked versions already produced. Adds a folding property over count, failure count, agent attribution, and ordering. * refactor(api): resolve activity position once per boundary Positional fields on TrackedActivity are captured at different times against an array that keeps moving, so each was a cache that could go stale, collide, or be truncated — and closesBeforeBoundary read three of them directly. Roughly two thirds of the review findings on #14785 were that pattern: a proxy outranking, outliving, or standing in for the rendered position. resolvePosition now folds tool indices, the prior partition floor, the unmaterialized fallback, and reasoning anchors into one value, and closesBeforeBoundary takes only that value. A caller cannot reach past it to a raw field, and the four branches the predicate used to carry collapse into one comparison: an activity closes early exactly when nothing locates it beyond the boundary. Resolution also decides when the saved fallback has gone stale, so the stripping that kept it out of snapshots is now a property of the resolved value rather than a separate step. No behavior change; the existing boundary and straddle regressions cover both directions. --- .../api/src/agents/activityPhases/runtime.ts | 218 ++++++++++++++---- 1 file changed, 172 insertions(+), 46 deletions(-) diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index b3acca5a7f..37f2708da5 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -447,28 +447,168 @@ interface PhasePartition { } /** - * Whether an activity belongs to the phase closing at `boundary`. Shared by - * the boundary partition and the completion boundary so the two cannot drift - * on what counts as work lying after a given index. + * Where an activity sits in the live content, resolved once. Positional fields + * on `TrackedActivity` are captured at different times against an array that + * keeps moving — parts materialize after their hooks fire, label reservations + * shift indices, persistence compacts, resume prepends — so each is an input + * here rather than something a boundary consults directly. */ -function closesBeforeBoundary( +interface ResolvedPosition { + /** Materialized indices of this activity's tool calls, ascending. */ + toolIndices: number[]; + /** Where the activity begins once anchors and prior floors are applied. */ + startsAt: number; + /** The highest index this activity is known to reach, including a saved + * fallback for calls that have not materialized yet. `undefined` when + * nothing in the live content locates it at all. */ + endsAt?: number; + /** Whether the saved fallback is still doing work. Once every tracked call + * has materialized it is stale, and persisting it would hold the activity + * past a boundary its real position precedes after a resume. */ + awaitingTools: boolean; +} + +function resolvePosition( parts: ReadonlyArray, activity: TrackedActivity, - boundary: number | undefined, - toolIndices?: number[], -): boolean { +): ResolvedPosition { + const toolIndices = findTrackedToolIndices(parts, activity); + const toolStart = toolIndices[0]; + /** A saved fallback outlives its purpose once every tracked call has + * materialized; keeping it would hold the activity past a boundary its + * real position precedes. */ + const trackedToolCount = activity.toolCallIds?.length ?? 0; + const awaitingTools = trackedToolCount === 0 || toolIndices.length < trackedToolCount; + const fallback = awaitingTools ? activity.unresolvedToolStartIndex : undefined; + if (toolStart != null) { + return { + toolIndices, + awaitingTools, + startsAt: Math.max(toolStart, activity.partitionStartIndex ?? 0), + endsAt: Math.max(toolIndices[toolIndices.length - 1], fallback ?? Number.NEGATIVE_INFINITY), + }; + } + /** Anchors carry only stable evidence and must be re-located against the + * current content; a full activity keeps the index maintained live, which + * repeated reasoning must not drag back across a prior phase. */ + const startsAt = + activity.bounded === true ? findTrackedStart(parts, activity) : activity.startIndex; + const materializedStart = findMaterializedActivityStart(parts, activity, toolIndices); + const located = + fallback != null ? Math.max(fallback, materializedStart ?? fallback) : materializedStart; + return { toolIndices, awaitingTools, startsAt, ...(located != null && { endsAt: located }) }; +} + +/** + * Whether an activity belongs to the phase closing at `boundary`. Takes only a + * resolved position, so no caller can reach past it to a raw field: an + * activity closes early exactly when nothing locates it beyond the boundary. + * A completed batch straddling the boundary therefore stays whole on the later + * side rather than leaving one tool call outside its own parent. + */ +function closesBeforeBoundary(position: ResolvedPosition, boundary: number | undefined): boolean { if (boundary == null) { return true; } - if (activity.unresolvedToolStartIndex != null && activity.unresolvedToolStartIndex >= boundary) { - return false; + return position.endsAt == null || position.endsAt < boundary; +} + +/** + * Every field of a bounded anchor, stated explicitly. Anchors are built by + * demoting an activity and by folding two together, and both used to spread + * one side and hand-pick the rest — so any field nobody named was dropped + * silently, and nothing failed until a boundary happened to land badly. + * Mapping over `keyof Required` makes every field mandatory + * at the construction site: adding one to `TrackedActivity` is a type error + * until its anchor semantics are decided here. + */ +type AnchorFields = { [K in keyof Required]: TrackedActivity[K] }; + +function laterDefinedIndex(earlier?: number, later?: number): number | undefined { + if (earlier == null) { + return later; } - const materializedToolIndices = toolIndices ?? findTrackedToolIndices(parts, activity); - if (materializedToolIndices.length > 0) { - return materializedToolIndices.every((index) => index < boundary); - } - const materializedStart = findMaterializedActivityStart(parts, activity, materializedToolIndices); - return materializedStart == null || materializedStart < boundary; + return later == null ? earlier : Math.max(earlier, later); +} + +function foldedAgentIds(earlier: TrackedActivity, later: TrackedActivity): string[] | undefined { + const folded = [ + ...new Set( + [ + ...(earlier.mergedAgentIds ?? []), + ...(earlier.agentId != null ? [earlier.agentId] : []), + ...(later.mergedAgentIds ?? []), + ...(later.agentId != null ? [later.agentId] : []), + ].filter((id) => id !== earlier.agentId), + ), + ]; + return folded.length > 0 ? folded : undefined; +} + +/** Strips prompt evidence while keeping everything a boundary reasons about. */ +function boundedAnchor(activity: TrackedActivity): TrackedActivity { + const fields: AnchorFields = { + startIndex: activity.startIndex, + bounded: true, + status: activity.status, + partitionStartIndex: activity.partitionStartIndex, + unresolvedToolStartIndex: activity.unresolvedToolStartIndex, + toolCallIds: activity.toolCallIds?.slice(-MAX_RETAINED_TOOL_ENTRIES), + thinkingExcerpts: activity.thinkingExcerpts + ?.slice(-MAX_RETAINED_TOOL_ENTRIES) + .map((text) => text.slice(-REASONING_ANCHOR_CHARS)), + agentId: activity.agentId, + mergedCount: activity.mergedCount, + mergedFailedCount: activity.mergedFailedCount, + mergedPartialCount: activity.mergedPartialCount, + mergedAgentIds: activity.mergedAgentIds, + /** An anchor is never summarized directly, so prompt evidence and the + * child-label slot are deliberately not carried. */ + label: undefined, + entries: undefined, + childLabelIndex: undefined, + }; + return fields; +} + +/** Folds `later` into `earlier`, which keeps its position. */ +function mergeAnchors(earlier: TrackedActivity, later: TrackedActivity): TrackedActivity { + const mergedFailedCount = countFailedActivities([earlier, later]); + const mergedPartialCount = countPartialActivities([earlier, later]); + const excerpts = + earlier.thinkingExcerpts != null || later.thinkingExcerpts != null + ? [...(earlier.thinkingExcerpts ?? []), ...(later.thinkingExcerpts ?? [])].slice( + -MAX_RETAINED_TOOL_ENTRIES, + ) + : undefined; + const fields: AnchorFields = { + /** The pair starts where its first activity did; the later side's floor + * describes a position being absorbed and must not move the survivor. */ + startIndex: earlier.startIndex, + partitionStartIndex: earlier.partitionStartIndex, + bounded: true, + status: earlier.status, + /** The later side may still be waiting on a tool call. Dropping its + * fallback lets a boundary close the whole merged count on the earlier + * side; resolution clears it once every retained id materializes. */ + unresolvedToolStartIndex: laterDefinedIndex( + earlier.unresolvedToolStartIndex, + later.unresolvedToolStartIndex, + ), + toolCallIds: [...(earlier.toolCallIds ?? []), ...(later.toolCallIds ?? [])].slice( + -MAX_RETAINED_TOOL_ENTRIES, + ), + thinkingExcerpts: excerpts, + agentId: earlier.agentId, + mergedCount: (earlier.mergedCount ?? 1) + (later.mergedCount ?? 1), + mergedFailedCount: mergedFailedCount > 0 ? mergedFailedCount : undefined, + mergedPartialCount: mergedPartialCount > 0 ? mergedPartialCount : undefined, + mergedAgentIds: foldedAgentIds(earlier, later), + label: undefined, + entries: undefined, + childLabelIndex: undefined, + }; + return fields; } /** @@ -1083,41 +1223,23 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity * where a retained straddling batch reanchors — so the shared content * array is walked once per activity and the result carried through. */ const resolved = activities.map((activity) => { - const toolIndices = findTrackedToolIndices(currentParts, activity); - const toolStart = toolIndices[0]; - if (toolStart != null) { - /** The saved fallback outlives its purpose once every tracked call has - * materialized. Keeping it would reject the activity at any boundary - * below that stale index even though its real position is earlier. */ - const fullyMaterialized = - (activity.toolCallIds?.length ?? 0) > 0 && - toolIndices.length >= (activity.toolCallIds?.length ?? 0); - const { unresolvedToolStartIndex, ...rest } = activity; - return { - activity: { - ...rest, - ...(!fullyMaterialized && - unresolvedToolStartIndex != null && { unresolvedToolStartIndex }), - startIndex: Math.max(toolStart, activity.partitionStartIndex ?? 0), - }, - toolIndices, - }; - } - /** Anchors carry only stable evidence and must be re-located against the - * current content; a full activity keeps the index maintained live, - * which repeated reasoning must not drag back across a prior phase. */ + const position = resolvePosition(currentParts, activity); + const { unresolvedToolStartIndex, ...rest } = activity; return { - activity: - activity.bounded === true - ? { ...activity, startIndex: findTrackedStart(currentParts, activity) } - : activity, - toolIndices, + activity: { + ...rest, + ...(position.awaitingTools && + unresolvedToolStartIndex != null && { unresolvedToolStartIndex }), + startIndex: position.startsAt, + }, + toolIndices: position.toolIndices, + position, }; }); const closingActivities: TrackedActivity[] = []; const retainedActivities: TrackedActivity[] = []; - for (const { activity, toolIndices } of resolved) { - if (closesBeforeBoundary(currentParts, activity, requestedEndIndex, toolIndices)) { + for (const { activity, toolIndices, position } of resolved) { + if (closesBeforeBoundary(position, requestedEndIndex)) { closingActivities.push(activity); continue; } @@ -1592,7 +1714,11 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity return undefined; } const boundary = candidate; - if (activities.some((activity) => !closesBeforeBoundary(parts, activity, boundary))) { + if ( + activities.some( + (activity) => !closesBeforeBoundary(resolvePosition(parts, activity), boundary), + ) + ) { return undefined; } for (const reasoning of pendingReasoning.values()) {