🧷 fix: Keep Group Identity Stable and Memoize Label Endpoint Resolution

Group remount. Tool-group identity was keyed on the first part in the
block. An activity label absorbs the block's leading THINK part the moment
its text lands, so the key flipped from tool:<id> to fallback:<scope>:<idx>
mid-run, remounting the group and discarding whatever the user had
expanded. The key now scans for the first tool call, which does not move
when the block re-forms.

Label endpoint resolution is memoized per response. It reads provider
config and can hit the database for user keys, yet nothing it depends on
changes between batches of one run — and it ran twice per batch, once for
generation and once for usage accounting. The promise is cached rather
than the value so concurrent batches share a single in-flight resolution,
and a rejection is evicted so one transient credential failure cannot
disable labels for the rest of the response.
This commit is contained in:
Danny Avila 2026-07-26 23:09:19 -04:00
parent d89f8368e8
commit 5c60f77bfb
2 changed files with 33 additions and 13 deletions

View file

@ -358,16 +358,30 @@ class AgentClient extends BaseClient {
* endpoint named by `activityEndpoint` (default: the agent's).
*/
async resolveActivityLabelLLM() {
return resolveActivityLabelModel({
req: this.options.req,
agent: this.options.agent,
ids: {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
db: { getUserKey: db.getUserKey, getUserKeyValues: db.getUserKeyValues },
});
/** Memoized per response: resolution reads provider config and can hit the
* database for user keys, and nothing it depends on changes between
* batches of the same run so re-resolving on every batch (twice, with
* usage accounting) is repeated credential work for an identical result.
* The promise is cached rather than the value so concurrent batches share
* one in-flight resolution. */
this.activityLabelLLMPromise =
this.activityLabelLLMPromise ??
resolveActivityLabelModel({
req: this.options.req,
agent: this.options.agent,
ids: {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
},
db: { getUserKey: db.getUserKey, getUserKeyValues: db.getUserKeyValues },
}).catch((error) => {
/** Never cache a rejection: a transient credential read failure would
* otherwise disable labels for the rest of the response. */
this.activityLabelLLMPromise = null;
throw error;
});
return this.activityLabelLLMPromise;
}
/**

View file

@ -30,9 +30,15 @@ const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string =
if (!firstPart) {
return 'empty';
}
const toolCallId = getToolCallId(firstPart.part);
if (toolCallId) {
return `tool:${toolCallId}`;
/** Keyed on the first TOOL CALL, not the first part. An activity label
* absorbs the block's leading THINK part when its text lands, so keying on
* `parts[0]` would flip the key mid-run remounting the group and losing
* whatever the user had expanded. The tool calls themselves do not move. */
for (const { part } of parts) {
const toolCallId = getToolCallId(part);
if (toolCallId) {
return `tool:${toolCallId}`;
}
}
return `fallback:${fallbackScope}:${firstPart.idx}`;
};