Files
kandev/apps/backend/internal/agentctl/server/adapter/copilot_normalizer.go
T

382 lines
11 KiB
Go

package adapter
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
// exitedPattern matches the "<exited with exit code N>" suffix that Copilot appends to shell output.
var exitedPattern = regexp.MustCompile(`\n?<exited with exit code (\d+)>$`)
// Copilot tool name constants.
// Copilot typically uses lowercase snake_case tool names.
const (
CopilotToolBash = "bash"
CopilotToolReadFile = "read_file"
CopilotToolWriteFile = "write_file"
CopilotToolEditFile = "edit_file"
CopilotToolGlob = "glob"
CopilotToolGrep = "grep"
CopilotToolWebFetch = "web_fetch"
CopilotToolWebSearch = "web_search"
)
// CopilotNormalizer converts Copilot SDK tool data to NormalizedPayload.
type CopilotNormalizer struct{}
// NewCopilotNormalizer creates a new Copilot normalizer.
func NewCopilotNormalizer() *CopilotNormalizer {
return &CopilotNormalizer{}
}
// NormalizeToolCall converts Copilot tool call data to NormalizedPayload.
func (n *CopilotNormalizer) NormalizeToolCall(toolName string, args any) *streams.NormalizedPayload {
// Convert args to map if possible
argsMap := n.toMap(args)
// Normalize tool name to lowercase for comparison
toolNameLower := strings.ToLower(toolName)
switch toolNameLower {
case CopilotToolBash, "shell", "execute", "run":
return n.normalizeExecute(argsMap)
case CopilotToolReadFile, "read":
return n.normalizeRead(argsMap)
case CopilotToolWriteFile, "write":
return n.normalizeWrite(argsMap)
case CopilotToolEditFile, "edit":
return n.normalizeEdit(argsMap)
case CopilotToolGlob, CopilotToolGrep, "search", "find":
return n.normalizeCodeSearch(toolNameLower, argsMap)
case CopilotToolWebFetch, CopilotToolWebSearch, "fetch", "http":
return n.normalizeHttpRequest(toolNameLower, argsMap)
default:
return n.normalizeGeneric(toolName, argsMap)
}
}
// NormalizeToolResult updates the payload with tool result data.
func (n *CopilotNormalizer) NormalizeToolResult(cachedPayload *streams.NormalizedPayload, result any, isError bool) *streams.NormalizedPayload {
if cachedPayload == nil {
// No cached payload, create a generic one with the result
output := n.extractOutput(result)
payload := streams.NewGeneric("unknown", nil)
if payload.Generic() != nil {
payload.Generic().Output = output
}
return payload
}
// Update the cached payload with the result
switch cachedPayload.Kind() {
case streams.ToolKindShellExec:
if cachedPayload.ShellExec() != nil {
output, exitCode := n.parseShellResult(result)
cachedPayload.ShellExec().Output = &streams.ShellExecOutput{
Stdout: output,
ExitCode: exitCode,
}
if isError {
cachedPayload.ShellExec().Output.Stderr = output
cachedPayload.ShellExec().Output.Stdout = ""
}
}
case streams.ToolKindHttpRequest:
if cachedPayload.HttpRequest() != nil {
if output, ok := n.extractOutput(result).(string); ok {
cachedPayload.HttpRequest().Response = output
}
}
case streams.ToolKindGeneric:
if cachedPayload.Generic() != nil {
cachedPayload.Generic().Output = n.extractOutput(result)
}
case streams.ToolKindReadFile:
if cachedPayload.ReadFile() != nil {
if output, ok := n.extractOutput(result).(string); ok {
cachedPayload.ReadFile().Output = &streams.ReadFileOutput{
Content: output,
}
}
}
case streams.ToolKindCodeSearch:
if cachedPayload.CodeSearch() != nil {
if output, ok := n.extractOutput(result).(string); ok {
// Parse file list from newline-separated output
files := n.parseFileList(output)
cachedPayload.CodeSearch().Output = &streams.CodeSearchOutput{
Files: files,
FileCount: len(files),
}
}
}
}
return cachedPayload
}
// toMap converts an argument to a map[string]any.
func (n *CopilotNormalizer) toMap(args any) map[string]any {
if args == nil {
return nil
}
if m, ok := args.(map[string]any); ok {
return m
}
// Try JSON round-trip for struct types
if data, err := json.Marshal(args); err == nil {
var m map[string]any
if err := json.Unmarshal(data, &m); err == nil {
return m
}
}
return nil
}
// extractOutput extracts a string output from various result formats.
func (n *CopilotNormalizer) extractOutput(result any) any {
if result == nil {
return nil
}
switch r := result.(type) {
case string:
return r
case map[string]any:
// Try common output field names
if output, ok := r["output"].(string); ok {
return output
}
if stdout, ok := r["stdout"].(string); ok {
return stdout
}
if content, ok := r["content"].(string); ok {
return content
}
if text, ok := r["text"].(string); ok {
return text
}
return r
default:
// Handle struct types (like Copilot SDK's *Result) via JSON round-trip
if data, err := json.Marshal(result); err == nil {
var m map[string]any
if err := json.Unmarshal(data, &m); err == nil {
// Try to extract content from the unmarshaled map
if content, ok := m["content"].(string); ok {
return content
}
if output, ok := m["output"].(string); ok {
return output
}
if stdout, ok := m["stdout"].(string); ok {
return stdout
}
if text, ok := m["text"].(string); ok {
return text
}
}
}
return result
}
}
// parseShellResult extracts output and exit code from shell execution result.
func (n *CopilotNormalizer) parseShellResult(result any) (string, int) {
if result == nil {
return "", 0
}
var output string
var exitCode int
switch r := result.(type) {
case string:
output = r
case map[string]any:
// Try various output field names used by different tools
output = shared.GetString(r, "output")
if output == "" {
output = shared.GetString(r, "stdout")
}
if output == "" {
output = shared.GetString(r, "content")
}
if output == "" {
output = shared.GetString(r, "text")
}
exitCode = shared.GetInt(r, "exitCode")
if exitCode == 0 {
exitCode = shared.GetInt(r, "exit_code")
}
default:
// Handle struct types (like Copilot SDK's *Result) via JSON round-trip
if data, err := json.Marshal(result); err == nil {
var m map[string]any
if err := json.Unmarshal(data, &m); err == nil {
// Try to extract content from the unmarshaled map
if content, ok := m["content"].(string); ok {
output = content
} else if out, ok := m["output"].(string); ok {
output = out
} else if stdout, ok := m["stdout"].(string); ok {
output = stdout
} else {
// Fallback: return the JSON string
output = string(data)
}
} else {
output = string(data)
}
}
}
// Strip Copilot's "<exited with exit code N>" suffix and extract exit code
output, exitCode = n.cleanShellOutput(output, exitCode)
return output, exitCode
}
// cleanShellOutput strips Copilot's "<exited with exit code N>" suffix from shell output
// and extracts the exit code if not already set.
func (n *CopilotNormalizer) cleanShellOutput(output string, exitCode int) (string, int) {
if matches := exitedPattern.FindStringSubmatch(output); matches != nil {
// Strip the suffix
output = exitedPattern.ReplaceAllString(output, "")
// Extract exit code from the suffix if we don't already have one
if exitCode == 0 && len(matches) > 1 {
if code, err := strconv.Atoi(matches[1]); err == nil {
exitCode = code
}
}
}
return strings.TrimSuffix(output, "\n"), exitCode
}
// normalizeExecute converts shell execution tool data.
func (n *CopilotNormalizer) normalizeExecute(args map[string]any) *streams.NormalizedPayload {
command := shared.GetString(args, "command")
if command == "" {
command = shared.GetString(args, "cmd")
}
description := shared.GetString(args, "description")
timeout := shared.GetInt(args, "timeout")
background := shared.GetBool(args, "background")
return streams.NewShellExec(command, "", description, timeout, background)
}
// normalizeRead converts file read tool data.
func (n *CopilotNormalizer) normalizeRead(args map[string]any) *streams.NormalizedPayload {
filePath := shared.GetString(args, "file_path")
if filePath == "" {
filePath = shared.GetString(args, "path")
}
offset := shared.GetInt(args, "offset")
limit := shared.GetInt(args, "limit")
return streams.NewReadFile(filePath, offset, limit)
}
// normalizeWrite converts file write tool data.
func (n *CopilotNormalizer) normalizeWrite(args map[string]any) *streams.NormalizedPayload {
filePath := shared.GetString(args, "file_path")
if filePath == "" {
filePath = shared.GetString(args, "path")
}
content := shared.GetString(args, "content")
return streams.NewModifyFile(filePath, []streams.FileMutation{
{
Type: streams.MutationCreate,
Content: content,
},
})
}
// normalizeEdit converts file edit tool data.
func (n *CopilotNormalizer) normalizeEdit(args map[string]any) *streams.NormalizedPayload {
filePath := shared.GetString(args, "file_path")
if filePath == "" {
filePath = shared.GetString(args, "path")
}
oldContent := shared.GetString(args, "old_string")
if oldContent == "" {
oldContent = shared.GetString(args, "old_content")
}
newContent := shared.GetString(args, "new_string")
if newContent == "" {
newContent = shared.GetString(args, "new_content")
}
mutation := streams.FileMutation{
Type: streams.MutationPatch,
OldContent: oldContent,
NewContent: newContent,
}
// Generate unified diff
if oldContent != "" && newContent != "" {
mutation.Diff = shared.GenerateUnifiedDiff(oldContent, newContent, filePath, 0)
}
return streams.NewModifyFile(filePath, []streams.FileMutation{mutation})
}
// normalizeCodeSearch converts code search tool data.
func (n *CopilotNormalizer) normalizeCodeSearch(toolName string, args map[string]any) *streams.NormalizedPayload {
path := shared.GetString(args, "path")
if path == "" {
path = shared.GetString(args, "directory")
}
pattern := shared.GetString(args, "pattern")
query := shared.GetString(args, "query")
glob := ""
if toolName == CopilotToolGlob {
glob = pattern
}
return streams.NewCodeSearch(query, pattern, path, glob)
}
// normalizeHttpRequest converts HTTP request tool data.
func (n *CopilotNormalizer) normalizeHttpRequest(toolName string, args map[string]any) *streams.NormalizedPayload {
url := shared.GetString(args, "url")
method := shared.GetString(args, "method")
if method == "" {
method = "GET"
}
if toolName == CopilotToolWebSearch {
method = "SEARCH"
}
return streams.NewHttpRequest(url, method)
}
// normalizeGeneric wraps unknown tools as generic.
func (n *CopilotNormalizer) normalizeGeneric(toolName string, args map[string]any) *streams.NormalizedPayload {
return streams.NewGeneric(toolName, args)
}
// parseFileList parses newline-separated file paths from grep/glob output.
func (n *CopilotNormalizer) parseFileList(output string) []string {
if output == "" {
return nil
}
lines := strings.Split(output, "\n")
var files []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
files = append(files, line)
}
}
return files
}