mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-15 00:27:29 +08:00
feat: expand node agent routing and media artifacts
This commit is contained in:
@@ -251,7 +251,19 @@ func (m *Manager) SupportsAction(nodeID, action string) bool {
|
||||
if !ok || !n.Online {
|
||||
return false
|
||||
}
|
||||
action = strings.ToLower(strings.TrimSpace(action))
|
||||
return nodeSupportsRequest(n, Request{Action: action})
|
||||
}
|
||||
|
||||
func (m *Manager) SupportsRequest(nodeID string, req Request) bool {
|
||||
n, ok := m.Get(nodeID)
|
||||
if !ok || !n.Online {
|
||||
return false
|
||||
}
|
||||
return nodeSupportsRequest(n, req)
|
||||
}
|
||||
|
||||
func nodeSupportsRequest(n NodeInfo, req Request) bool {
|
||||
action := strings.ToLower(strings.TrimSpace(req.Action))
|
||||
if len(n.Actions) > 0 {
|
||||
allowed := false
|
||||
for _, a := range n.Actions {
|
||||
@@ -283,44 +295,112 @@ func (m *Manager) SupportsAction(nodeID, action string) bool {
|
||||
}
|
||||
|
||||
func (m *Manager) PickFor(action string) (NodeInfo, bool) {
|
||||
return m.PickRequest(Request{Action: action}, "auto")
|
||||
}
|
||||
|
||||
func (m *Manager) PickRequest(req Request, mode string) (NodeInfo, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
bestScore := -1
|
||||
bestNode := NodeInfo{}
|
||||
for _, n := range m.nodes {
|
||||
if !n.Online {
|
||||
score, ok := scoreNodeCandidate(n, req, mode, m.senders[strings.TrimSpace(n.ID)] != nil)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "run":
|
||||
if n.Capabilities.Run {
|
||||
return n, true
|
||||
}
|
||||
case "agent_task":
|
||||
if n.Capabilities.Model {
|
||||
return n, true
|
||||
}
|
||||
case "camera_snap", "camera_clip":
|
||||
if n.Capabilities.Camera {
|
||||
return n, true
|
||||
}
|
||||
case "screen_record", "screen_snapshot":
|
||||
if n.Capabilities.Screen {
|
||||
return n, true
|
||||
}
|
||||
case "location_get":
|
||||
if n.Capabilities.Location {
|
||||
return n, true
|
||||
}
|
||||
case "canvas_snapshot", "canvas_action":
|
||||
if n.Capabilities.Canvas {
|
||||
return n, true
|
||||
}
|
||||
default:
|
||||
if n.Capabilities.Invoke {
|
||||
return n, true
|
||||
}
|
||||
if score > bestScore || (score == bestScore && bestNode.ID != "" && n.LastSeenAt.After(bestNode.LastSeenAt)) {
|
||||
bestScore = score
|
||||
bestNode = n
|
||||
}
|
||||
}
|
||||
return NodeInfo{}, false
|
||||
if bestScore < 0 || strings.TrimSpace(bestNode.ID) == "" {
|
||||
return NodeInfo{}, false
|
||||
}
|
||||
return bestNode, true
|
||||
}
|
||||
|
||||
func scoreNodeCandidate(n NodeInfo, req Request, mode string, hasWireSender bool) (int, bool) {
|
||||
if !n.Online {
|
||||
return 0, false
|
||||
}
|
||||
if !nodeSupportsRequest(n, req) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
mode = strings.ToLower(strings.TrimSpace(mode))
|
||||
if mode == "p2p" && !hasWireSender {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
score := 100
|
||||
if hasWireSender {
|
||||
score += 30
|
||||
}
|
||||
if prefersRealtimeTransport(req.Action) && hasWireSender {
|
||||
score += 40
|
||||
}
|
||||
if mode == "relay" && hasWireSender {
|
||||
score -= 10
|
||||
}
|
||||
if mode == "p2p" && hasWireSender {
|
||||
score += 80
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(req.Action), "agent_task") {
|
||||
remoteAgentID := requestedRemoteAgentID(req.Args)
|
||||
switch {
|
||||
case remoteAgentID == "", remoteAgentID == "main":
|
||||
score += 20
|
||||
case nodeHasAgent(n, remoteAgentID):
|
||||
score += 80
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if !n.LastSeenAt.IsZero() {
|
||||
ageSeconds := int(time.Since(n.LastSeenAt).Seconds())
|
||||
if ageSeconds < 0 {
|
||||
ageSeconds = 0
|
||||
}
|
||||
if ageSeconds < 60 {
|
||||
score += 20
|
||||
} else if ageSeconds < 300 {
|
||||
score += 5
|
||||
}
|
||||
}
|
||||
return score, true
|
||||
}
|
||||
|
||||
func requestedRemoteAgentID(args map[string]interface{}) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
value, ok := args["remote_agent_id"]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
|
||||
}
|
||||
|
||||
func nodeHasAgent(n NodeInfo, agentID string) bool {
|
||||
agentID = strings.ToLower(strings.TrimSpace(agentID))
|
||||
if agentID == "" {
|
||||
return false
|
||||
}
|
||||
for _, agent := range n.Agents {
|
||||
if strings.ToLower(strings.TrimSpace(agent.ID)) == agentID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prefersRealtimeTransport(action string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "camera_snap", "camera_clip", "screen_record", "screen_snapshot", "canvas_snapshot", "canvas_action":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) reaperLoop() {
|
||||
|
||||
76
pkg/nodes/manager_test.go
Normal file
76
pkg/nodes/manager_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPickRequestPrefersMatchingRemoteAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := NewManager()
|
||||
now := time.Now().UTC()
|
||||
manager.Upsert(NodeInfo{
|
||||
ID: "node-main-only",
|
||||
Online: true,
|
||||
LastSeenAt: now,
|
||||
Capabilities: Capabilities{
|
||||
Model: true,
|
||||
},
|
||||
Agents: []AgentInfo{{ID: "main"}},
|
||||
})
|
||||
manager.Upsert(NodeInfo{
|
||||
ID: "node-coder",
|
||||
Online: true,
|
||||
LastSeenAt: now,
|
||||
Capabilities: Capabilities{
|
||||
Model: true,
|
||||
},
|
||||
Agents: []AgentInfo{{ID: "main"}, {ID: "coder"}},
|
||||
})
|
||||
|
||||
picked, ok := manager.PickRequest(Request{
|
||||
Action: "agent_task",
|
||||
Args: map[string]interface{}{"remote_agent_id": "coder"},
|
||||
}, "auto")
|
||||
if !ok {
|
||||
t.Fatalf("expected node pick")
|
||||
}
|
||||
if picked.ID != "node-coder" {
|
||||
t.Fatalf("expected node-coder, got %+v", picked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRequestPrefersRealtimeCapableNodeForScreenActions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := NewManager()
|
||||
now := time.Now().UTC()
|
||||
manager.Upsert(NodeInfo{
|
||||
ID: "relay-only",
|
||||
Online: true,
|
||||
LastSeenAt: now.Add(-2 * time.Minute),
|
||||
Capabilities: Capabilities{
|
||||
Screen: true,
|
||||
},
|
||||
Actions: []string{"screen_snapshot"},
|
||||
})
|
||||
manager.Upsert(NodeInfo{
|
||||
ID: "p2p-ready",
|
||||
Online: true,
|
||||
LastSeenAt: now,
|
||||
Capabilities: Capabilities{
|
||||
Screen: true,
|
||||
},
|
||||
Actions: []string{"screen_snapshot"},
|
||||
})
|
||||
manager.RegisterWireSender("p2p-ready", &captureWireSender{})
|
||||
|
||||
picked, ok := manager.PickRequest(Request{Action: "screen_snapshot"}, "auto")
|
||||
if !ok {
|
||||
t.Fatalf("expected node pick")
|
||||
}
|
||||
if picked.ID != "p2p-ready" {
|
||||
t.Fatalf("expected p2p-ready, got %+v", picked)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -211,5 +212,101 @@ func normalizeDevicePayload(action string, payload map[string]interface{}) map[s
|
||||
if _, ok := payload["meta"]; !ok {
|
||||
payload["meta"] = map[string]interface{}{}
|
||||
}
|
||||
payload["artifacts"] = normalizeArtifacts(payload, a)
|
||||
return payload
|
||||
}
|
||||
|
||||
func normalizeArtifacts(payload map[string]interface{}, action string) []map[string]interface{} {
|
||||
if payload == nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
if raw, ok := payload["artifacts"]; ok {
|
||||
items := normalizeArtifactList(raw)
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
artifact := map[string]interface{}{}
|
||||
if mediaType, _ := payload["media_type"].(string); strings.TrimSpace(mediaType) != "" {
|
||||
artifact["kind"] = strings.TrimSpace(mediaType)
|
||||
}
|
||||
if mimeType, _ := payload["mime_type"].(string); strings.TrimSpace(mimeType) != "" {
|
||||
artifact["mime_type"] = strings.TrimSpace(mimeType)
|
||||
}
|
||||
if storage, _ := payload["storage"].(string); strings.TrimSpace(storage) != "" {
|
||||
artifact["storage"] = strings.TrimSpace(storage)
|
||||
}
|
||||
if path, _ := payload["path"].(string); strings.TrimSpace(path) != "" {
|
||||
artifact["path"] = filepath.Clean(strings.TrimSpace(path))
|
||||
}
|
||||
if url, _ := payload["url"].(string); strings.TrimSpace(url) != "" {
|
||||
artifact["url"] = strings.TrimSpace(url)
|
||||
}
|
||||
if image, _ := payload["image"].(string); strings.TrimSpace(image) != "" {
|
||||
artifact["content_base64"] = strings.TrimSpace(image)
|
||||
}
|
||||
if text, _ := payload["content_text"].(string); strings.TrimSpace(text) != "" {
|
||||
artifact["content_text"] = text
|
||||
}
|
||||
if name, _ := payload["name"].(string); strings.TrimSpace(name) != "" {
|
||||
artifact["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if size := int64FromPayload(payload["size_bytes"]); size > 0 {
|
||||
artifact["size_bytes"] = size
|
||||
}
|
||||
if len(artifact) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
if _, ok := artifact["kind"]; !ok && strings.TrimSpace(action) != "" {
|
||||
artifact["kind"] = strings.ToLower(strings.TrimSpace(action))
|
||||
}
|
||||
return []map[string]interface{}{artifact}
|
||||
}
|
||||
|
||||
func normalizeArtifactList(raw interface{}) []map[string]interface{} {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
row, ok := item.(map[string]interface{})
|
||||
if !ok || len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
normalized := map[string]interface{}{}
|
||||
for _, key := range []string{"id", "name", "kind", "mime_type", "storage", "path", "url", "content_text", "content_base64", "source_path"} {
|
||||
if value, ok := row[key]; ok && strings.TrimSpace(fmt.Sprint(value)) != "" {
|
||||
normalized[key] = value
|
||||
}
|
||||
}
|
||||
if truncated, ok := row["truncated"].(bool); ok && truncated {
|
||||
normalized["truncated"] = true
|
||||
}
|
||||
if size := int64FromPayload(row["size_bytes"]); size > 0 {
|
||||
normalized["size_bytes"] = size
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func int64FromPayload(v interface{}) int64 {
|
||||
switch value := v.(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float64:
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
n, _ := value.Int64()
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,24 @@ func TestWebsocketP2PTransportSend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDevicePayloadBuildsArtifacts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := normalizeDevicePayload("screen_snapshot", map[string]interface{}{
|
||||
"media_type": "image",
|
||||
"storage": "path",
|
||||
"path": "/tmp/screen.png",
|
||||
"mime_type": "image/png",
|
||||
})
|
||||
artifacts, ok := payload["artifacts"].([]map[string]interface{})
|
||||
if !ok || len(artifacts) != 1 {
|
||||
t.Fatalf("expected one artifact, got %+v", payload["artifacts"])
|
||||
}
|
||||
if artifacts[0]["kind"] != "image" || artifacts[0]["path"] != "/tmp/screen.png" {
|
||||
t.Fatalf("unexpected artifact payload: %+v", artifacts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRTCTransportSendEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -13,6 +13,31 @@ type Capabilities struct {
|
||||
Canvas bool `json:"canvas"`
|
||||
}
|
||||
|
||||
// AgentInfo describes an enabled agent exposed by a remote clawgo node.
|
||||
type AgentInfo struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
ParentAgentID string `json:"parent_agent_id,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact describes a file/media payload returned from a node action.
|
||||
type Artifact struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
MIMEType string `json:"mime_type,omitempty"`
|
||||
Storage string `json:"storage,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ContentText string `json:"content_text,omitempty"`
|
||||
ContentB64 string `json:"content_base64,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
SourcePath string `json:"source_path,omitempty"`
|
||||
}
|
||||
|
||||
// NodeInfo is the runtime descriptor for cross-device scheduling.
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id"`
|
||||
@@ -25,6 +50,7 @@ type NodeInfo struct {
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Agents []AgentInfo `json:"agents,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at,omitempty"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
Online bool `json:"online"`
|
||||
@@ -51,15 +77,15 @@ type Response struct {
|
||||
|
||||
// WireMessage is the websocket envelope for node lifecycle messages.
|
||||
type WireMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Node *NodeInfo `json:"node,omitempty"`
|
||||
Request *Request `json:"request,omitempty"`
|
||||
Response *Response `json:"response,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Session string `json:"session,omitempty"`
|
||||
Node *NodeInfo `json:"node,omitempty"`
|
||||
Request *Request `json:"request,omitempty"`
|
||||
Response *Response `json:"response,omitempty"`
|
||||
Payload map[string]interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// WireAck is the websocket response envelope for node lifecycle messages.
|
||||
|
||||
Reference in New Issue
Block a user