mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
fix(chat): classify pre-tool narration by observation count, not iteration budget
This commit is contained in:
parent
48a7b979df
commit
30560eb7cf
@ -254,7 +254,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
output.state().value(NEEDS_TOOL_CALL, false),
|
||||
output.state().value(CURRENT_ITERATION, 0),
|
||||
output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
|
||||
@ -426,7 +426,7 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
lastEmittedStreamedContent.set(streamed);
|
||||
addWithKindEvent(deltas, streamedContentDelta(isFinalAnswerTurn,
|
||||
output.state().value(NEEDS_TOOL_CALL, false),
|
||||
output.state().value(CURRENT_ITERATION, 0),
|
||||
output.state().value(TOOL_CALL_COUNT, 0),
|
||||
streamed));
|
||||
}
|
||||
|
||||
@ -648,15 +648,23 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
* {@link ContentKind}: the graph is the only layer that definitively knows
|
||||
* whether the completion carried tool calls ({@code NEEDS_TOOL_CALL}) and
|
||||
* whether any tool observation preceded the text this turn
|
||||
* ({@code CURRENT_ITERATION} — ObservationNode increments it after each
|
||||
* observed round, so iteration 0 means "no observation yet"). Downstream
|
||||
* consumers read the tag instead of re-deriving it from stream structure.
|
||||
* ({@code TOOL_CALL_COUNT} — ObservationNode adds each round's observed
|
||||
* results to it, so 0 means "no observation yet"). Downstream consumers
|
||||
* read the tag instead of re-deriving it from stream structure.
|
||||
*
|
||||
* <p>The observation signal MUST be the observation counter, not
|
||||
* {@code CURRENT_ITERATION}: the latter is an iteration <em>budget</em>
|
||||
* counter that ObservationNode refunds for progressive-disclosure rounds
|
||||
* (load_skill / enable_tool) and GoalEvaluationNode resets to 0 on a hard
|
||||
* continuation. Either path leaves the budget at 0 after real observations
|
||||
* already landed, which tagged grounded narration as provisional and made
|
||||
* renderers collapse it.
|
||||
*
|
||||
* <ul>
|
||||
* <li>terminal turn → {@code FINAL_ANSWER};</li>
|
||||
* <li>completion carries tool calls and no observation happened yet this
|
||||
* turn → {@code PRE_TOOL_NARRATION} (provisional, may be replaced by
|
||||
* the turn's next content);</li>
|
||||
* <li>completion carries tool calls and no tool observation happened yet
|
||||
* this turn → {@code PRE_TOOL_NARRATION} (provisional, may be
|
||||
* replaced by the turn's next content);</li>
|
||||
* <li>otherwise → {@code GROUNDED_NARRATION} (follows an observation, or
|
||||
* closed its completion without tool calls — never replaced).</li>
|
||||
* </ul>
|
||||
@ -685,11 +693,11 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
||||
}
|
||||
|
||||
static AgentService.StreamDelta streamedContentDelta(boolean isFinalAnswerTurn, boolean carriesToolCalls,
|
||||
int iteration, String streamed) {
|
||||
int observationCount, String streamed) {
|
||||
if (isFinalAnswerTurn) {
|
||||
return AgentService.StreamDelta.persistOnly(streamed, null, ContentKind.FINAL_ANSWER);
|
||||
}
|
||||
ContentKind kind = carriesToolCalls && iteration == 0
|
||||
ContentKind kind = carriesToolCalls && observationCount == 0
|
||||
? ContentKind.PRE_TOOL_NARRATION
|
||||
: ContentKind.GROUNDED_NARRATION;
|
||||
return AgentService.StreamDelta.segmentOnly(streamed, null, kind);
|
||||
|
||||
@ -5,6 +5,9 @@ import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.ContentKind;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
@ -28,16 +31,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* graph knows definitively whether the completion carried tool calls and whether
|
||||
* any observation preceded the text this turn, so downstream consumers read the
|
||||
* tag instead of re-deriving the category from stream structure.
|
||||
*
|
||||
* <p>The observation signal is the {@code TOOL_CALL_COUNT} observation counter,
|
||||
* NOT the {@code CURRENT_ITERATION} budget counter — see
|
||||
* {@code observationsWithoutIterationBudget_taggedGrounded} for why.
|
||||
*/
|
||||
class StateGraphReActAgentStreamedContentDeltaTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("iteration 0 with tool calls → segmentOnly + PRE_TOOL_NARRATION — provisional rehearsal")
|
||||
@DisplayName("zero observations with tool calls → segmentOnly + PRE_TOOL_NARRATION — provisional rehearsal")
|
||||
void preToolNarration_taggedProvisional() {
|
||||
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
|
||||
/* isFinalAnswerTurn */ false,
|
||||
/* carriesToolCalls */ true,
|
||||
/* iteration */ 0,
|
||||
/* observationCount */ 0,
|
||||
"先加载 skill,然后逐个查询。");
|
||||
|
||||
assertTrue(d.persistenceOnly(),
|
||||
@ -50,10 +57,10 @@ class StateGraphReActAgentStreamedContentDeltaTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iteration ≥1 narration → segmentOnly + GROUNDED_NARRATION even when the completion issues more tool calls")
|
||||
@DisplayName("≥1 observation → segmentOnly + GROUNDED_NARRATION even when the completion issues more tool calls")
|
||||
void postObservationNarration_taggedGrounded() {
|
||||
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
|
||||
false, /* carriesToolCalls */ true, /* iteration */ 1, "第一间空闲,继续查下一间。");
|
||||
false, /* carriesToolCalls */ true, /* observationCount */ 1, "第一间空闲,继续查下一间。");
|
||||
|
||||
assertTrue(d.segmentOnly());
|
||||
assertEquals(ContentKind.GROUNDED_NARRATION, d.kind(),
|
||||
@ -61,10 +68,31 @@ class StateGraphReActAgentStreamedContentDeltaTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("iteration 0 without tool calls (non-terminal) → GROUNDED_NARRATION — conservative, never replaced")
|
||||
@DisplayName("regression: observations landed while the iteration budget still reads 0 → GROUNDED_NARRATION")
|
||||
void observationsWithoutIterationBudget_taggedGrounded() {
|
||||
// The false-collapse this pins. Two graph paths leave CURRENT_ITERATION
|
||||
// at 0 after real observations already happened:
|
||||
// - ObservationNode refunds the iteration for a progressive-disclosure
|
||||
// round (load_skill / enable_tool), and
|
||||
// - GoalEvaluationNode resets it to 0 on a hard continuation.
|
||||
// Reading the budget counter tagged the next round's grounded narration
|
||||
// as provisional, so renderers collapsed it. Observed live: a turn whose
|
||||
// first round was `load_skill` had its second-round narration
|
||||
// ("我先检查…环境配置和 API 参考文档。") folded behind the "模型在工具执行前
|
||||
// 预写的内容" toggle. The observation counter is not refunded or reset.
|
||||
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
|
||||
false, /* carriesToolCalls */ true, /* observationCount */ 1,
|
||||
"我先检查腾讯会议技能的环境配置和 API 参考文档。");
|
||||
|
||||
assertEquals(ContentKind.GROUNDED_NARRATION, d.kind(),
|
||||
"one observation already landed — narration after it is grounded regardless of the iteration budget");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("zero observations without tool calls (non-terminal) → GROUNDED_NARRATION — conservative, never replaced")
|
||||
void noToolCallCompletion_taggedGrounded() {
|
||||
AgentService.StreamDelta d = StateGraphReActAgent.streamedContentDelta(
|
||||
false, /* carriesToolCalls */ false, /* iteration */ 0, "narrative without tools");
|
||||
false, /* carriesToolCalls */ false, /* observationCount */ 0, "narrative without tools");
|
||||
|
||||
assertEquals(ContentKind.GROUNDED_NARRATION, d.kind(),
|
||||
"a completion that closed without tool calls has no later observation to defer to — keep it");
|
||||
@ -118,7 +146,7 @@ class StateGraphReActAgentStreamedContentDeltaTest {
|
||||
@Test
|
||||
@DisplayName("kind-carrying delta is followed by a segment_kind broadcast event")
|
||||
void kindDeltaEmitsSegmentKindEvent() {
|
||||
java.util.List<AgentService.StreamDelta> deltas = new java.util.ArrayList<>();
|
||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||
StateGraphReActAgent.addWithKindEvent(deltas,
|
||||
StateGraphReActAgent.streamedContentDelta(false, true, 0, "先查询。"));
|
||||
|
||||
@ -132,7 +160,7 @@ class StateGraphReActAgentStreamedContentDeltaTest {
|
||||
@Test
|
||||
@DisplayName("untagged delta emits no segment_kind event")
|
||||
void untaggedDeltaEmitsNoEvent() {
|
||||
java.util.List<AgentService.StreamDelta> deltas = new java.util.ArrayList<>();
|
||||
List<AgentService.StreamDelta> deltas = new ArrayList<>();
|
||||
StateGraphReActAgent.addWithKindEvent(deltas, AgentService.StreamDelta.segmentOnly("x", null));
|
||||
|
||||
assertEquals(1, deltas.size());
|
||||
|
||||
@ -75,6 +75,20 @@ class ObservationNodeRefundTest {
|
||||
assertNull(out.get(ITERATION_REFUND_COUNT));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退还迭代的轮次仍然递增观察计数(内容分类只能读观察计数,不能读迭代预算)")
|
||||
void refundedRound_stillAdvancesObservationCount() throws Exception {
|
||||
// The invariant StateGraphReActAgent.streamedContentDelta relies on: a
|
||||
// refunded round did real observation work even though it was not
|
||||
// charged an iteration. Classifying content by CURRENT_ITERATION here
|
||||
// tagged the NEXT round's grounded narration as pre-tool rehearsal, and
|
||||
// renderers collapsed it. TOOL_CALL_COUNT is never refunded or reset.
|
||||
Map<String, Object> out = node().apply(state(0, 0, List.of(result("load_skill"))));
|
||||
|
||||
assertEquals(0, out.get(CURRENT_ITERATION), "iteration budget stays put — the round was refunded");
|
||||
assertEquals(1, out.get(TOOL_CALL_COUNT), "the observation still happened and must be counted");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("退还次数达上限后不再退还")
|
||||
void refundCapReached_consumesIteration() throws Exception {
|
||||
|
||||
120
mateclaw-ui/src/composables/chat/__tests__/supersede.test.ts
Normal file
120
mateclaw-ui/src/composables/chat/__tests__/supersede.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { MessageSegment } from '@/types'
|
||||
import {
|
||||
SUPERSEDED_REASON_PRE_TOOL,
|
||||
markSuperseded,
|
||||
supersedesProvisionalNarration,
|
||||
} from '../supersede'
|
||||
|
||||
function content(over: Partial<MessageSegment> = {}): MessageSegment {
|
||||
return {
|
||||
id: 'seg-1',
|
||||
type: 'content',
|
||||
status: 'completed',
|
||||
text: '我先查一下会议室占用情况:',
|
||||
timestamp: 0,
|
||||
...over,
|
||||
} as MessageSegment
|
||||
}
|
||||
|
||||
describe('supersedesProvisionalNarration', () => {
|
||||
it('replaces a provisional narration once an observation landed after it', () => {
|
||||
const prev = content({ kind: 'pre_tool_narration' })
|
||||
// opened at 0 observations, one tool completed since
|
||||
expect(supersedesProvisionalNarration(prev, 1, 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps narration that no observation followed', () => {
|
||||
// The reported false-collapse: a phase boundary splits one round's text, so
|
||||
// a second content span opens with no tool having run in between. Nothing
|
||||
// replaced the first span — it must stay visible.
|
||||
const prev = content({ kind: 'pre_tool_narration' })
|
||||
expect(supersedesProvisionalNarration(prev, 0, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps narration when the only observations predate it', () => {
|
||||
// Span opened after two observations; a third span opens with the count
|
||||
// unchanged — the later text is not a replacement, just more narration.
|
||||
const prev = content({ kind: 'pre_tool_narration' })
|
||||
expect(supersedesProvisionalNarration(prev, 2, 2)).toBe(false)
|
||||
})
|
||||
|
||||
it('never touches grounded narration or final answers', () => {
|
||||
expect(supersedesProvisionalNarration(content({ kind: 'grounded_narration' }), 3, 0)).toBe(false)
|
||||
expect(supersedesProvisionalNarration(content({ kind: 'final_answer' }), 3, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('never touches an untagged span', () => {
|
||||
// `segment_kind` has not arrived (or the producer predates the tag) — the
|
||||
// persisted-metadata pass decides those, not the live rule.
|
||||
expect(supersedesProvisionalNarration(content(), 3, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('is idempotent — an already-collapsed span is not re-marked', () => {
|
||||
const prev = content({ kind: 'pre_tool_narration', superseded: true })
|
||||
expect(supersedesProvisionalNarration(prev, 5, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('handles the turn-opening span with no predecessor', () => {
|
||||
expect(supersedesProvisionalNarration(undefined, 1, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores non-content predecessors', () => {
|
||||
const toolSeg = { id: 'to-1', type: 'tool_call', status: 'completed', kind: 'pre_tool_narration' } as any
|
||||
expect(supersedesProvisionalNarration(toolSeg, 1, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('markSuperseded', () => {
|
||||
it('writes the three annotation fields renderers read', () => {
|
||||
const seg = content({ kind: 'pre_tool_narration' })
|
||||
markSuperseded(seg, 'seg-2')
|
||||
|
||||
expect(seg.superseded).toBe(true)
|
||||
expect(seg.supersededBySegmentId).toBe('seg-2')
|
||||
expect(seg.supersededReason).toBe(SUPERSEDED_REASON_PRE_TOOL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-round timeline', () => {
|
||||
/**
|
||||
* Walks the guard across a full ReAct turn, the way useChat drives it: each
|
||||
* content span records the observation count it opened at, and only the
|
||||
* immediately preceding span is ever a candidate.
|
||||
*/
|
||||
it('collapses only the span an observation actually replaced', () => {
|
||||
const marks = new Map<string, number>()
|
||||
const spans: MessageSegment[] = []
|
||||
let observations = 0
|
||||
|
||||
/** Mirrors the content_delta branch that opens a new content span. */
|
||||
const openSpan = (id: string): MessageSegment => {
|
||||
const prev = spans.at(-1)
|
||||
if (prev && supersedesProvisionalNarration(prev, observations, marks.get(String(prev.id)) ?? 0)) {
|
||||
markSuperseded(prev, id)
|
||||
}
|
||||
const seg = content({ id, status: 'running' })
|
||||
marks.set(id, observations)
|
||||
spans.push(seg)
|
||||
return seg
|
||||
}
|
||||
|
||||
// Round 0: narration written before any tool ran. `segment_kind` lands at
|
||||
// the end of the round, after the span opened.
|
||||
const s0 = openSpan('seg-0')
|
||||
s0.kind = 'pre_tool_narration'
|
||||
observations++ // load_skill observed — an iteration-refunded round still counts
|
||||
|
||||
// Round 1: narration written with that observation in hand. It replaces s0
|
||||
// and is itself tagged provisional (its completion calls tools again).
|
||||
const s1 = openSpan('seg-1')
|
||||
s1.kind = 'pre_tool_narration'
|
||||
|
||||
// A phase boundary splits round 1's text — no tool ran in between.
|
||||
openSpan('seg-2')
|
||||
|
||||
expect(s0.superseded).toBe(true)
|
||||
expect(s0.supersededBySegmentId).toBe('seg-1')
|
||||
expect(s1.superseded).toBeUndefined()
|
||||
})
|
||||
})
|
||||
46
mateclaw-ui/src/composables/chat/supersede.ts
Normal file
46
mateclaw-ui/src/composables/chat/supersede.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import type { MessageSegment } from '@/types'
|
||||
|
||||
/**
|
||||
* Live counterpart of the backend's provisional-narration policy.
|
||||
*
|
||||
* A `pre_tool_narration` span is text the model wrote in a completion that went
|
||||
* on to call tools, before any of this turn's observations landed — it may be
|
||||
* process narration or a rehearsal of a result the tool had not produced yet.
|
||||
* It is replaced only when a later content span was actually written with an
|
||||
* observation in hand. Anything else (a phase boundary splitting one round's
|
||||
* text, a second span inside the same completion, an unrelated earlier span)
|
||||
* leaves it standing: nothing has superseded it, and collapsing it there hides
|
||||
* narration the user needs.
|
||||
*/
|
||||
|
||||
/** Wire value shared with the backend so renderers need no new vocabulary. */
|
||||
export const SUPERSEDED_REASON_PRE_TOOL = 'pre_tool_content_replaced_by_post_tool_answer'
|
||||
|
||||
/**
|
||||
* Whether a content span opening now replaces `prev`.
|
||||
*
|
||||
* @param prev the content span immediately preceding the new one,
|
||||
* or undefined when this is the turn's first
|
||||
* @param observationCount tool observations completed so far this turn
|
||||
* @param prevObservationMark observation count when `prev` was opened
|
||||
*/
|
||||
export function supersedesProvisionalNarration(
|
||||
prev: MessageSegment | undefined,
|
||||
observationCount: number,
|
||||
prevObservationMark: number,
|
||||
): boolean {
|
||||
if (!prev || prev.type !== 'content') return false
|
||||
// Untagged spans (pre-tag producers, or a span whose `segment_kind` event has
|
||||
// not arrived yet) are never collapsed live — the persisted-metadata pass
|
||||
// decides those.
|
||||
if (prev.kind !== 'pre_tool_narration') return false
|
||||
if (prev.superseded) return false
|
||||
return observationCount > prevObservationMark
|
||||
}
|
||||
|
||||
/** Apply the three annotation fields renderers read. */
|
||||
export function markSuperseded(seg: MessageSegment, bySegmentId: string): void {
|
||||
seg.superseded = true
|
||||
seg.supersededBySegmentId = bySegmentId
|
||||
seg.supersededReason = SUPERSEDED_REASON_PRE_TOOL
|
||||
}
|
||||
@ -13,6 +13,7 @@ import { ref, computed } from 'vue'
|
||||
import { useMessages } from './useMessages'
|
||||
import { useStream } from './useStream'
|
||||
import { useMessageQueue } from './useMessageQueue'
|
||||
import { supersedesProvisionalNarration, markSuperseded } from './supersede'
|
||||
import { useGoalStore } from '@/stores/useGoalStore'
|
||||
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@ -232,6 +233,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
const segIdCounter = { value: 0 }
|
||||
const genSegId = () => `seg-${Date.now()}-${segIdCounter.value++}`
|
||||
|
||||
/**
|
||||
* Tool observations completed so far this turn, and the count each content
|
||||
* segment was opened at. A provisional narration is only replaced once an
|
||||
* observation actually landed after it, so the live collapse needs both the
|
||||
* running total and each span's mark — the same bookkeeping the backend
|
||||
* tracker does. Turn-scoped: reset with the rest of the streaming state.
|
||||
*/
|
||||
let observationCount = 0
|
||||
const segmentObservationMark = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Fine-grained lifecycle stage exposed to the UI for the "connecting → started
|
||||
* → context_prepared → llm_request_sent → streaming" loading bar. Reset on
|
||||
@ -268,6 +279,8 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
function resetCurrentTurnState() {
|
||||
currentSegments.value = []
|
||||
segIdCounter.value = 0
|
||||
observationCount = 0
|
||||
segmentObservationMark.clear()
|
||||
bufferedText = ''
|
||||
bufferedThinking = ''
|
||||
activeTurnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
@ -495,18 +508,19 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
// Close any running thinking segment first
|
||||
const thinkingSeg = segs.findLast((s: MessageSegment) => s.type === 'thinking' && s.status === 'running')
|
||||
if (thinkingSeg) thinkingSeg.status = 'completed'
|
||||
// The content span that precedes the one about to open — the only
|
||||
// candidate this span can replace.
|
||||
const prevContent = segs.findLast((s: MessageSegment) => s.type === 'content')
|
||||
contentSeg = { id: genSegId(), type: 'content', status: 'running', text: '', timestamp: Date.now() }
|
||||
applyIterationTags(contentSeg)
|
||||
// Later content supersedes any earlier provisional narration of the
|
||||
// same turn — collapse it in place, live, instead of waiting for the
|
||||
// persisted-metadata annotations in the done payload. Mirrors the
|
||||
// backend tracker's rule for kind-tagged segments.
|
||||
for (const s of segs) {
|
||||
if (s.type === 'content' && s.kind === 'pre_tool_narration' && !s.superseded) {
|
||||
s.superseded = true
|
||||
s.supersededBySegmentId = String(contentSeg.id)
|
||||
s.supersededReason = 'pre_tool_content_replaced_by_post_tool_answer'
|
||||
}
|
||||
segmentObservationMark.set(String(contentSeg.id), observationCount)
|
||||
// Later content supersedes an earlier provisional narration — collapse
|
||||
// it in place, live, instead of waiting for the persisted-metadata
|
||||
// annotations in the done payload. Rule and guards live in
|
||||
// supersede.ts, mirroring the backend tracker.
|
||||
const prevMark = prevContent ? (segmentObservationMark.get(String(prevContent.id)) ?? 0) : 0
|
||||
if (prevContent && supersedesProvisionalNarration(prevContent, observationCount, prevMark)) {
|
||||
markSuperseded(prevContent, String(contentSeg.id))
|
||||
}
|
||||
segs.push(contentSeg)
|
||||
flushSegmentsToMessage() // sync once when a new content segment is created
|
||||
@ -937,6 +951,11 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
||||
// Body of tool_call_completed — see handleToolCallStarted.
|
||||
function handleToolCallCompleted(data: any) {
|
||||
if (isStaleEvent(data)) return
|
||||
// Counted regardless of success: a failed tool still produces an
|
||||
// observation, and the content written after it is grounded in that
|
||||
// failure. Counted before the segment work below so a content span opened
|
||||
// later in this turn sees the higher mark.
|
||||
observationCount++
|
||||
if (currentAssistantId.value) {
|
||||
const msg = getMessage(currentAssistantId.value)
|
||||
if (msg) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user