xworkmate-bridge/internal/acp/helpers.go
2026-05-18 18:33:09 +08:00

614 lines
15 KiB
Go

package acp
import (
"fmt"
"net/url"
"strings"
"xworkmate-bridge/internal/gatewayruntime"
"xworkmate-bridge/internal/shared"
"xworkmate-bridge/internal/skills"
)
func resolveSingleAgentForwardEndpoint(provider syncedProvider) string {
endpoint := strings.TrimSpace(provider.Endpoint)
if endpoint == "" {
return ""
}
// For compatibility with tests expecting specific protocol mappings
if provider.ProviderID == "opencode" || provider.ProviderID == "gemini" {
if strings.HasPrefix(endpoint, "ws") {
endpoint = "http" + strings.TrimPrefix(endpoint, "ws")
}
}
parsed, err := url.Parse(endpoint)
if err != nil {
return endpoint
}
isWS := strings.HasPrefix(parsed.Scheme, "ws")
isHTTP := strings.HasPrefix(parsed.Scheme, "http")
path := strings.TrimRight(parsed.EscapedPath(), "/")
if path == "" {
path = strings.TrimRight(parsed.Path, "/")
}
if isWS {
if path == "/acp" || strings.HasSuffix(path, "/acp") {
parsed.Path = path
} else {
parsed.Path = path + "/acp"
}
} else if isHTTP {
if path == "/acp/rpc" || strings.HasSuffix(path, "/acp/rpc") {
parsed.Path = path
} else if path == "/acp" || strings.HasSuffix(path, "/acp") {
parsed.Path = strings.TrimSuffix(path, "/acp") + "/acp/rpc"
} else {
parsed.Path = path + "/acp/rpc"
}
}
return parsed.String()
}
func normalizeAuthorizationHeader(raw string) string {
normalized := strings.TrimSpace(raw)
if normalized == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(normalized), "bearer ") {
return normalized
}
return "Bearer " + normalized
}
type externalACPNotificationCollector struct {
deltas strings.Builder
lastMessage string
errors []string
turnID string
}
func (c *externalACPNotificationCollector) observe(notification map[string]any) {
method := strings.TrimSpace(stringValue(notification["method"]))
if method != "session.update" && method != "acp.session.update" && method != "session/update" && !strings.HasPrefix(method, "item/") && !strings.HasPrefix(method, "turn/") {
return
}
params := asMap(notification["params"])
if len(params) == 0 {
return
}
if turnID := strings.TrimSpace(stringValue(params["turnId"])); turnID != "" {
c.turnID = turnID
}
if errorText := extractExternalACPNotificationError(notification); errorText != "" {
c.errors = append(c.errors, errorText)
}
if strings.TrimSpace(stringValue(notification["method"])) == "turn/completed" {
return
}
updateText := extractExternalACPNotificationText(notification)
if updateText == "" {
return
}
if isExternalACPFailureText(updateText) {
c.errors = append(c.errors, updateText)
return
}
if c.deltas.Len() > 0 {
c.deltas.WriteString("\n")
}
c.deltas.WriteString(updateText)
c.lastMessage = updateText
}
func (c *externalACPNotificationCollector) apply(result map[string]any) map[string]any {
if result == nil {
result = map[string]any{}
}
text := strings.TrimSpace(c.deltas.String())
if isGenericHermesAckText(text) {
text = ""
}
if text == "" {
text = strings.TrimSpace(c.lastMessage)
if isGenericHermesAckText(text) {
text = ""
}
}
if text == "" {
for _, candidate := range []string{
strings.TrimSpace(stringValue(result["output"])),
strings.TrimSpace(stringValue(result["summary"])),
strings.TrimSpace(stringValue(result["message"])),
} {
if candidate == "" || isGenericHermesAckText(candidate) {
continue
}
text = candidate
break
}
}
text = normalizeExternalACPAssistantText(text)
if errorText := c.errorText(); errorText != "" {
result["success"] = false
result["error"] = errorText
result["message"] = errorText
delete(result, "output")
delete(result, "summary")
} else if text != "" {
result["output"] = text
result["summary"] = text
}
if _, exists := result["turnId"]; !exists && strings.TrimSpace(c.turnID) != "" {
result["turnId"] = strings.TrimSpace(c.turnID)
}
return result
}
func (c *externalACPNotificationCollector) errorText() string {
if c == nil || len(c.errors) == 0 {
return ""
}
seen := make(map[string]struct{}, len(c.errors))
var parts []string
for _, item := range c.errors {
text := strings.TrimSpace(item)
if text == "" {
continue
}
if _, ok := seen[text]; ok {
continue
}
seen[text] = struct{}{}
parts = append(parts, text)
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func isGenericHermesAckText(text string) bool {
switch strings.ToLower(strings.TrimSpace(text)) {
case "", "ok", "session started", "single-agent completed":
return true
default:
return false
}
}
func extractExternalACPNotificationText(notification map[string]any) string {
if notification == nil {
return ""
}
method := strings.TrimSpace(stringValue(notification["method"]))
if strings.HasPrefix(method, "turn/") {
return ""
}
payload := asMap(notification["params"])
if len(payload) == 0 {
payload = notification
}
update := asMap(payload["update"])
if len(update) == 0 {
update = payload
}
updateKind := strings.TrimSpace(stringValue(update["sessionUpdate"]))
switch updateKind {
case "available_commands_update":
return ""
case "session_started", "session completed", "single-agent completed":
return ""
}
item := asMap(payload["item"])
if len(item) > 0 {
if strings.TrimSpace(stringValue(item["type"])) == "userMessage" {
return ""
}
if text := extractExternalACPAssistantTextValue(item); text != "" {
return text
}
return ""
}
if text := extractExternalACPAssistantTextValue(update); text != "" {
return text
}
if text := extractExternalACPAssistantTextValue(payload); text != "" {
return text
}
return ""
}
func extractExternalACPNotificationError(notification map[string]any) string {
if notification == nil {
return ""
}
payload := asMap(notification["params"])
if len(payload) == 0 {
payload = notification
}
update := asMap(payload["update"])
if len(update) == 0 {
update = payload
}
if turnError := extractExternalACPTextValue(asMap(asMap(payload["turn"])["error"])); turnError != "" {
return turnError
}
for _, source := range []map[string]any{update, asMap(payload["item"]), payload} {
if len(source) == 0 {
continue
}
if !parseBool(source["error"]) && strings.TrimSpace(stringValue(source["level"])) != "error" {
if text := extractExternalACPTextValue(source); !isExternalACPFailureText(text) {
continue
}
}
for _, key := range []string{"error", "message", "text", "content", "delta", "value"} {
if text := extractExternalACPTextValue(source[key]); text != "" {
return text
}
}
if text := extractExternalACPTextValue(source); text != "" {
return text
}
}
return ""
}
func isExternalACPFailureText(text string) bool {
normalized := strings.ToLower(strings.TrimSpace(text))
if normalized == "" {
return false
}
for _, marker := range []string{
"exec_command failed",
"failed to create unified exec process",
"execution_failed",
"tool execution failed",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func extractExternalACPTextValue(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case map[string]any:
var builder strings.Builder
for _, key := range []string{"text", "message", "content", "delta", "value"} {
if text := extractExternalACPTextValue(v[key]); text != "" {
if builder.Len() > 0 {
builder.WriteString(" ")
}
builder.WriteString(text)
}
}
if builder.Len() > 0 {
return strings.TrimSpace(builder.String())
}
for key, child := range v {
if key == "text" || key == "message" || key == "content" || key == "delta" || key == "value" || key == "sessionId" || key == "session_id" || key == "sessionUpdate" || key == "session_update" || key == "threadId" || key == "turnId" || key == "itemId" {
continue
}
if text := extractExternalACPTextValue(child); text != "" {
if builder.Len() > 0 {
builder.WriteString(" ")
}
builder.WriteString(text)
}
}
return strings.TrimSpace(builder.String())
case []any:
var parts []string
for _, child := range v {
if text := extractExternalACPTextValue(child); text != "" {
parts = append(parts, text)
}
}
return strings.TrimSpace(strings.Join(parts, " "))
default:
return ""
}
}
func extractExternalACPAssistantTextValue(value any) string {
return normalizeExternalACPAssistantText(extractExternalACPTextValue(value))
}
func structuredExternalACPEvent(notification map[string]any) map[string]any {
if notification == nil {
return nil
}
method := strings.TrimSpace(stringValue(notification["method"]))
payload := asMap(notification["params"])
if len(payload) == 0 {
payload = notification
}
update := asMap(payload["update"])
if len(update) == 0 {
update = payload
}
item := asMap(payload["item"])
source := update
if len(item) > 0 {
source = item
}
eventType := "status"
if strings.Contains(method, "thinking") || strings.TrimSpace(stringValue(source["thinking"])) != "" {
eventType = "thinking"
} else if strings.Contains(method, "tool") || len(asMap(source["toolCall"])) > 0 || len(asMap(source["tool_call"])) > 0 {
eventType = "tool_call"
} else if text := extractExternalACPAssistantTextValue(source); text != "" {
eventType = "text"
_ = text
}
result := map[string]any{
"type": eventType,
"method": method,
}
if text := extractExternalACPAssistantTextValue(source); text != "" {
result["text"] = text
}
if status := strings.TrimSpace(firstNonEmptyString(source, "status", "sessionUpdate", "session_update")); status != "" {
result["status"] = status
}
if tool := asMap(source["toolCall"]); len(tool) > 0 {
result["toolCall"] = tool
} else if tool := asMap(source["tool_call"]); len(tool) > 0 {
result["toolCall"] = tool
}
if result["text"] == nil && result["status"] == nil && result["toolCall"] == nil && eventType == "status" {
return nil
}
return result
}
func normalizeExternalACPAssistantText(text string) string {
normalized := strings.TrimSpace(text)
if normalized == "" {
return ""
}
if isExternalACPCommentaryAgentMessage(normalized) {
return ""
}
if idx := strings.LastIndex(normalized, "final_answer"); idx >= 0 {
normalized = strings.TrimSpace(normalized[idx+len("final_answer"):])
}
normalized = stripExternalACPAgentMessagePrefix(normalized)
normalized = stripExternalACPExecutionContextBlocks(normalized)
normalized = stripExternalACPRepeatedFinalLine(normalized)
return strings.TrimSpace(normalized)
}
func isExternalACPCommentaryAgentMessage(text string) bool {
fields := strings.Fields(strings.TrimSpace(text))
return len(fields) >= 3 && fields[0] == "commentary" && fields[1] == "agentMessage" && strings.HasPrefix(fields[2], "msg_")
}
func stripExternalACPAgentMessagePrefix(text string) string {
fields := strings.Fields(strings.TrimSpace(text))
if len(fields) >= 2 && fields[0] == "agentMessage" && strings.HasPrefix(fields[1], "msg_") {
return strings.TrimSpace(strings.Join(fields[2:], " "))
}
return strings.TrimSpace(text)
}
func stripExternalACPExecutionContextBlocks(text string) string {
lines := strings.Split(text, "\n")
var kept []string
skippingContext := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
if skippingContext {
skippingContext = false
continue
}
kept = append(kept, line)
continue
}
if strings.HasPrefix(trimmed, "Execution context:") {
skippingContext = true
continue
}
if skippingContext {
if strings.HasPrefix(trimmed, "•") || strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "*") || strings.Contains(trimmed, ":") {
continue
}
skippingContext = false
}
if isExternalACPProtocolStatusLine(trimmed) {
continue
}
kept = append(kept, line)
}
return strings.TrimSpace(strings.Join(kept, "\n"))
}
func stripExternalACPRepeatedFinalLine(text string) string {
lines := strings.Split(text, "\n")
nonEmpty := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
nonEmpty = append(nonEmpty, trimmed)
}
}
if len(nonEmpty) >= 2 && nonEmpty[len(nonEmpty)-1] == nonEmpty[len(nonEmpty)-2] {
return nonEmpty[len(nonEmpty)-1]
}
return strings.TrimSpace(text)
}
func isExternalACPProtocolStatusLine(text string) bool {
fields := strings.Fields(text)
if len(fields) < 2 {
return false
}
switch fields[0] {
case "inProgress", "pending", "running", "completed":
return strings.Count(fields[1], "-") >= 4
default:
return false
}
}
func parseGatewayRuntimeStringSlice(value any) []string {
list, ok := value.([]any)
if !ok {
if typed, ok := value.([]string); ok {
return append([]string(nil), typed...)
}
return nil
}
result := make([]string, 0, len(list))
for _, item := range list {
text := strings.TrimSpace(stringValue(item))
if text == "" {
continue
}
result = append(result, text)
}
return result
}
func parseBool(value any) bool {
switch typed := value.(type) {
case bool:
return typed
case string:
return shared.BoolArg(typed, false)
case float64:
return typed != 0
case int:
return typed != 0
default:
return false
}
}
func parsePositiveInt(value any) int {
switch typed := value.(type) {
case int:
if typed > 0 {
return typed
}
case int64:
if typed > 0 {
return int(typed)
}
case float64:
if typed > 0 {
return int(typed)
}
case string:
return shared.IntArg(typed, 0)
}
return 0
}
func resolveGatewayReportedRemoteAddress(
server *Server,
request gatewayruntime.ConnectRequest,
) string {
if strings.TrimSpace(strings.ToLower(request.Mode)) != "openclaw" {
return ""
}
gatewayURL := resolveURL(server.config.Upstream.GatewayURL, "GATEWAY_RPC_URL")
if gatewayURL == "" {
return "127.0.0.1:18789"
}
return publicEndpointAddressLabel(gatewayURL)
}
func publicEndpointAddressLabel(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || strings.TrimSpace(parsed.Hostname()) == "" {
return ""
}
host := strings.TrimSpace(parsed.Hostname())
port := strings.TrimSpace(parsed.Port())
if port == "" {
switch strings.TrimSpace(strings.ToLower(parsed.Scheme)) {
case "https", "wss":
port = "443"
case "http", "ws":
port = "80"
}
}
if port == "" {
return host
}
return host + ":" + port
}
func stringValue(value any) string {
if value == nil {
return ""
}
switch typed := value.(type) {
case string:
return typed
default:
return fmt.Sprint(typed)
}
}
func asMap(value any) map[string]any {
if value == nil {
return nil
}
if typed, ok := value.(map[string]any); ok {
return typed
}
if typed, ok := value.(map[string]interface{}); ok {
return typed
}
return nil
}
func cloneMap(source map[string]any) map[string]any {
if source == nil {
return nil
}
result := make(map[string]any, len(source))
for key, value := range source {
result[key] = value
}
return result
}
func cloneMapSlice(source []map[string]any) []map[string]any {
if source == nil {
return nil
}
result := make([]map[string]any, 0, len(source))
for _, item := range source {
result = append(result, cloneMap(item))
}
return result
}
func parseSkillsCandidates(raw []any) []skills.Candidate {
result := make([]skills.Candidate, 0, len(raw))
for _, item := range raw {
m := asMap(item)
if m == nil {
continue
}
result = append(result, skills.Candidate{
ID: shared.StringArg(m, "id", ""),
Label: shared.StringArg(m, "label", ""),
Description: shared.StringArg(m, "description", ""),
Installed: parseBool(m["installed"]),
})
}
return result
}