Tool Call Semantics Alignment
Nucleic-Session: B5E370C3-4764-47E1-95D3-A36B4AAB8AC6
This commit is contained in:
@@ -4,8 +4,10 @@ import NucleicProtocol
|
||||
/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
|
||||
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
|
||||
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
|
||||
/// per message, one card per tool call — so the view layer stays dumb. (Mirrors the desktop's
|
||||
/// `TranscriptProjection` at phone fidelity.)
|
||||
/// per message, one card per tool call, a single contiguous block for a run of consecutive
|
||||
/// calls, and a subagent spawn carrying its own nested activity — so the view layer stays dumb.
|
||||
/// (Mirrors the desktop's `TranscriptProjection` at phone fidelity, including its event
|
||||
/// partitioning, tool-run coalescing, and subagent nesting.)
|
||||
struct TranscriptItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
/// The seq of the first event that created this item — stable scroll anchor + ordering.
|
||||
@@ -17,9 +19,14 @@ struct TranscriptItem: Identifiable, Equatable {
|
||||
enum Kind: Equatable {
|
||||
case message(role: Role, text: String)
|
||||
case thinking(text: String)
|
||||
/// A single tool call. A subagent spawn (`Task`/`Agent`) carries its inner activity in
|
||||
/// `group.children` and renders as the gold subagent card; every other tool is a plain
|
||||
/// collapsible card.
|
||||
case tool(ToolGroup)
|
||||
/// A `Task`/`Agent` spawn — rendered as the gold Orchestra card.
|
||||
case orchestration(ToolGroup)
|
||||
/// A run of ≥2 consecutive tool calls coalesced into one contiguous block — the phone
|
||||
/// echo of the Mac's `.toolGroup`. A run made entirely of subagent spawns is a
|
||||
/// multi-agent fan-out (the gold orchestration card); any other run is a tool block.
|
||||
case toolBlock([ToolGroup])
|
||||
case sessionStarted(model: String, cwd: String)
|
||||
case usage(Usage)
|
||||
case rateLimit(RateLimit)
|
||||
@@ -30,6 +37,17 @@ struct TranscriptItem: Identifiable, Equatable {
|
||||
case note(text: String, icon: String?, lockEvent: Bool)
|
||||
case raw(type: String, body: String)
|
||||
}
|
||||
|
||||
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
|
||||
/// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside
|
||||
/// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into
|
||||
/// two cards with a gap. Mirrors the desktop projection's `rendersNothing`.
|
||||
var rendersNothing: Bool {
|
||||
if case .thinking(let text) = kind {
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
||||
@@ -42,6 +60,10 @@ struct ToolGroup: Equatable {
|
||||
var finished: Bool = false
|
||||
/// Paths the call touched (from `fileChange` events tagged with this tool call).
|
||||
var fileChanges: [FilePatch] = []
|
||||
/// For a subagent spawn (`Task`/`Agent`): the inner activity it produced — its own tool
|
||||
/// calls, thinking, and prose — projected the same way and nested here, so the card can show
|
||||
/// its live Read/Bash/Edit stream. Empty for every ordinary (non-subagent) tool.
|
||||
var children: [TranscriptItem] = []
|
||||
|
||||
struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }
|
||||
|
||||
@@ -51,9 +73,167 @@ struct ToolGroup: Equatable {
|
||||
}
|
||||
|
||||
enum TranscriptProjection {
|
||||
/// How deep subagent nesting is followed before deeper descendants are left unattached — a
|
||||
/// safety bound on the recursion (a subagent spawning a subagent spawning a subagent …), far
|
||||
/// past any real fan-out depth. Mirrors the desktop projection.
|
||||
private static let maxSubagentDepth = 4
|
||||
|
||||
/// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
|
||||
/// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
|
||||
///
|
||||
/// Work that happens *inside* a spawned subagent arrives in this same stream tagged with the
|
||||
/// parent `Task`'s id (`parentToolCallID`). We partition events by which subagent (if any)
|
||||
/// owns them, project the main agent's own events at the top level, and recursively project
|
||||
/// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
|
||||
/// under its card instead of leaking (and interleaving) into the main transcript.
|
||||
static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
|
||||
let (topLevel, byParent) = partition(events)
|
||||
return project(topLevel, byParent: byParent, depth: 0, showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
|
||||
// MARK: - Subagent partitioning
|
||||
|
||||
/// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
|
||||
/// the spawning `Task`'s id.
|
||||
private static func partition(_ events: [AgentEvent])
|
||||
-> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
|
||||
{
|
||||
let parentOf = toolParentMap(events)
|
||||
var topLevel: [AgentEvent] = []
|
||||
var byParent: [String: [AgentEvent]] = [:]
|
||||
for event in events {
|
||||
if let owner = subagentOwner(of: event, parentOf: parentOf) {
|
||||
byParent[owner, default: []].append(event)
|
||||
} else {
|
||||
topLevel.append(event)
|
||||
}
|
||||
}
|
||||
return (topLevel, byParent)
|
||||
}
|
||||
|
||||
/// Maps each tool-call id to its parent subagent's id, built from the call start/complete
|
||||
/// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
|
||||
/// scope from the call it belongs to via this map.
|
||||
private static func toolParentMap(_ events: [AgentEvent]) -> [String: String] {
|
||||
var map: [String: String] = [:]
|
||||
for event in events {
|
||||
switch event.kind {
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
if let parent = call.parentToolCallID { map[call.toolCallID] = parent }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/// The id of the subagent whose scope this event belongs to, or nil for the main agent's own
|
||||
/// turn. Tool calls and the subagent's prose/thinking carry the parent link directly; a tool
|
||||
/// result or file change inherits it from the call it references.
|
||||
private static func subagentOwner(of event: AgentEvent, parentOf: [String: String]) -> String? {
|
||||
switch event.kind {
|
||||
case .toolCallStarted(let call), .toolCallCompleted(let call):
|
||||
return call.parentToolCallID
|
||||
case .toolResult(let result):
|
||||
return parentOf[result.toolCallID]
|
||||
case .fileChange(let change):
|
||||
return change.toolCallID.flatMap { parentOf[$0] }
|
||||
case .assistantText(let chunk), .thinking(let chunk):
|
||||
return chunk.parentToolCallID
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Projects one scope's events into items, coalesces runs of tool calls, then nests each
|
||||
/// subagent spawn's own activity (drawn from `byParent`) as its `children`, recursing for
|
||||
/// subagents-within-subagents up to `maxSubagentDepth`.
|
||||
private static func project(
|
||||
_ events: [AgentEvent], byParent: [String: [AgentEvent]], depth: Int,
|
||||
showRaw: Bool, showLockEvents: Bool
|
||||
) -> [TranscriptItem] {
|
||||
let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
|
||||
guard depth < maxSubagentDepth else { return items }
|
||||
return items.map {
|
||||
attachSubagentChildren($0, byParent: byParent, depth: depth,
|
||||
showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
}
|
||||
}
|
||||
|
||||
/// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out block — projects the
|
||||
/// events it owns into its `children`. Leaves every ordinary tool untouched.
|
||||
private static func attachSubagentChildren(
|
||||
_ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int,
|
||||
showRaw: Bool, showLockEvents: Bool
|
||||
) -> TranscriptItem {
|
||||
func childrenFor(_ group: ToolGroup) -> ToolGroup {
|
||||
guard group.isOrchestration else { return group }
|
||||
var g = group
|
||||
g.children = project(byParent[group.toolCallID] ?? [], byParent: byParent, depth: depth + 1,
|
||||
showRaw: showRaw, showLockEvents: showLockEvents)
|
||||
return g
|
||||
}
|
||||
switch item.kind {
|
||||
case .tool(let group):
|
||||
guard group.isOrchestration else { return item }
|
||||
return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(childrenFor(group)))
|
||||
case .toolBlock(let groups):
|
||||
return TranscriptItem(id: item.id, seq: item.seq, kind: .toolBlock(groups.map(childrenFor)))
|
||||
default:
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tool-run coalescing
|
||||
|
||||
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
|
||||
///
|
||||
/// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a
|
||||
/// run: an invisible row landing between two tool calls must not split them into two cards
|
||||
/// with a gap. Such items are held aside and re-emitted right after the block, so they still
|
||||
/// render in place while the calls stay one contiguous block. Mirrors the desktop projection.
|
||||
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
|
||||
var out: [TranscriptItem] = []
|
||||
var run: [TranscriptItem] = [] // consecutive `.tool` items
|
||||
var held: [TranscriptItem] = [] // non-rendering rows seen mid-run, held so they don't split it
|
||||
|
||||
func flush() {
|
||||
if run.count == 1 {
|
||||
out.append(run[0])
|
||||
} else if run.count >= 2 {
|
||||
let groups = run.compactMap { item -> ToolGroup? in
|
||||
if case .tool(let g) = item.kind { return g } else { return nil }
|
||||
}
|
||||
out.append(TranscriptItem(id: "toolblock-\(groups[0].toolCallID)",
|
||||
seq: run[0].seq, kind: .toolBlock(groups)))
|
||||
}
|
||||
run.removeAll(keepingCapacity: true)
|
||||
out.append(contentsOf: held)
|
||||
held.removeAll(keepingCapacity: true)
|
||||
}
|
||||
|
||||
for item in flat {
|
||||
if case .tool = item.kind {
|
||||
run.append(item)
|
||||
} else if item.rendersNothing, !run.isEmpty {
|
||||
held.append(item)
|
||||
} else {
|
||||
flush()
|
||||
out.append(item)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Flat projection (one scope)
|
||||
|
||||
/// Fold one scope's raw events into interleaved, display-ready rows: streaming assistant /
|
||||
/// thinking deltas accumulate into one growing row, and a tool call shows the moment it starts
|
||||
/// (`toolCallStarted`), with its full input/result/file-changes filled in as they arrive.
|
||||
private static func flatItems(
|
||||
_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool
|
||||
) -> [TranscriptItem] {
|
||||
var items: [TranscriptItem] = []
|
||||
var messageIndex: [String: Int] = [:] // messageID → items index (text coalescing)
|
||||
var thinkingIndex: [String: Int] = [:]
|
||||
@@ -145,11 +325,11 @@ enum TranscriptProjection {
|
||||
group.name = call.name
|
||||
if !call.input.isEmptyValue { group.input = call.input }
|
||||
group.finished = group.finished || finished
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
} else {
|
||||
index[call.toolCallID] = items.count
|
||||
let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: wrap(group)))
|
||||
items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: .tool(group)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +340,7 @@ enum TranscriptProjection {
|
||||
group.result = result.content
|
||||
group.isError = result.isError
|
||||
group.finished = true
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
}
|
||||
|
||||
private static func attachFileChange(
|
||||
@@ -168,22 +348,16 @@ enum TranscriptProjection {
|
||||
) {
|
||||
if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
|
||||
group.fileChanges.append(.init(path: change.path, change: change.kind))
|
||||
items[i].kind = wrap(group)
|
||||
items[i].kind = .tool(group)
|
||||
}
|
||||
// Untagged file changes are folded into the diff stat, not the transcript.
|
||||
}
|
||||
|
||||
/// Extract a tool group from either the plain or orchestration kind.
|
||||
/// Extract a tool group from a `.tool` item (the only kind `flatItems` produces for a call;
|
||||
/// blocks/children are formed later, after coalescing).
|
||||
private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
|
||||
switch item.kind {
|
||||
case .tool(let g), .orchestration(let g): return g
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a group in the right kind — orchestration spawns get the gold card.
|
||||
private static func wrap(_ group: ToolGroup) -> TranscriptItem.Kind {
|
||||
group.isOrchestration ? .orchestration(group) : .tool(group)
|
||||
if case .tool(let g) = item.kind { return g }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user