Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/agent-gateway/internal/kbrain/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ type ToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
// ExitCode, TimedOut and DurationMs describe how the call ran (history only;
// a shell tool's exit status, 0 included). Display only.
ExitCode *int `json:"exit_code,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}

type Usage struct {
Expand Down
26 changes: 23 additions & 3 deletions crates/agent-gateway/internal/kbrain/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import (
// event vocabulary. Provider-specific response/chat-message fields never cross
// this boundary.
func ToChatEvent(event Event) (*v2.ChatEvent, *v2.ChatControlEvent, error) {
return toChatEvent(event, nil)
}

// toChatEvent is ToChatEvent with the recorded arguments of a tool call, which
// the question tool's plain-text result is read against.
func toChatEvent(event Event, callArgs func(id string) map[string]any) (*v2.ChatEvent, *v2.ChatControlEvent, error) {
payload := map[string]any{}
if len(event.Payload) != 0 && string(event.Payload) != "null" {
if err := json.Unmarshal(event.Payload, &payload); err != nil {
Expand Down Expand Up @@ -46,11 +52,25 @@ func ToChatEvent(event Event) (*v2.ChatEvent, *v2.ChatControlEvent, error) {
case "tool.result":
base.Type = v2.ChatEvent_TOOL_RESULT
if result, ok := payload["tool_result"].(map[string]any); ok {
payload["id"], payload["name"] = result["id"], result["name"]
payload["isError"] = result["failed"] == true || result["cancelled"] == true
name, _ := result["name"].(string)
id, _ := result["id"].(string)
isError := result["failed"] == true || result["cancelled"] == true
payload["id"], payload["name"] = result["id"], toolResultName(name)
payload["isError"] = isError
output, _ := result["output"].(string)
if details := questionResultDetails(name, output); details != nil {
var args map[string]any
if callArgs != nil && id != "" {
args = callArgs(id)
}
details := resultDetails(name, output, args, isError)
var exitCode *int
if code, ok := result["exit_code"].(float64); ok {
value := int(code)
exitCode = &value
}
timedOut := result["timed_out"] == true
duration, _ := result["duration_ms"].(float64)
if details = withRunInfo(name, details, exitCode, timedOut, int64(duration)); details != nil {
payload["details"] = details
}
content := []any{map[string]any{"type": "text", "text": result["output"]}}
Expand Down
35 changes: 31 additions & 4 deletions crates/agent-gateway/internal/kbrain/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,14 @@ func isBackgroundNotice(message Message) bool {
// WebUI can match search hits, which report whole-session offsets.
func historyMessagesJSON(messages []Message, firstOffset int32) (string, error) {
out := make([]map[string]any, 0, len(messages))
// A tool result is read against its call: the question tool's text answers
// against the questions asked, and run info sits on the call.
calls := map[string]ToolCall{}
for _, message := range messages {
for _, call := range message.ToolCalls {
calls[call.ID] = call
}
}
for i, message := range messages {
if message.Role != "user" && message.Role != "assistant" && message.Role != "tool" {
continue
Expand Down Expand Up @@ -366,9 +374,19 @@ func historyMessagesJSON(messages []Message, firstOffset int32) (string, error)
entry["liveAgentBackgroundNotice"] = true
}
if message.Role == "tool" {
entry["role"], entry["toolCallId"], entry["toolName"] = "toolResult", message.ToolCallID, message.Name
entry["isError"] = message.StopReason == "error" || message.StopReason == "cancelled"
if details := questionResultDetails(message.Name, historyText(message)); details != nil {
isError := message.StopReason == "error" || message.StopReason == "cancelled"
entry["role"], entry["toolCallId"], entry["toolName"] = "toolResult", message.ToolCallID, toolResultName(message.Name)
entry["isError"] = isError
call, known := calls[message.ToolCallID]
var args map[string]any
if known && len(call.Arguments) > 0 {
_ = json.Unmarshal(call.Arguments, &args)
}
details := resultDetails(message.Name, historyText(message), args, isError)
if known {
details = withRunInfo(message.Name, details, call.ExitCode, call.TimedOut, call.DurationMs)
}
if details != nil {
entry["details"] = details
}
}
Expand All @@ -388,7 +406,16 @@ func historyMessagesJSON(messages []Message, firstOffset int32) (string, error)
return "", fmt.Errorf("invalid history tool arguments at %d: %w", i, err)
}
}
content = append(content, map[string]any{"type": "toolCall", "id": call.ID, "name": call.Name, "arguments": args})
name := call.Name
if name == kbrainQuestionTool {
if record, ok := args.(map[string]any); ok {
args = normalizeQuestionToolArgs(record)
} else {
args = normalizeQuestionToolArgs(map[string]any{})
}
name = askUserQuestionTool
}
content = append(content, map[string]any{"type": "toolCall", "id": call.ID, "name": name, "arguments": args})
}
entry["content"], entry["api"], entry["provider"], entry["model"] = content, protocolVersion, message.Provider, message.Model
stop := message.StopReason
Expand Down
76 changes: 70 additions & 6 deletions crates/agent-gateway/internal/kbrain/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
argApprovalRules = "__toolApprovalRememberRules"
argApprovalDefault = "__toolApprovalDefaultScope"
argApprovalNotice = "__toolApprovalNotice"
argApprovalPreview = "__toolApprovalPreview"
argAutoResolved = "__toolPermissionAutoResolved"
argQuestionPending = "__askUserQuestionPending"
argQuestionDeadline = "__askUserQuestionDeadlineAt"
Expand All @@ -42,6 +43,8 @@ const (

approvalTextMax = 2000
approvalScopeMax = 300
// approvalPreviewMax bounds the synced diff, as K-brain bounds its own (32 KiB).
approvalPreviewMax = 32 * 1024
)

var syntheticArgPrefixes = []string{"__toolApproval", "__toolPermission", "__askUserQuestion", "__exitPlanMode"}
Expand Down Expand Up @@ -279,7 +282,7 @@ func (r *Relay) relayRunEvent(ctx context.Context, event Event, conversationID s
}
mapped := event
mapped.ConversationID = conversationID
chat, control, err := ToChatEvent(mapped)
chat, control, err := toChatEvent(mapped, func(id string) map[string]any { return s.callArguments(conversationID, id) })
if err != nil {
return nil, err
}
Expand All @@ -304,6 +307,13 @@ type permissionRequest struct {
} `json:"suggestion"`
Scope string `json:"scope"`
ToolCallID string `json:"tool_call_id"`
// Preview is the change a file tool will make, as diff rows.
Preview *struct {
Path string `json:"path"`
Diff string `json:"diff"`
NewFile bool `json:"new_file"`
Truncated bool `json:"truncated"`
} `json:"preview"`
}

type questionRequest struct {
Expand All @@ -329,6 +339,10 @@ func (s *interactiveState) recordCall(conversationID, runID string, call ToolCal
delete(args, key)
}
}
if call.Name == kbrainQuestionTool {
// K-brain's question tool is answered on the AskUserQuestion card.
call.Name, args = askUserQuestionTool, normalizeQuestionToolArgs(args)
}
key := interactionKey(conversationID, call.ID)
s.mu.Lock()
defer s.mu.Unlock()
Expand All @@ -348,6 +362,21 @@ func (s *interactiveState) recordCall(conversationID, runID string, call ToolCal
return s.callEventLocked(conversationID, call.ID)
}

// callArguments is a copy of a recorded call's arguments; nil when unknown.
func (s *interactiveState) callArguments(conversationID, toolCallID string) map[string]any {
s.mu.Lock()
defer s.mu.Unlock()
call := s.calls[interactionKey(conversationID, toolCallID)]
if call == nil {
return nil
}
args := make(map[string]any, len(call.arguments))
for key, value := range call.arguments {
args[key] = value
}
return args
}

func (s *interactiveState) settleCall(conversationID, toolCallID string) {
s.mu.Lock()
defer s.mu.Unlock()
Expand Down Expand Up @@ -432,6 +461,14 @@ func (s *interactiveState) requestPermission(conversationID, sessionID, runID st
} else if request.Rule != "" {
overlay[argApprovalScope] = clampText(request.Rule, approvalTextMax)
}
if preview := request.Preview; preview != nil && (preview.Diff != "" || preview.Truncated) {
// WebUI shows K-brain's diff rows instead of diffing the call's arguments.
diff, cut := clampDiffRows(preview.Diff, approvalPreviewMax)
overlay[argApprovalPreview] = map[string]any{
"path": preview.Path, "before": "", "after": "", "diff": diff,
"newFile": preview.NewFile, "truncated": preview.Truncated || cut,
}
}
s.pending[key] = &pendingInteraction{
kind: "permission", conversationID: conversationID, sessionID: sessionID, runID: runID,
requestID: request.PermissionID, toolCallID: toolCallID, synthetic: synthetic,
Expand Down Expand Up @@ -656,10 +693,11 @@ func (r *Relay) answerQuestion(ctx context.Context, conversationID, toolCallID,
return queueError("not_found", "This question is no longer pending.")
}
var answers []struct {
QuestionID string `json:"questionId"`
Prompt string `json:"prompt"`
SelectedLabel string `json:"selectedLabel"`
Custom bool `json:"custom"`
QuestionID string `json:"questionId"`
Prompt string `json:"prompt"`
SelectedLabel string `json:"selectedLabel"`
SelectedLabels []string `json:"selectedLabels"`
Custom bool `json:"custom"`
}
if err := json.Unmarshal([]byte(requestJSON), &answers); err != nil || len(answers) == 0 {
return queueError("invalid_request", "question answers are invalid")
Expand All @@ -670,7 +708,9 @@ func (r *Relay) answerQuestion(ctx context.Context, conversationID, toolCallID,
if answer.Prompt != "" {
item["prompt"] = answer.Prompt
}
if answer.Custom {
// K-brain takes one label per answer: several picks of a multi-select
// question arrive as their joined labels, which it only accepts as custom.
if answer.Custom || len(answer.SelectedLabels) > 1 {
item["custom"] = true
}
out = append(out, item)
Expand Down Expand Up @@ -831,6 +871,21 @@ func nonEmpty(values ...string) []string {
return out
}

// clampDiffRows cuts diff rows to max bytes at a row boundary.
func clampDiffRows(diff string, max int) (string, bool) {
if len(diff) <= max {
return diff, false
}
end := strings.LastIndexByte(diff[:max], '\n')
if end <= 0 {
end = max
for end > 0 && !utf8.RuneStart(diff[end]) {
end--
}
}
return diff[:end], true
}

func clampText(text string, max int) string {
if utf8.RuneCountInString(text) <= max {
return text
Expand All @@ -857,6 +912,15 @@ func clampRules(rules []string, max int) []string {
return out
}

// resultDetails is the question card's details for a question or
// AskUserQuestion result; nil for other tools.
func resultDetails(name, output string, args map[string]any, isError bool) map[string]any {
if name == kbrainQuestionTool {
return questionToolResultDetails(args, output, isError)
}
return questionResultDetails(name, output)
}

// questionResultDetails turns K-brain's AskUserQuestion result text into the
// tool-result details the WebUI question card reads to show the chosen answers
// (the desktop does the same in crates/agent-gui/src/lib/kbrain/questions.ts).
Expand Down
Loading
Loading