Repository navigation
Feat: Agent-neutral timeline turns and tools (#1559) #1600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
611d27d
6c115e8
3e21cab
1236163
fa04cb4
2f4bd3b
0ad2799
ed9a347
e3649d8
2191df2
9f782a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -837,10 +837,19 @@ func (m *model) cursorStrip(lay tlLayout) string { | |
| parts = append(parts, sanitizeLabel(p)) | ||
| } | ||
| if len(parts) == 0 { | ||
| // The turn covering the cell names its model, so a client running several models side | ||
| // by side (Bob's classifier beside its main turns) reads apart. | ||
| suffix := "" | ||
| for _, tu := range m.timeline.tl.Turns { | ||
| if tu.Model != "" && !cell.from.Before(tu.Start) && (tu.End == nil || cell.from.Before(*tu.End)) { | ||
| suffix = " (" + sanitizeLabel(tu.Model) + " turn)" | ||
| break | ||
|
Comment on lines
+843
to
+846
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Select the model for the request named by the cursor strip. When a turn starts after
|
||
| } | ||
| } | ||
| if h, ok := m.cellHost(lay, cell); ok { | ||
| return styleMuted.Render(head + "no tool running; a request to " + sanitizeLabel(h.Host) + " [enter] events") | ||
| return styleMuted.Render(head + "no tool running" + suffix + "; a request to " + sanitizeLabel(h.Host) + " [enter] events") | ||
| } | ||
| return styleMuted.Render(head + "no tool running in this cell") | ||
| return styleMuted.Render(head + "no tool running" + suffix + " in this cell") | ||
| } | ||
| if len(parts) > 1 { | ||
| head += fmt.Sprintf("(%d) ", len(parts)) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -716,6 +716,9 @@ func inferenceToCMFParts(inf *pipeline.InferenceExtension) []cmfPart { | |
| for _, m := range inf.Messages { | ||
| switch { | ||
| case m.Content == "": | ||
| case m.Role == roleTool && m.ToolUseID != "": | ||
| // A chat-completions result the parser also recorded in ToolResults; the | ||
| // loop below emits the id-carrying copy, so this one would count twice. | ||
|
Comment on lines
+719
to
+721
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| case m.Role == roleTool: | ||
| parts = append(parts, cmfPart{Kind: cmfPartToolResult, Content: m.Content}) | ||
| default: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -147,8 +147,20 @@ func parseOpenAIRequest(body []byte) *pipeline.InferenceExtension { | |
| Role: msg.Role, | ||
| Content: msg.Content, | ||
| ContentBytes: msg.ContentBytes, | ||
| ToolUseID: msg.ToolCallID, | ||
| }) | ||
| } | ||
| // A chat-completions tool result is a "role":"tool" message naming the call it answers | ||
| // by tool_call_id — the pairing key the timeline's tool spans close on. Anthropic and | ||
| // the Responses API surface theirs through the same ToolResults field. | ||
| for _, msg := range req.Messages { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: This second pass over |
||
| if msg.Role == "tool" && msg.ToolCallID != "" { | ||
| ext.ToolResults = append(ext.ToolResults, pipeline.InferenceToolResult{ | ||
| ToolUseID: msg.ToolCallID, | ||
| Content: msg.Content, | ||
| }) | ||
| } | ||
| } | ||
| for _, tool := range req.Tools { | ||
| if tool.Function.Name == "" { | ||
| continue | ||
|
|
@@ -963,19 +975,24 @@ type inferenceMessage struct { | |
| Role string | ||
| Content string | ||
| ContentBytes int | ||
| // ToolCallID is the tool_call_id of a chat-completions "role":"tool" result message: | ||
| // the id of the assistant tool_calls entry it answers. Empty on every other role. | ||
| ToolCallID string | ||
| } | ||
|
|
||
| func (m *inferenceMessage) UnmarshalJSON(data []byte) error { | ||
| var raw struct { | ||
| Role string `json:"role"` | ||
| Content json.RawMessage `json:"content"` | ||
| Role string `json:"role"` | ||
| Content json.RawMessage `json:"content"` | ||
| ToolCallID string `json:"tool_call_id"` | ||
| } | ||
| if err := json.Unmarshal(data, &raw); err != nil { | ||
| return err | ||
| } | ||
| m.Role = raw.Role | ||
| m.Content = flattenContent(raw.Content) | ||
| m.ContentBytes = contentBytes(raw.Content) | ||
| m.ToolCallID = raw.ToolCallID | ||
| return nil | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package inferenceparser | ||
|
|
||
| import "testing" | ||
|
|
||
| // A chat-completions tool result is a "role":"tool" message naming the call it answers by | ||
| // tool_call_id. Those ids are the pairing key the timeline's tool spans close on, so they | ||
| // must reach InferenceExtension.ToolResults; the result text stays in Messages, where | ||
| // settle's cost math reads it. | ||
| func TestChatCompletionsToolResultsCarryTheCallID(t *testing.T) { | ||
| body := []byte(`{"model":"premium-ide","messages":[ | ||
| {"role":"user","content":"list the files"}, | ||
| {"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function", | ||
| "function":{"name":"read_dir","arguments":"{\"path\":\"/tmp\"}"}}]}, | ||
| {"role":"tool","tool_call_id":"call_1","content":"file_a\nfile_b"}, | ||
| {"role":"tool","tool_call_id":"","content":"no id"}]}`) | ||
| ext := parseOpenAIRequest(body) | ||
| if ext == nil { | ||
| t.Fatal("request not parsed") | ||
| } | ||
| if len(ext.ToolResults) != 1 { | ||
| t.Fatalf("ToolResults = %+v, want only the call_1 result (an empty id pairs with nothing)", ext.ToolResults) | ||
| } | ||
| r := ext.ToolResults[0] | ||
| if r.ToolUseID != "call_1" || r.Content != "file_a\nfile_b" { | ||
| t.Fatalf("ToolResults[0] = %+v, want {call_1 file_a\\nfile_b}", r) | ||
| } | ||
| var toolMsg bool | ||
| for _, m := range ext.Messages { | ||
| if m.Role == "tool" && m.Content == "file_a\nfile_b" { | ||
| toolMsg = true | ||
| if m.ToolUseID != "call_1" { | ||
| t.Fatalf("role:tool message ToolUseID = %q, want call_1", m.ToolUseID) | ||
| } | ||
| } | ||
| } | ||
| if !toolMsg { | ||
| t.Fatal("role:tool message content missing from Messages") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -167,7 +167,7 @@ func buildAgents(t *Timeline, events []pipeline.SessionEvent) { | |
| } | ||
| st.prev = msgs | ||
|
|
||
| turn := Turn{Agent: c.agent, Start: c.req.At, Resent: resent, Seq: c.req.Seq} | ||
| turn := Turn{Agent: c.agent, Start: c.req.At, Resent: resent, Seq: c.req.Seq, Model: inf.Model} | ||
| if c.resp == nil { | ||
| t.Turns = append(t.Turns, turn) | ||
| continue | ||
|
|
@@ -356,8 +356,10 @@ func pairCalls(events []pipeline.SessionEvent) []call { | |
| return calls | ||
| } | ||
|
|
||
| // agentOf names the agent that sent a request. Only Claude Code's requests are attributed: | ||
| // AgentRole is set by its cc_is_subagent marker, and Subagent by its agent id header. | ||
| // agentOf names the agent that sent a request. Claude Code's requests carry their own | ||
| // markers: AgentRole from its cc_is_subagent header, Subagent from its agent id header. | ||
| // Every other known client gets the single main row — nothing on the wire says which of | ||
| // its calls belong together beyond that. | ||
| func agentOf(e *pipeline.SessionEvent) (string, bool) { | ||
| switch { | ||
| case e.Subagent != "": | ||
|
|
@@ -366,12 +368,20 @@ func agentOf(e *pipeline.SessionEvent) (string, bool) { | |
| return UnseparatedAgent, true | ||
| case e.Inference.AgentRole == pipeline.AgentRoleMain: | ||
| return MainAgent, true | ||
| case e.Client != nil && pipeline.IsKnownAgent(e.Client.Name): | ||
| return MainAgent, true | ||
|
Comment on lines
+371
to
+372
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Limit background-job detection to Claude Code. When a known non-Claude client returns tool text beginning
|
||
| } | ||
| return "", false | ||
| } | ||
|
|
||
| // isSideCall drops a call that is not an agent's work. The no-tools rule is Claude Code only | ||
| // (AgentRole set): it holds because Claude always sends a manifest on real turns, while another | ||
| // client's tool-less calls (Bob's security classifier) are real turns with real cost. The prompt | ||
| // literals run for every client: they match Claude's own progress and recap prompt text, which | ||
| // no other client sends, and a recap storm reads as turns on any session that carries one — | ||
| // including pre-marker Claude Code traffic, attributed by client name alone. | ||
| func isSideCall(inf *pipeline.InferenceExtension) bool { | ||
| if len(inf.Tools) == 0 && inf.ToolCount == 0 { | ||
| if inf.AgentRole != "" && len(inf.Tools) == 0 && inf.ToolCount == 0 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Keeping a non-Claude client's tool-less calls as turns on the single
Bob's
Once the result-bearing request arrives, the tool closes correctly (endAt takes precedence), so archived/finished sessions look right. The live view, which is the main point for #1559/#1560, will flicker. A related effect: One possible fix: compute |
||
| return true | ||
| } | ||
| for i := len(inf.Messages) - 1; i >= 0; i-- { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Given the concurrency above, a cell is often covered by both a
premium-ideturn and a classifier turn. "First covering turn" then depends on order (you already listed this as deferred). Preferring the turn that started latest at or beforecell.from, or the non-side-call one, would make the label stable. Fine as a follow-up.