feat: expand node agent routing and media artifacts

This commit is contained in:
lpf
2026-03-09 01:21:19 +08:00
parent c0fe977bce
commit 2d5a384342
14 changed files with 1291 additions and 81 deletions

View File

@@ -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
View 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)
}
}

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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.