Show reasoning in active tool batches
This commit is contained in:
1 parent
4690e856e9
commit
b294f4346f
5 files changed
+186
-18
No files matched your search
@@ -46,6 +46,28 @@ nonisolated enum AgentToolPresentation {
|
||||
return .warning
|
||||
}
|
||||
|
||||
/// A stable 30% sample for a batch. Invocation identifiers are random UUIDs,
|
||||
/// so using their first 32 bits gives the desired distribution without
|
||||
/// making a SwiftUI row redraw or scroll reuse roll the dice again.
|
||||
static func showsReasoning(forBatchID id: UUID) -> Bool {
|
||||
let sample = withUnsafeBytes(of: id.uuid) { bytes in
|
||||
bytes.prefix(4).reduce(UInt32.zero) { value, byte in
|
||||
(value << 8) | UInt32(byte)
|
||||
}
|
||||
}
|
||||
return sample < (UInt32.max / 10) * 3
|
||||
}
|
||||
|
||||
/// Converts provider-authored Markdown into the one-line prose a tool
|
||||
/// batch header can accommodate. Structure and emphasis remain available
|
||||
/// in the live reasoning row; here only the visible words are reused.
|
||||
static func reasoningTitle(from markdown: String) -> String {
|
||||
let text = MarkdownDocumentParser.parse(markdown)
|
||||
.flatMap(plainText)
|
||||
.joined(separator: " ")
|
||||
return text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
}
|
||||
|
||||
static func summary(
|
||||
for card: AgentToolCard,
|
||||
hostName: String? = nil
|
||||
@@ -92,6 +114,27 @@ nonisolated enum AgentToolPresentation {
|
||||
return action
|
||||
}
|
||||
|
||||
private static func plainText(_ block: MarkdownBlock) -> [String] {
|
||||
switch block {
|
||||
case .paragraph(let inlines), .heading(_, let inlines):
|
||||
[inlines.map(\.text).joined()]
|
||||
case .codeBlock(_, let code):
|
||||
[code]
|
||||
case .list(let list):
|
||||
list.items.flatMap { $0.blocks.flatMap(plainText) }
|
||||
case .table(let table):
|
||||
([table.header] + table.rows).flatMap { row in
|
||||
row.map { $0.map(\.text).joined() }
|
||||
}
|
||||
case .blockQuote(let blocks):
|
||||
blocks.flatMap(plainText)
|
||||
case .htmlBlock(let raw):
|
||||
[raw]
|
||||
case .thematicBreak:
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
/// A finished contiguous batch, phrased from the same presentation
|
||||
/// metadata as its individual rows but with repeated activities counted.
|
||||
/// Calls in different terminal states stay separate, so one failure can
|
||||
|
||||
@@ -304,21 +304,34 @@ nonisolated enum TimelineItem: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct AgentToolBatch: Identifiable, Hashable, Sendable {
|
||||
var id: UUID { cards.first?.id ?? Self.emptyID }
|
||||
var cards: [AgentToolCard]
|
||||
/// The assistant response that requested every tool in this batch.
|
||||
var sourceMessageID: UUID
|
||||
/// A single-line projection of that response's display-safe reasoning.
|
||||
/// Kept only while at least one member is unfinished.
|
||||
var reasoningTitle: String?
|
||||
|
||||
private static let emptyID = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000000"
|
||||
)!
|
||||
}
|
||||
|
||||
nonisolated enum AgentWorkItem: Identifiable, Hashable, Sendable {
|
||||
case reasoning(AgentReasoningBlock)
|
||||
case message(ChatMessage)
|
||||
case tool(AgentToolCard)
|
||||
/// Two or more contiguous tool calls, regardless of their current result.
|
||||
/// The view shows activity until every member is terminal, then summarizes.
|
||||
case tools([AgentToolCard])
|
||||
case tools(AgentToolBatch)
|
||||
|
||||
var id: UUID {
|
||||
switch self {
|
||||
case .reasoning(let block): block.id
|
||||
case .message(let message): message.id
|
||||
case .tool(let card): card.id
|
||||
case .tools(let cards):
|
||||
cards.first?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")!
|
||||
case .tools(let batch): batch.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +340,7 @@ nonisolated enum AgentWorkItem: Identifiable, Hashable, Sendable {
|
||||
case .reasoning(let block): block.createdAt
|
||||
case .message(let message): message.createdAt
|
||||
case .tool(let card): card.invocation.createdAt
|
||||
case .tools(let cards): cards.first?.invocation.createdAt ?? .distantPast
|
||||
case .tools(let batch): batch.cards.first?.invocation.createdAt ?? .distantPast
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -987,8 +1000,15 @@ final class ConversationModel: Identifiable {
|
||||
?? recoveredSourceByCallID[card.invocation.call.id]
|
||||
return source.map { (card.id, $0) }
|
||||
})
|
||||
let reasoningTitles = Dictionary(uniqueKeysWithValues: processMessages.compactMap {
|
||||
message -> (UUID, String)? in
|
||||
let reasoning = message.reasoning.map(\.text).joined(separator: "\n\n")
|
||||
let title = AgentToolPresentation.reasoningTitle(from: reasoning)
|
||||
return title.isEmpty ? nil : (message.id, title)
|
||||
})
|
||||
items = Self.groupAdjacentTools(
|
||||
in: items, sourceMessageIDs: sourceMessageIDs
|
||||
in: items, sourceMessageIDs: sourceMessageIDs,
|
||||
reasoningTitles: reasoningTitles
|
||||
)
|
||||
let stored = runSummaries[runID]
|
||||
// A user stop is useful transcript state even when it interrupted a
|
||||
@@ -1027,7 +1047,8 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
|
||||
private static func groupAdjacentTools(
|
||||
in items: [AgentWorkItem], sourceMessageIDs: [AgentToolCard.ID: UUID]
|
||||
in items: [AgentWorkItem], sourceMessageIDs: [AgentToolCard.ID: UUID],
|
||||
reasoningTitles: [UUID: String]
|
||||
) -> [AgentWorkItem] {
|
||||
var grouped: [AgentWorkItem] = []
|
||||
var adjacent: [AgentToolCard] = []
|
||||
@@ -1036,8 +1057,13 @@ final class ConversationModel: Identifiable {
|
||||
guard !adjacent.isEmpty else { return }
|
||||
if adjacent.count == 1 {
|
||||
grouped.append(.tool(adjacent[0]))
|
||||
} else {
|
||||
grouped.append(.tools(adjacent))
|
||||
} else if let sourceMessageID = adjacentSourceMessageID {
|
||||
let hasUnfinished = adjacent.contains { !$0.state.isTerminal }
|
||||
grouped.append(.tools(AgentToolBatch(
|
||||
cards: adjacent,
|
||||
sourceMessageID: sourceMessageID,
|
||||
reasoningTitle: hasUnfinished ? reasoningTitles[sourceMessageID] : nil
|
||||
)))
|
||||
}
|
||||
adjacent.removeAll(keepingCapacity: true)
|
||||
adjacentSourceMessageID = nil
|
||||
|
||||
@@ -394,8 +394,8 @@ private struct AgentWorkGroupView: View {
|
||||
processMessage(message)
|
||||
case .tool(let card):
|
||||
AgentToolRowView(card: card)
|
||||
case .tools(let cards):
|
||||
AgentToolBatchView(cards: cards)
|
||||
case .tools(let batch):
|
||||
AgentToolBatchView(batch: batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -440,11 +440,13 @@ private struct AgentWorkGroupView: View {
|
||||
/// the header reuses those calls' ordinary live wording exactly; only a fully
|
||||
/// terminal batch changes over to a compact action summary.
|
||||
private struct AgentToolBatchView: View {
|
||||
let cards: [AgentToolCard]
|
||||
let batch: AgentToolBatch
|
||||
|
||||
@Environment(AppModel.self) private var app
|
||||
@State private var isExpanded = false
|
||||
|
||||
private var cards: [AgentToolCard] { batch.cards }
|
||||
|
||||
private var unfinished: [AgentToolCard] {
|
||||
cards.filter { card in
|
||||
switch card.state {
|
||||
@@ -454,10 +456,18 @@ private struct AgentToolBatchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var displayedReasoning: String? {
|
||||
guard !unfinished.isEmpty, let reasoning = batch.reasoningTitle,
|
||||
AgentToolPresentation.showsReasoning(forBatchID: batch.id)
|
||||
else { return nil }
|
||||
return reasoning
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
guard !unfinished.isEmpty else {
|
||||
return AgentToolPresentation.batchSummary(for: cards)
|
||||
}
|
||||
if let displayedReasoning { return displayedReasoning }
|
||||
return unfinished.map { card in
|
||||
AgentToolPresentation.summary(for: card, hostName: hostName(for: card))
|
||||
}.joined(separator: "; ")
|
||||
@@ -474,8 +484,11 @@ private struct AgentToolBatchView: View {
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
Text(title)
|
||||
.font(MarkdownTypography.body)
|
||||
.lineLimit(displayedReasoning == nil ? nil : 1)
|
||||
.truncationMode(.tail)
|
||||
.foregroundStyle(.secondary)
|
||||
.modifier(ScanningTextMask(isActive: !unfinished.isEmpty))
|
||||
.layoutPriority(1)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
|
||||
@@ -254,6 +254,32 @@ struct AgentToolPresentationTests {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reasoningBatchTitleIsPlainSingleLineText() {
|
||||
let title = AgentToolPresentation.reasoningTitle(from: """
|
||||
**Checked both hosts.**
|
||||
|
||||
- Compare the logs
|
||||
- Verify `sshd`
|
||||
""")
|
||||
|
||||
#expect(title == "Checked both hosts. Compare the logs Verify sshd")
|
||||
}
|
||||
|
||||
@Test
|
||||
func reasoningBatchSamplingIsStableAtThirtyPercent() throws {
|
||||
let included = try #require(UUID(
|
||||
uuidString: "00000000-0000-4000-8000-000000000001"
|
||||
))
|
||||
let excluded = try #require(UUID(
|
||||
uuidString: "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
||||
))
|
||||
|
||||
#expect(AgentToolPresentation.showsReasoning(forBatchID: included))
|
||||
#expect(AgentToolPresentation.showsReasoning(forBatchID: included))
|
||||
#expect(!AgentToolPresentation.showsReasoning(forBatchID: excluded))
|
||||
}
|
||||
|
||||
@Test
|
||||
func countedArrayActivityRoundTrips() throws {
|
||||
let activity = AgentToolDescriptor.Presentation.Activity.arrayCount(
|
||||
|
||||
@@ -641,7 +641,7 @@ struct ChatComposerStateTests {
|
||||
Issue.record("Two adjacent tools should form one live batch")
|
||||
return
|
||||
}
|
||||
#expect(live.map(\.id) == [first.id, second.id])
|
||||
#expect(live.cards.map(\.id) == [first.id, second.id])
|
||||
|
||||
conversation.handle(.toolFinished(first, AgentToolResult(
|
||||
callID: first.call.id, content: "failed", isError: true
|
||||
@@ -655,7 +655,67 @@ struct ChatComposerStateTests {
|
||||
Issue.record("A failed member must not split the completed batch")
|
||||
return
|
||||
}
|
||||
#expect(terminal.map(\.state) == [.failed, .completed])
|
||||
#expect(terminal.cards.map(\.state) == [.failed, .completed])
|
||||
}
|
||||
|
||||
@Test
|
||||
func liveToolBatchReusesOnlyItsProviderTurnsReasoning() throws {
|
||||
let conversation = ConversationModel()
|
||||
conversation.appendUserTurn("inspect both")
|
||||
let runID = UUID()
|
||||
let start = Date()
|
||||
let source = AgentTranscriptMessage(
|
||||
role: .assistant,
|
||||
reasoning: [AgentReasoningBlock(text: "**Compare** both hosts.\nThen verify them.")],
|
||||
toolCalls: ["first", "second"].map {
|
||||
AgentToolCall(id: $0, name: "lookup", arguments: .object([:]))
|
||||
},
|
||||
createdAt: start.addingTimeInterval(1)
|
||||
)
|
||||
conversation.handle(.runStarted(AgentRunSummary(
|
||||
id: runID, state: .running, startedAt: start
|
||||
)))
|
||||
conversation.handle(.messageStarted(source))
|
||||
conversation.handle(.messageFinished(source))
|
||||
conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls))
|
||||
|
||||
let descriptor = AgentToolDescriptor(
|
||||
name: "lookup", summary: "lookup", inputSchema: .object([:]),
|
||||
target: .local, safety: .locallyReadOnly
|
||||
)
|
||||
let invocations = ["first", "second"].enumerated().map { offset, callID in
|
||||
AgentToolInvocation(
|
||||
runID: runID,
|
||||
call: AgentToolCall(id: callID, name: "lookup", arguments: .object([:])),
|
||||
sourceMessageID: source.id,
|
||||
createdAt: start.addingTimeInterval(Double(offset + 2))
|
||||
)
|
||||
}
|
||||
for invocation in invocations {
|
||||
conversation.handle(.toolProposed(invocation, descriptor))
|
||||
}
|
||||
|
||||
guard case .tools(let live) = try #require(
|
||||
conversation.workGroups.first?.items.first
|
||||
) else {
|
||||
Issue.record("Expected a live tool batch")
|
||||
return
|
||||
}
|
||||
#expect(live.sourceMessageID == source.id)
|
||||
#expect(live.reasoningTitle == "Compare both hosts. Then verify them.")
|
||||
|
||||
for invocation in invocations {
|
||||
conversation.handle(.toolFinished(invocation, AgentToolResult(
|
||||
callID: invocation.call.id, content: "ok"
|
||||
)))
|
||||
}
|
||||
guard case .tools(let terminal) = try #require(
|
||||
conversation.workGroups.first?.items.first
|
||||
) else {
|
||||
Issue.record("Expected the completed tools to remain batched")
|
||||
return
|
||||
}
|
||||
#expect(terminal.reasoningTitle == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -837,8 +897,8 @@ struct ChatComposerStateTests {
|
||||
case .reasoning(let block): return "reasoning:\(block.text)"
|
||||
case .message(let message): return "message:\(message.text)"
|
||||
case .tool(let card): return "tool:\(card.invocation.call.id)"
|
||||
case .tools(let cards):
|
||||
return "tools:\(cards.map(\.invocation.call.id).joined(separator: ","))"
|
||||
case .tools(let batch):
|
||||
return "tools:\(batch.cards.map(\.invocation.call.id).joined(separator: ","))"
|
||||
}
|
||||
}
|
||||
#expect(kinds == [
|
||||
@@ -896,7 +956,7 @@ struct ChatComposerStateTests {
|
||||
let group = try #require(conversation.workGroups.first)
|
||||
let batches: [[String]] = group.items.map { item -> [String] in
|
||||
switch item {
|
||||
case .tools(let cards): cards.map(\.invocation.call.id)
|
||||
case .tools(let batch): batch.cards.map(\.invocation.call.id)
|
||||
case .tool(let card): [card.invocation.call.id]
|
||||
case .reasoning, .message: []
|
||||
}
|
||||
@@ -953,7 +1013,7 @@ struct ChatComposerStateTests {
|
||||
let items = try #require(store.conversation(id: conversationID)?.workGroups.first).items
|
||||
let groups = items.map { item -> [String] in
|
||||
switch item {
|
||||
case .tools(let cards): cards.map(\.invocation.call.id)
|
||||
case .tools(let batch): batch.cards.map(\.invocation.call.id)
|
||||
case .tool(let card): [card.invocation.call.id]
|
||||
case .reasoning, .message: []
|
||||
}
|
||||
@@ -998,7 +1058,7 @@ struct ChatComposerStateTests {
|
||||
let items = group.items
|
||||
let groups = items.map { item -> [String] in
|
||||
switch item {
|
||||
case .tools(let cards): cards.map(\.descriptor.name)
|
||||
case .tools(let batch): batch.cards.map(\.descriptor.name)
|
||||
case .tool(let card): [card.descriptor.name]
|
||||
case .reasoning, .message: []
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user