mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 07:27:28 +08:00
refactor api server around rpc services
This commit is contained in:
@@ -96,6 +96,127 @@ type subagentDigestState struct {
|
||||
dueAt time.Time
|
||||
}
|
||||
|
||||
type localNodeActionHandler func(nodes.Request) nodes.Response
|
||||
|
||||
var localNodeActionHandlers = map[string]localNodeActionHandler{
|
||||
"run": handleLocalNodeRun,
|
||||
"agent_task": handleLocalNodeAgentTask,
|
||||
"camera_snap": handleLocalNodeCameraSnap,
|
||||
"camera_clip": handleLocalNodeCameraClip,
|
||||
"screen_snapshot": handleLocalNodeScreenSnapshot,
|
||||
"screen_record": handleLocalNodeScreenRecord,
|
||||
"location_get": handleLocalNodeLocationGet,
|
||||
"canvas_snapshot": handleLocalNodeCanvasSnapshot,
|
||||
"canvas_action": handleLocalNodeCanvasAction,
|
||||
}
|
||||
|
||||
var fallbackProviderPriority = map[string]int{
|
||||
"claude": 10,
|
||||
"codex": 20,
|
||||
"gemini": 30,
|
||||
"gemini-cli": 40,
|
||||
"aistudio": 50,
|
||||
"vertex": 60,
|
||||
"antigravity": 70,
|
||||
"qwen": 80,
|
||||
"kimi": 90,
|
||||
"iflow": 100,
|
||||
"openai-compatibility": 110,
|
||||
}
|
||||
|
||||
func localSimulatedPayload(extra map[string]interface{}) map[string]interface{} {
|
||||
payload := map[string]interface{}{
|
||||
"transport": "relay-local",
|
||||
"simulated": true,
|
||||
}
|
||||
for k, v := range extra {
|
||||
payload[k] = v
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func handleLocalNodeRun(req nodes.Request) nodes.Response {
|
||||
payload := localSimulatedPayload(nil)
|
||||
if cmdRaw, ok := req.Args["command"].([]interface{}); ok && len(cmdRaw) > 0 {
|
||||
parts := make([]string, 0, len(cmdRaw))
|
||||
for _, x := range cmdRaw {
|
||||
parts = append(parts, fmt.Sprint(x))
|
||||
}
|
||||
payload["command"] = parts
|
||||
}
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: payload}
|
||||
}
|
||||
|
||||
func handleLocalNodeAgentTask(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"task": req.Task,
|
||||
"result": "local child-model simulated execution completed",
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeCameraSnap(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"media_type": "image",
|
||||
"storage": "inline",
|
||||
"facing": req.Args["facing"],
|
||||
"meta": map[string]interface{}{"width": 1280, "height": 720},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeCameraClip(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"media_type": "video",
|
||||
"storage": "path",
|
||||
"path": "/tmp/camera_clip.mp4",
|
||||
"duration_ms": req.Args["duration_ms"],
|
||||
"meta": map[string]interface{}{"fps": 30},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeScreenSnapshot(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"media_type": "image",
|
||||
"storage": "inline",
|
||||
"meta": map[string]interface{}{"width": 1920, "height": 1080},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeScreenRecord(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"media_type": "video",
|
||||
"storage": "path",
|
||||
"path": "/tmp/screen_record.mp4",
|
||||
"duration_ms": req.Args["duration_ms"],
|
||||
"meta": map[string]interface{}{"fps": 30},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeLocationGet(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"lat": 0.0,
|
||||
"lng": 0.0,
|
||||
"accuracy": "simulated",
|
||||
"meta": map[string]interface{}{"provider": "simulated"},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeCanvasSnapshot(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"image": "data:image/png;base64,<simulated>",
|
||||
"media_type": "image",
|
||||
"storage": "inline",
|
||||
"meta": map[string]interface{}{"width": 1280, "height": 720},
|
||||
})}
|
||||
}
|
||||
|
||||
func handleLocalNodeCanvasAction(req nodes.Request) nodes.Response {
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: localSimulatedPayload(map[string]interface{}{
|
||||
"applied": true,
|
||||
"args": req.Args,
|
||||
})}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) SetConfigPath(path string) {
|
||||
if al == nil {
|
||||
return
|
||||
@@ -170,36 +291,10 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
|
||||
nodesManager.SetStatePath(filepath.Join(workspace, "memory", "nodes-state.json"))
|
||||
nodesManager.Upsert(nodes.NodeInfo{ID: "local", Name: "local", Capabilities: nodes.Capabilities{Run: true, Invoke: true, Model: true, Camera: true, Screen: true, Location: true, Canvas: true}, Models: []string{"local-sim"}, Online: true})
|
||||
nodesManager.RegisterHandler("local", func(req nodes.Request) nodes.Response {
|
||||
switch req.Action {
|
||||
case "run":
|
||||
payload := map[string]interface{}{"transport": "relay-local", "simulated": true}
|
||||
if cmdRaw, ok := req.Args["command"].([]interface{}); ok && len(cmdRaw) > 0 {
|
||||
parts := make([]string, 0, len(cmdRaw))
|
||||
for _, x := range cmdRaw {
|
||||
parts = append(parts, fmt.Sprint(x))
|
||||
}
|
||||
payload["command"] = parts
|
||||
}
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: payload}
|
||||
case "agent_task":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "simulated": true, "model": req.Model, "task": req.Task, "result": "local child-model simulated execution completed"}}
|
||||
case "camera_snap":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "image", "storage": "inline", "facing": req.Args["facing"], "simulated": true, "meta": map[string]interface{}{"width": 1280, "height": 720}}}
|
||||
case "camera_clip":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "video", "storage": "path", "path": "/tmp/camera_clip.mp4", "duration_ms": req.Args["duration_ms"], "simulated": true, "meta": map[string]interface{}{"fps": 30}}}
|
||||
case "screen_snapshot":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "image", "storage": "inline", "simulated": true, "meta": map[string]interface{}{"width": 1920, "height": 1080}}}
|
||||
case "screen_record":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "media_type": "video", "storage": "path", "path": "/tmp/screen_record.mp4", "duration_ms": req.Args["duration_ms"], "simulated": true, "meta": map[string]interface{}{"fps": 30}}}
|
||||
case "location_get":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "lat": 0.0, "lng": 0.0, "accuracy": "simulated", "meta": map[string]interface{}{"provider": "simulated"}}}
|
||||
case "canvas_snapshot":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "image": "data:image/png;base64,<simulated>", "media_type": "image", "storage": "inline", "simulated": true, "meta": map[string]interface{}{"width": 1280, "height": 720}}}
|
||||
case "canvas_action":
|
||||
return nodes.Response{OK: true, Code: "ok", Node: "local", Action: req.Action, Payload: map[string]interface{}{"transport": "relay-local", "applied": true, "simulated": true, "args": req.Args}}
|
||||
default:
|
||||
return nodes.Response{OK: false, Code: "unsupported_action", Node: "local", Action: req.Action, Error: "unsupported local simulated action"}
|
||||
if handler := localNodeActionHandlers[req.Action]; handler != nil {
|
||||
return handler(req)
|
||||
}
|
||||
return nodes.Response{OK: false, Code: "unsupported_action", Node: "local", Action: req.Action, Error: "unsupported local simulated action"}
|
||||
})
|
||||
nodeDispatchPolicy := nodes.DispatchPolicy{
|
||||
PreferLocal: cfg.Gateway.Nodes.Dispatch.PreferLocal,
|
||||
@@ -733,32 +828,10 @@ func (al *AgentLoop) ensureProviderCandidate(candidate providerCandidate) (provi
|
||||
}
|
||||
|
||||
func automaticFallbackPriority(name string) int {
|
||||
switch normalizeFallbackProviderName(name) {
|
||||
case "claude":
|
||||
return 10
|
||||
case "codex":
|
||||
return 20
|
||||
case "gemini":
|
||||
return 30
|
||||
case "gemini-cli":
|
||||
return 40
|
||||
case "aistudio":
|
||||
return 50
|
||||
case "vertex":
|
||||
return 60
|
||||
case "antigravity":
|
||||
return 70
|
||||
case "qwen":
|
||||
return 80
|
||||
case "kimi":
|
||||
return 90
|
||||
case "iflow":
|
||||
return 100
|
||||
case "openai-compatibility":
|
||||
return 110
|
||||
default:
|
||||
return 1000
|
||||
if priority, ok := fallbackProviderPriority[normalizeFallbackProviderName(name)]; ok {
|
||||
return priority
|
||||
}
|
||||
return 1000
|
||||
}
|
||||
|
||||
func normalizeFallbackProviderName(name string) string {
|
||||
@@ -871,15 +944,13 @@ func buildAuditTaskID(msg bus.InboundMessage) string {
|
||||
trigger = strings.ToLower(strings.TrimSpace(msg.Metadata["trigger"]))
|
||||
}
|
||||
sessionPart := shortSessionKey(msg.SessionKey)
|
||||
switch trigger {
|
||||
case "heartbeat":
|
||||
if trigger == "heartbeat" {
|
||||
if sessionPart == "" {
|
||||
sessionPart = "default"
|
||||
}
|
||||
return "heartbeat:" + sessionPart
|
||||
default:
|
||||
return fmt.Sprintf("%s-%d", sessionPart, time.Now().Unix()%100000)
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", sessionPart, time.Now().Unix()%100000)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) appendTaskAudit(taskID string, msg bus.InboundMessage, started time.Time, runErr error, suppressed bool) {
|
||||
@@ -2185,9 +2256,7 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel,
|
||||
if channel == "" || chatID == "" {
|
||||
return args
|
||||
}
|
||||
switch toolName {
|
||||
case "message", "spawn", "remind":
|
||||
default:
|
||||
if !toolContextEligibleTool(toolName) {
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -2254,9 +2323,7 @@ func withToolMemoryNamespaceArgs(toolName string, args map[string]interface{}, n
|
||||
if ns == "main" {
|
||||
return args
|
||||
}
|
||||
switch strings.TrimSpace(toolName) {
|
||||
case "memory_search", "memory_get", "memory_write":
|
||||
default:
|
||||
if !toolNeedsMemoryNamespace(toolName) {
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -2356,12 +2423,34 @@ func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]in
|
||||
}
|
||||
|
||||
func isImplicitlyAllowedSubagentTool(name string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "skill_exec":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
_, ok := implicitSubagentToolSet[strings.ToLower(strings.TrimSpace(name))]
|
||||
return ok
|
||||
}
|
||||
|
||||
var toolContextEligibleSet = map[string]struct{}{
|
||||
"message": {},
|
||||
"spawn": {},
|
||||
"remind": {},
|
||||
}
|
||||
|
||||
func toolContextEligibleTool(name string) bool {
|
||||
_, ok := toolContextEligibleSet[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
var toolMemoryNamespaceSet = map[string]struct{}{
|
||||
"memory_search": {},
|
||||
"memory_get": {},
|
||||
"memory_write": {},
|
||||
}
|
||||
|
||||
func toolNeedsMemoryNamespace(name string) bool {
|
||||
_, ok := toolMemoryNamespaceSet[strings.TrimSpace(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
var implicitSubagentToolSet = map[string]struct{}{
|
||||
"skill_exec": {},
|
||||
}
|
||||
|
||||
func normalizeToolAllowlist(in []string) map[string]struct{} {
|
||||
|
||||
@@ -15,6 +15,12 @@ import (
|
||||
"github.com/YspCoder/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
var subagentRuntimeActionAliases = map[string]string{
|
||||
"info": "get",
|
||||
"create": "spawn",
|
||||
"trace": "thread",
|
||||
}
|
||||
|
||||
func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) {
|
||||
if al == nil || al.subagentManager == nil {
|
||||
return nil, fmt.Errorf("subagent runtime is not configured")
|
||||
@@ -26,330 +32,384 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
if action == "" {
|
||||
action = "list"
|
||||
}
|
||||
if canonical := subagentRuntimeActionAliases[action]; canonical != "" {
|
||||
action = canonical
|
||||
}
|
||||
handler := al.subagentRuntimeHandlers()[action]
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("unsupported action: %s", action)
|
||||
}
|
||||
return handler(ctx, args)
|
||||
}
|
||||
|
||||
type runtimeAdminHandler func(context.Context, map[string]interface{}) (interface{}, error)
|
||||
|
||||
func (al *AgentLoop) subagentRuntimeHandlers() map[string]runtimeAdminHandler {
|
||||
sm := al.subagentManager
|
||||
router := al.subagentRouter
|
||||
switch action {
|
||||
case "list":
|
||||
tasks := sm.ListTasks()
|
||||
items := make([]*tools.SubagentTask, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
items = append(items, cloneSubagentTask(task))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Created > items[j].Created })
|
||||
return map[string]interface{}{"items": items}, nil
|
||||
case "snapshot":
|
||||
limit := runtimeIntArg(args, "limit", 100)
|
||||
return map[string]interface{}{"snapshot": sm.RuntimeSnapshot(limit)}, nil
|
||||
case "get", "info":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
return map[string]interface{}{"found": true, "task": cloneSubagentTask(task)}, nil
|
||||
case "spawn", "create":
|
||||
taskInput := runtimeStringArg(args, "task")
|
||||
if taskInput == "" {
|
||||
return nil, fmt.Errorf("task is required")
|
||||
}
|
||||
msg, err := sm.Spawn(ctx, tools.SubagentSpawnOptions{
|
||||
Task: taskInput,
|
||||
Label: runtimeStringArg(args, "label"),
|
||||
Role: runtimeStringArg(args, "role"),
|
||||
AgentID: runtimeStringArg(args, "agent_id"),
|
||||
MaxRetries: runtimeIntArg(args, "max_retries", 0),
|
||||
RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: runtimeIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: runtimeIntArg(args, "max_result_chars", 0),
|
||||
OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"),
|
||||
OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"message": msg}, nil
|
||||
case "dispatch_and_wait":
|
||||
taskInput := runtimeStringArg(args, "task")
|
||||
if taskInput == "" {
|
||||
return nil, fmt.Errorf("task is required")
|
||||
}
|
||||
task, err := router.DispatchTask(ctx, tools.RouterDispatchRequest{
|
||||
Task: taskInput,
|
||||
Label: runtimeStringArg(args, "label"),
|
||||
Role: runtimeStringArg(args, "role"),
|
||||
AgentID: runtimeStringArg(args, "agent_id"),
|
||||
NotifyMainPolicy: "internal_only",
|
||||
ThreadID: runtimeStringArg(args, "thread_id"),
|
||||
CorrelationID: runtimeStringArg(args, "correlation_id"),
|
||||
ParentRunID: runtimeStringArg(args, "parent_run_id"),
|
||||
OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"),
|
||||
OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"),
|
||||
MaxRetries: runtimeIntArg(args, "max_retries", 0),
|
||||
RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: runtimeIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: runtimeIntArg(args, "max_result_chars", 0),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waitTimeoutSec := runtimeIntArg(args, "wait_timeout_sec", 120)
|
||||
waitCtx := ctx
|
||||
var cancel context.CancelFunc
|
||||
if waitTimeoutSec > 0 {
|
||||
waitCtx, cancel = context.WithTimeout(ctx, time.Duration(waitTimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
reply, err := router.WaitReply(waitCtx, task.ID, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"task": cloneSubagentTask(task),
|
||||
"reply": reply,
|
||||
"merged": router.MergeResults([]*tools.RouterReply{reply}),
|
||||
}, nil
|
||||
case "registry":
|
||||
cfg := runtimecfg.Get()
|
||||
items := make([]map[string]interface{}, 0)
|
||||
if cfg != nil {
|
||||
items = make([]map[string]interface{}, 0, len(cfg.Agents.Subagents))
|
||||
for agentID, subcfg := range cfg.Agents.Subagents {
|
||||
promptFileFound := false
|
||||
if strings.TrimSpace(subcfg.SystemPromptFile) != "" {
|
||||
if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil {
|
||||
if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() {
|
||||
promptFileFound = true
|
||||
return map[string]runtimeAdminHandler{
|
||||
"list": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
tasks := sm.ListTasks()
|
||||
items := make([]*tools.SubagentTask, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
items = append(items, cloneSubagentTask(task))
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Created > items[j].Created })
|
||||
return map[string]interface{}{"items": items}, nil
|
||||
},
|
||||
"snapshot": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
limit := runtimeIntArg(args, "limit", 100)
|
||||
return map[string]interface{}{"snapshot": sm.RuntimeSnapshot(limit)}, nil
|
||||
},
|
||||
"get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
return map[string]interface{}{"found": true, "task": cloneSubagentTask(task)}, nil
|
||||
},
|
||||
"spawn": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskInput := runtimeStringArg(args, "task")
|
||||
if taskInput == "" {
|
||||
return nil, fmt.Errorf("task is required")
|
||||
}
|
||||
msg, err := sm.Spawn(ctx, tools.SubagentSpawnOptions{
|
||||
Task: taskInput,
|
||||
Label: runtimeStringArg(args, "label"),
|
||||
Role: runtimeStringArg(args, "role"),
|
||||
AgentID: runtimeStringArg(args, "agent_id"),
|
||||
MaxRetries: runtimeIntArg(args, "max_retries", 0),
|
||||
RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: runtimeIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: runtimeIntArg(args, "max_result_chars", 0),
|
||||
OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"),
|
||||
OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"message": msg}, nil
|
||||
},
|
||||
"dispatch_and_wait": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskInput := runtimeStringArg(args, "task")
|
||||
if taskInput == "" {
|
||||
return nil, fmt.Errorf("task is required")
|
||||
}
|
||||
task, err := router.DispatchTask(ctx, tools.RouterDispatchRequest{
|
||||
Task: taskInput,
|
||||
Label: runtimeStringArg(args, "label"),
|
||||
Role: runtimeStringArg(args, "role"),
|
||||
AgentID: runtimeStringArg(args, "agent_id"),
|
||||
NotifyMainPolicy: "internal_only",
|
||||
ThreadID: runtimeStringArg(args, "thread_id"),
|
||||
CorrelationID: runtimeStringArg(args, "correlation_id"),
|
||||
ParentRunID: runtimeStringArg(args, "parent_run_id"),
|
||||
OriginChannel: fallbackString(runtimeStringArg(args, "channel"), "webui"),
|
||||
OriginChatID: fallbackString(runtimeStringArg(args, "chat_id"), "webui"),
|
||||
MaxRetries: runtimeIntArg(args, "max_retries", 0),
|
||||
RetryBackoff: runtimeIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: runtimeIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: runtimeIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: runtimeIntArg(args, "max_result_chars", 0),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
waitTimeoutSec := runtimeIntArg(args, "wait_timeout_sec", 120)
|
||||
waitCtx := ctx
|
||||
var cancel context.CancelFunc
|
||||
if waitTimeoutSec > 0 {
|
||||
waitCtx, cancel = context.WithTimeout(ctx, time.Duration(waitTimeoutSec)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
reply, err := router.WaitReply(waitCtx, task.ID, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"task": cloneSubagentTask(task),
|
||||
"reply": reply,
|
||||
"merged": router.MergeResults([]*tools.RouterReply{reply}),
|
||||
}, nil
|
||||
},
|
||||
"registry": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
cfg := runtimecfg.Get()
|
||||
items := make([]map[string]interface{}, 0)
|
||||
if cfg != nil {
|
||||
items = make([]map[string]interface{}, 0, len(cfg.Agents.Subagents))
|
||||
for agentID, subcfg := range cfg.Agents.Subagents {
|
||||
promptFileFound := false
|
||||
if strings.TrimSpace(subcfg.SystemPromptFile) != "" {
|
||||
if absPath, err := al.resolvePromptFilePath(subcfg.SystemPromptFile); err == nil {
|
||||
if info, statErr := os.Stat(absPath); statErr == nil && !info.IsDir() {
|
||||
promptFileFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
toolInfo := al.describeSubagentTools(subcfg.Tools.Allowlist)
|
||||
items = append(items, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"enabled": subcfg.Enabled,
|
||||
"type": subcfg.Type,
|
||||
"transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"),
|
||||
"node_id": strings.TrimSpace(subcfg.NodeID),
|
||||
"parent_agent_id": strings.TrimSpace(subcfg.ParentAgentID),
|
||||
"notify_main_policy": fallbackString(strings.TrimSpace(subcfg.NotifyMainPolicy), "final_only"),
|
||||
"display_name": subcfg.DisplayName,
|
||||
"role": subcfg.Role,
|
||||
"description": subcfg.Description,
|
||||
"system_prompt_file": subcfg.SystemPromptFile,
|
||||
"prompt_file_found": promptFileFound,
|
||||
"memory_namespace": subcfg.MemoryNamespace,
|
||||
"tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...),
|
||||
"tool_visibility": toolInfo,
|
||||
"effective_tools": toolInfo["effective_tools"],
|
||||
"inherited_tools": toolInfo["inherited_tools"],
|
||||
"routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID),
|
||||
"managed_by": "config.json",
|
||||
})
|
||||
}
|
||||
}
|
||||
if store := sm.ProfileStore(); store != nil {
|
||||
if profiles, err := store.List(); err == nil {
|
||||
for _, profile := range profiles {
|
||||
if strings.TrimSpace(profile.ManagedBy) != "node_registry" {
|
||||
continue
|
||||
}
|
||||
toolInfo := al.describeSubagentTools(profile.ToolAllowlist)
|
||||
toolInfo := al.describeSubagentTools(subcfg.Tools.Allowlist)
|
||||
items = append(items, map[string]interface{}{
|
||||
"agent_id": profile.AgentID,
|
||||
"enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"),
|
||||
"type": "node_branch",
|
||||
"transport": profile.Transport,
|
||||
"node_id": profile.NodeID,
|
||||
"parent_agent_id": profile.ParentAgentID,
|
||||
"notify_main_policy": fallbackString(strings.TrimSpace(profile.NotifyMainPolicy), "final_only"),
|
||||
"display_name": profile.Name,
|
||||
"role": profile.Role,
|
||||
"description": "Node-registered remote main agent branch",
|
||||
"system_prompt_file": profile.SystemPromptFile,
|
||||
"prompt_file_found": false,
|
||||
"memory_namespace": profile.MemoryNamespace,
|
||||
"tool_allowlist": append([]string(nil), profile.ToolAllowlist...),
|
||||
"agent_id": agentID,
|
||||
"enabled": subcfg.Enabled,
|
||||
"type": subcfg.Type,
|
||||
"transport": fallbackString(strings.TrimSpace(subcfg.Transport), "local"),
|
||||
"node_id": strings.TrimSpace(subcfg.NodeID),
|
||||
"parent_agent_id": strings.TrimSpace(subcfg.ParentAgentID),
|
||||
"notify_main_policy": fallbackString(strings.TrimSpace(subcfg.NotifyMainPolicy), "final_only"),
|
||||
"display_name": subcfg.DisplayName,
|
||||
"role": subcfg.Role,
|
||||
"description": subcfg.Description,
|
||||
"system_prompt_file": subcfg.SystemPromptFile,
|
||||
"prompt_file_found": promptFileFound,
|
||||
"memory_namespace": subcfg.MemoryNamespace,
|
||||
"tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...),
|
||||
"tool_visibility": toolInfo,
|
||||
"effective_tools": toolInfo["effective_tools"],
|
||||
"inherited_tools": toolInfo["inherited_tools"],
|
||||
"routing_keywords": []string{},
|
||||
"managed_by": profile.ManagedBy,
|
||||
"routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID),
|
||||
"managed_by": "config.json",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left, _ := items[i]["agent_id"].(string)
|
||||
right, _ := items[j]["agent_id"].(string)
|
||||
return left < right
|
||||
})
|
||||
return map[string]interface{}{"items": items}, nil
|
||||
case "set_config_subagent_enabled":
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be disabled", agentID)
|
||||
}
|
||||
enabled, ok := runtimeBoolArg(args, "enabled")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("enabled is required")
|
||||
}
|
||||
return tools.UpsertConfigSubagent(al.configPath, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"enabled": enabled,
|
||||
})
|
||||
case "delete_config_subagent":
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be deleted", agentID)
|
||||
}
|
||||
return tools.DeleteConfigSubagent(al.configPath, agentID)
|
||||
case "upsert_config_subagent":
|
||||
return tools.UpsertConfigSubagent(al.configPath, args)
|
||||
case "prompt_file_get":
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]interface{}{"found": false, "path": relPath, "content": ""}, nil
|
||||
if store := sm.ProfileStore(); store != nil {
|
||||
if profiles, err := store.List(); err == nil {
|
||||
for _, profile := range profiles {
|
||||
if strings.TrimSpace(profile.ManagedBy) != "node_registry" {
|
||||
continue
|
||||
}
|
||||
toolInfo := al.describeSubagentTools(profile.ToolAllowlist)
|
||||
items = append(items, map[string]interface{}{
|
||||
"agent_id": profile.AgentID,
|
||||
"enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"),
|
||||
"type": "node_branch",
|
||||
"transport": profile.Transport,
|
||||
"node_id": profile.NodeID,
|
||||
"parent_agent_id": profile.ParentAgentID,
|
||||
"notify_main_policy": fallbackString(strings.TrimSpace(profile.NotifyMainPolicy), "final_only"),
|
||||
"display_name": profile.Name,
|
||||
"role": profile.Role,
|
||||
"description": "Node-registered remote main agent branch",
|
||||
"system_prompt_file": profile.SystemPromptFile,
|
||||
"prompt_file_found": false,
|
||||
"memory_namespace": profile.MemoryNamespace,
|
||||
"tool_allowlist": append([]string(nil), profile.ToolAllowlist...),
|
||||
"tool_visibility": toolInfo,
|
||||
"effective_tools": toolInfo["effective_tools"],
|
||||
"inherited_tools": toolInfo["inherited_tools"],
|
||||
"routing_keywords": []string{},
|
||||
"managed_by": profile.ManagedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "path": relPath, "content": string(data)}, nil
|
||||
case "prompt_file_set":
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
content := runtimeRawStringArg(args, "content")
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "path": relPath, "bytes": len(content)}, nil
|
||||
case "prompt_file_bootstrap":
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("agents", agentID, "AGENT.md"))
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overwrite, _ := args["overwrite"].(bool)
|
||||
if _, err := os.Stat(absPath); err == nil && !overwrite {
|
||||
data, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left, _ := items[i]["agent_id"].(string)
|
||||
right, _ := items[j]["agent_id"].(string)
|
||||
return left < right
|
||||
})
|
||||
return map[string]interface{}{"items": items}, nil
|
||||
},
|
||||
"set_config_subagent_enabled": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be disabled", agentID)
|
||||
}
|
||||
enabled, ok := runtimeBoolArg(args, "enabled")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("enabled is required")
|
||||
}
|
||||
return tools.UpsertConfigSubagent(al.configPath, map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"enabled": enabled,
|
||||
})
|
||||
},
|
||||
"delete_config_subagent": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
if al.isProtectedMainAgent(agentID) {
|
||||
return nil, fmt.Errorf("main agent %q cannot be deleted", agentID)
|
||||
}
|
||||
return tools.DeleteConfigSubagent(al.configPath, agentID)
|
||||
},
|
||||
"upsert_config_subagent": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
return tools.UpsertConfigSubagent(al.configPath, args)
|
||||
},
|
||||
"prompt_file_get": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]interface{}{"found": false, "path": relPath, "content": ""}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "path": relPath, "content": string(data)}, nil
|
||||
},
|
||||
"prompt_file_set": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
return nil, fmt.Errorf("path is required")
|
||||
}
|
||||
content := runtimeRawStringArg(args, "content")
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "path": relPath, "bytes": len(content)}, nil
|
||||
},
|
||||
"prompt_file_bootstrap": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
relPath := runtimeStringArg(args, "path")
|
||||
if relPath == "" {
|
||||
relPath = filepath.ToSlash(filepath.Join("agents", agentID, "AGENT.md"))
|
||||
}
|
||||
absPath, err := al.resolvePromptFilePath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overwrite, _ := args["overwrite"].(bool)
|
||||
if _, err := os.Stat(absPath); err == nil && !overwrite {
|
||||
data, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"created": false,
|
||||
"path": relPath,
|
||||
"content": string(data),
|
||||
}, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := buildPromptTemplate(agentID, runtimeStringArg(args, "role"), runtimeStringArg(args, "display_name"))
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"created": false,
|
||||
"created": true,
|
||||
"path": relPath,
|
||||
"content": string(data),
|
||||
"content": content,
|
||||
}, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := buildPromptTemplate(agentID, runtimeStringArg(args, "role"), runtimeStringArg(args, "display_name"))
|
||||
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ok": true,
|
||||
"created": true,
|
||||
"path": relPath,
|
||||
"content": content,
|
||||
}, nil
|
||||
case "kill":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok := sm.KillTask(taskID)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
case "resume":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label, ok := sm.ResumeTask(ctx, taskID)
|
||||
return map[string]interface{}{"ok": ok, "label": label}, nil
|
||||
case "steer":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.SteerTask(taskID, msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
case "send":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.SendTaskMessage(taskID, msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
case "reply":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.ReplyToTask(taskID, runtimeStringArg(args, "message_id"), msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
case "ack":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageID := runtimeStringArg(args, "message_id")
|
||||
if messageID == "" {
|
||||
return nil, fmt.Errorf("message_id is required")
|
||||
}
|
||||
ok := sm.AckTaskMessage(taskID, messageID)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
case "thread", "trace":
|
||||
threadID := runtimeStringArg(args, "thread_id")
|
||||
if threadID == "" {
|
||||
},
|
||||
"kill": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok := sm.KillTask(taskID)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
},
|
||||
"resume": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
label, ok := sm.ResumeTask(ctx, taskID)
|
||||
return map[string]interface{}{"ok": ok, "label": label}, nil
|
||||
},
|
||||
"steer": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.SteerTask(taskID, msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
},
|
||||
"send": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.SendTaskMessage(taskID, msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
},
|
||||
"reply": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg := runtimeStringArg(args, "message")
|
||||
if msg == "" {
|
||||
return nil, fmt.Errorf("message is required")
|
||||
}
|
||||
ok := sm.ReplyToTask(taskID, runtimeStringArg(args, "message_id"), msg)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
},
|
||||
"ack": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messageID := runtimeStringArg(args, "message_id")
|
||||
if messageID == "" {
|
||||
return nil, fmt.Errorf("message_id is required")
|
||||
}
|
||||
ok := sm.AckTaskMessage(taskID, messageID)
|
||||
return map[string]interface{}{"ok": ok}, nil
|
||||
},
|
||||
"thread": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
threadID := runtimeStringArg(args, "thread_id")
|
||||
if threadID == "" {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
threadID = strings.TrimSpace(task.ThreadID)
|
||||
}
|
||||
if threadID == "" {
|
||||
return nil, fmt.Errorf("thread_id is required")
|
||||
}
|
||||
thread, ok := sm.Thread(threadID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
items, err := sm.ThreadMessages(threadID, runtimeIntArg(args, "limit", 50))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "thread": thread, "messages": items}, nil
|
||||
},
|
||||
"stream": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -358,74 +418,51 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
threadID = strings.TrimSpace(task.ThreadID)
|
||||
}
|
||||
if threadID == "" {
|
||||
return nil, fmt.Errorf("thread_id is required")
|
||||
}
|
||||
thread, ok := sm.Thread(threadID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
items, err := sm.ThreadMessages(threadID, runtimeIntArg(args, "limit", 50))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "thread": thread, "messages": items}, nil
|
||||
case "stream":
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
events, err := sm.Events(taskID, runtimeIntArg(args, "limit", 100))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var thread *tools.AgentThread
|
||||
var messages []tools.AgentMessage
|
||||
if strings.TrimSpace(task.ThreadID) != "" {
|
||||
if th, ok := sm.Thread(task.ThreadID); ok {
|
||||
thread = th
|
||||
}
|
||||
messages, err = sm.ThreadMessages(task.ThreadID, runtimeIntArg(args, "limit", 100))
|
||||
events, err := sm.Events(taskID, runtimeIntArg(args, "limit", 100))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stream := mergeSubagentStream(events, messages)
|
||||
return map[string]interface{}{
|
||||
"found": true,
|
||||
"task": cloneSubagentTask(task),
|
||||
"thread": thread,
|
||||
"items": stream,
|
||||
}, nil
|
||||
case "inbox":
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
var thread *tools.AgentThread
|
||||
var messages []tools.AgentMessage
|
||||
if strings.TrimSpace(task.ThreadID) != "" {
|
||||
if th, ok := sm.Thread(task.ThreadID); ok {
|
||||
thread = th
|
||||
}
|
||||
messages, err = sm.ThreadMessages(task.ThreadID, runtimeIntArg(args, "limit", 100))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stream := mergeSubagentStream(events, messages)
|
||||
return map[string]interface{}{
|
||||
"found": true,
|
||||
"task": cloneSubagentTask(task),
|
||||
"thread": thread,
|
||||
"items": stream,
|
||||
}, nil
|
||||
},
|
||||
"inbox": func(ctx context.Context, args map[string]interface{}) (interface{}, error) {
|
||||
agentID := runtimeStringArg(args, "agent_id")
|
||||
if agentID == "" {
|
||||
taskID, err := resolveSubagentTaskIDForRuntime(sm, runtimeStringArg(args, "id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
agentID = strings.TrimSpace(task.AgentID)
|
||||
}
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
items, err := sm.Inbox(agentID, runtimeIntArg(args, "limit", 50))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, ok := sm.GetTask(taskID)
|
||||
if !ok {
|
||||
return map[string]interface{}{"found": false}, nil
|
||||
}
|
||||
agentID = strings.TrimSpace(task.AgentID)
|
||||
}
|
||||
if agentID == "" {
|
||||
return nil, fmt.Errorf("agent_id is required")
|
||||
}
|
||||
items, err := sm.Inbox(agentID, runtimeIntArg(args, "limit", 50))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"found": true, "agent_id": agentID, "messages": items}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported action: %s", action)
|
||||
return map[string]interface{}{"found": true, "agent_id": agentID, "messages": items}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
380
pkg/api/rpc_http.go
Normal file
380
pkg/api/rpc_http.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
rpcpkg "github.com/YspCoder/clawgo/pkg/rpc"
|
||||
"github.com/YspCoder/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
func (s *Server) handleSubagentRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.subagentRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleNodeRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.nodeRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleProviderRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.providerRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleWorkspaceRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.workspaceRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleConfigRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.configRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleCronRPC(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleRPC(w, r, s.cronRPCRegistry())
|
||||
}
|
||||
|
||||
func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request, registry *rpcpkg.Registry) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req rpcpkg.Request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeRPCError(w, http.StatusBadRequest, req.RequestID, rpcError("invalid_argument", "invalid json", nil, false))
|
||||
return
|
||||
}
|
||||
result, rpcErr := registry.Handle(r.Context(), req)
|
||||
if rpcErr != nil {
|
||||
writeRPCError(w, rpcHTTPStatus(rpcErr), req.RequestID, rpcErr)
|
||||
return
|
||||
}
|
||||
writeJSON(w, rpcpkg.Response{
|
||||
OK: true,
|
||||
Result: result,
|
||||
RequestID: strings.TrimSpace(req.RequestID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) buildSubagentRegistry() *rpcpkg.Registry {
|
||||
svc := s.subagentRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "subagent.list", func(ctx context.Context, req rpcpkg.ListSubagentsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.List(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "subagent.snapshot", func(ctx context.Context, req rpcpkg.SnapshotRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Snapshot(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "subagent.get", func(ctx context.Context, req rpcpkg.GetSubagentRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Get(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "subagent.spawn", func(ctx context.Context, req rpcpkg.SpawnSubagentRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Spawn(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "subagent.dispatch_and_wait", func(ctx context.Context, req rpcpkg.DispatchAndWaitRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.DispatchAndWait(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "subagent.registry", func(ctx context.Context, req rpcpkg.RegistryRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Registry(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) subagentRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.subagentRPCOnce.Do(func() {
|
||||
s.subagentRPCReg = s.buildSubagentRegistry()
|
||||
})
|
||||
if s.subagentRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.subagentRPCReg
|
||||
}
|
||||
|
||||
func (s *Server) buildNodeRegistry() *rpcpkg.Registry {
|
||||
svc := s.nodeRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "node.register", func(ctx context.Context, req rpcpkg.RegisterNodeRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Register(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.heartbeat", func(ctx context.Context, req rpcpkg.HeartbeatNodeRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Heartbeat(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.dispatch", func(ctx context.Context, req rpcpkg.DispatchNodeRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Dispatch(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.artifact.list", func(ctx context.Context, req rpcpkg.ListNodeArtifactsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.ListArtifacts(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.artifact.get", func(ctx context.Context, req rpcpkg.GetNodeArtifactRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.GetArtifact(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.artifact.delete", func(ctx context.Context, req rpcpkg.DeleteNodeArtifactRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.DeleteArtifact(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "node.artifact.prune", func(ctx context.Context, req rpcpkg.PruneNodeArtifactsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.PruneArtifacts(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) nodeRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.nodeRPCOnce.Do(func() {
|
||||
s.nodeRPCReg = s.buildNodeRegistry()
|
||||
})
|
||||
if s.nodeRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.nodeRPCReg
|
||||
}
|
||||
|
||||
func (s *Server) buildProviderRegistry() *rpcpkg.Registry {
|
||||
svc := s.providerRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "provider.list_models", func(ctx context.Context, req rpcpkg.ListProviderModelsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.ListModels(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "provider.models.update", func(ctx context.Context, req rpcpkg.UpdateProviderModelsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.UpdateModels(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "provider.chat", func(ctx context.Context, req rpcpkg.ProviderChatRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Chat(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "provider.count_tokens", func(ctx context.Context, req rpcpkg.ProviderCountTokensRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.CountTokens(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "provider.runtime.view", func(ctx context.Context, req rpcpkg.ProviderRuntimeViewRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.RuntimeView(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "provider.runtime.action", func(ctx context.Context, req rpcpkg.ProviderRuntimeActionRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.RuntimeAction(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) providerRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.providerRPCOnce.Do(func() {
|
||||
s.providerRPCReg = s.buildProviderRegistry()
|
||||
})
|
||||
if s.providerRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.providerRPCReg
|
||||
}
|
||||
|
||||
func (s *Server) buildWorkspaceRegistry() *rpcpkg.Registry {
|
||||
svc := s.workspaceRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "workspace.list_files", func(ctx context.Context, req rpcpkg.ListWorkspaceFilesRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.ListFiles(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "workspace.read_file", func(ctx context.Context, req rpcpkg.ReadWorkspaceFileRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.ReadFile(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "workspace.write_file", func(ctx context.Context, req rpcpkg.WriteWorkspaceFileRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.WriteFile(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "workspace.delete_file", func(ctx context.Context, req rpcpkg.DeleteWorkspaceFileRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.DeleteFile(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) workspaceRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.workspaceRPCOnce.Do(func() {
|
||||
s.workspaceRPCReg = s.buildWorkspaceRegistry()
|
||||
})
|
||||
if s.workspaceRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.workspaceRPCReg
|
||||
}
|
||||
|
||||
func (s *Server) buildConfigRegistry() *rpcpkg.Registry {
|
||||
svc := s.configRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "config.view", func(ctx context.Context, req rpcpkg.ConfigViewRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.View(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "config.save", func(ctx context.Context, req rpcpkg.ConfigSaveRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Save(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) configRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.configRPCOnce.Do(func() {
|
||||
s.configRPCReg = s.buildConfigRegistry()
|
||||
})
|
||||
if s.configRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.configRPCReg
|
||||
}
|
||||
|
||||
func (s *Server) buildCronRegistry() *rpcpkg.Registry {
|
||||
svc := s.cronRPCService()
|
||||
reg := rpcpkg.NewRegistry()
|
||||
rpcpkg.RegisterJSON(reg, "cron.list", func(ctx context.Context, req rpcpkg.ListCronJobsRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.List(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "cron.get", func(ctx context.Context, req rpcpkg.GetCronJobRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Get(ctx, req)
|
||||
})
|
||||
rpcpkg.RegisterJSON(reg, "cron.mutate", func(ctx context.Context, req rpcpkg.MutateCronJobRequest) (interface{}, *rpcpkg.Error) {
|
||||
return svc.Mutate(ctx, req)
|
||||
})
|
||||
return reg
|
||||
}
|
||||
|
||||
func (s *Server) cronRPCRegistry() *rpcpkg.Registry {
|
||||
if s == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
s.cronRPCOnce.Do(func() {
|
||||
s.cronRPCReg = s.buildCronRegistry()
|
||||
})
|
||||
if s.cronRPCReg == nil {
|
||||
return rpcpkg.NewRegistry()
|
||||
}
|
||||
return s.cronRPCReg
|
||||
}
|
||||
|
||||
func writeRPCError(w http.ResponseWriter, status int, requestID string, rpcErr *rpcpkg.Error) {
|
||||
if rpcErr == nil {
|
||||
rpcErr = rpcError("internal", "rpc error", nil, false)
|
||||
}
|
||||
writeJSONStatus(w, status, rpcpkg.Response{
|
||||
OK: false,
|
||||
Error: rpcErr,
|
||||
RequestID: strings.TrimSpace(requestID),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubagentLegacyAction(ctx context.Context, action string, args map[string]interface{}) (interface{}, *rpcpkg.Error) {
|
||||
registry := s.subagentRPCRegistry()
|
||||
req := rpcpkg.Request{
|
||||
Method: legacySubagentActionMethod(action),
|
||||
Params: mustJSONMarshal(mapSubagentLegacyArgs(action, args)),
|
||||
}
|
||||
result, rpcErr := registry.Handle(ctx, req)
|
||||
if rpcErr != nil && !strings.HasPrefix(strings.TrimSpace(req.Method), "subagent.") {
|
||||
if s.onSubagents == nil {
|
||||
return nil, rpcError("unavailable", "subagent runtime handler not configured", nil, false)
|
||||
}
|
||||
fallback, err := s.onSubagents(ctx, action, args)
|
||||
if err != nil {
|
||||
return nil, rpcErrorFrom(err)
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
return result, rpcErr
|
||||
}
|
||||
|
||||
var legacySubagentActionMethods = map[string]string{
|
||||
"": "subagent.list",
|
||||
"list": "subagent.list",
|
||||
"snapshot": "subagent.snapshot",
|
||||
"get": "subagent.get",
|
||||
"info": "subagent.get",
|
||||
"spawn": "subagent.spawn",
|
||||
"create": "subagent.spawn",
|
||||
"dispatch_and_wait": "subagent.dispatch_and_wait",
|
||||
"registry": "subagent.registry",
|
||||
}
|
||||
|
||||
func legacySubagentActionMethod(action string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(action))
|
||||
if method, ok := legacySubagentActionMethods[normalized]; ok {
|
||||
return method
|
||||
}
|
||||
return strings.TrimSpace(action)
|
||||
}
|
||||
|
||||
var legacySubagentArgMappers = map[string]func(map[string]interface{}) interface{}{
|
||||
"snapshot": func(args map[string]interface{}) interface{} {
|
||||
return rpcpkg.SnapshotRequest{Limit: tools.MapIntArg(args, "limit", 0)}
|
||||
},
|
||||
"get": func(args map[string]interface{}) interface{} {
|
||||
return rpcpkg.GetSubagentRequest{ID: tools.MapStringArg(args, "id")}
|
||||
},
|
||||
"info": func(args map[string]interface{}) interface{} {
|
||||
return rpcpkg.GetSubagentRequest{ID: tools.MapStringArg(args, "id")}
|
||||
},
|
||||
"spawn": buildLegacySpawnSubagentRequest,
|
||||
"create": func(args map[string]interface{}) interface{} {
|
||||
return buildLegacySpawnSubagentRequest(args)
|
||||
},
|
||||
"dispatch_and_wait": func(args map[string]interface{}) interface{} {
|
||||
return rpcpkg.DispatchAndWaitRequest{
|
||||
Task: tools.MapStringArg(args, "task"),
|
||||
Label: tools.MapStringArg(args, "label"),
|
||||
Role: tools.MapStringArg(args, "role"),
|
||||
AgentID: tools.MapStringArg(args, "agent_id"),
|
||||
ThreadID: tools.MapStringArg(args, "thread_id"),
|
||||
CorrelationID: tools.MapStringArg(args, "correlation_id"),
|
||||
ParentRunID: tools.MapStringArg(args, "parent_run_id"),
|
||||
MaxRetries: tools.MapIntArg(args, "max_retries", 0),
|
||||
RetryBackoffMS: tools.MapIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: tools.MapIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: tools.MapIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: tools.MapIntArg(args, "max_result_chars", 0),
|
||||
WaitTimeoutSec: tools.MapIntArg(args, "wait_timeout_sec", 0),
|
||||
Channel: firstNonEmptyString(tools.MapStringArg(args, "channel"), tools.MapStringArg(args, "origin_channel")),
|
||||
ChatID: firstNonEmptyString(tools.MapStringArg(args, "chat_id"), tools.MapStringArg(args, "origin_chat_id")),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func mapSubagentLegacyArgs(action string, args map[string]interface{}) interface{} {
|
||||
normalized := strings.ToLower(strings.TrimSpace(action))
|
||||
if mapper, ok := legacySubagentArgMappers[normalized]; ok && mapper != nil {
|
||||
return mapper(args)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func buildLegacySpawnSubagentRequest(args map[string]interface{}) interface{} {
|
||||
return rpcpkg.SpawnSubagentRequest{
|
||||
Task: tools.MapStringArg(args, "task"),
|
||||
Label: tools.MapStringArg(args, "label"),
|
||||
Role: tools.MapStringArg(args, "role"),
|
||||
AgentID: tools.MapStringArg(args, "agent_id"),
|
||||
MaxRetries: tools.MapIntArg(args, "max_retries", 0),
|
||||
RetryBackoffMS: tools.MapIntArg(args, "retry_backoff_ms", 0),
|
||||
TimeoutSec: tools.MapIntArg(args, "timeout_sec", 0),
|
||||
MaxTaskChars: tools.MapIntArg(args, "max_task_chars", 0),
|
||||
MaxResultChars: tools.MapIntArg(args, "max_result_chars", 0),
|
||||
Channel: firstNonEmptyString(tools.MapStringArg(args, "channel"), tools.MapStringArg(args, "origin_channel")),
|
||||
ChatID: firstNonEmptyString(tools.MapStringArg(args, "chat_id"), tools.MapStringArg(args, "origin_chat_id")),
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSONMarshal(value interface{}) json.RawMessage {
|
||||
if value == nil {
|
||||
return json.RawMessage([]byte("{}"))
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return json.RawMessage([]byte("{}"))
|
||||
}
|
||||
return data
|
||||
}
|
||||
1133
pkg/api/rpc_services.go
Normal file
1133
pkg/api/rpc_services.go
Normal file
File diff suppressed because it is too large
Load Diff
2433
pkg/api/server.go
2433
pkg/api/server.go
File diff suppressed because it is too large
Load Diff
345
pkg/api/server_node_artifacts.go
Normal file
345
pkg/api/server_node_artifacts.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/YspCoder/clawgo/pkg/nodes"
|
||||
)
|
||||
|
||||
func (s *Server) webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter string, limit int) []map[string]interface{} {
|
||||
nodeFilter = strings.TrimSpace(nodeFilter)
|
||||
actionFilter = strings.TrimSpace(actionFilter)
|
||||
kindFilter = strings.TrimSpace(kindFilter)
|
||||
rows, _ := s.readNodeDispatchAuditRows()
|
||||
if len(rows) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, limit)
|
||||
for rowIndex := len(rows) - 1; rowIndex >= 0; rowIndex-- {
|
||||
row := rows[rowIndex]
|
||||
artifacts, _ := row["artifacts"].([]interface{})
|
||||
for artifactIndex, raw := range artifacts {
|
||||
artifact, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := map[string]interface{}{
|
||||
"id": buildNodeArtifactID(row, artifact, artifactIndex),
|
||||
"time": row["time"],
|
||||
"node": row["node"],
|
||||
"action": row["action"],
|
||||
"used_transport": row["used_transport"],
|
||||
"ok": row["ok"],
|
||||
"error": row["error"],
|
||||
}
|
||||
for _, key := range []string{"name", "kind", "mime_type", "storage", "path", "url", "content_text", "content_base64", "source_path", "size_bytes"} {
|
||||
if value, ok := artifact[key]; ok {
|
||||
item[key] = value
|
||||
}
|
||||
}
|
||||
if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
|
||||
continue
|
||||
}
|
||||
if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) {
|
||||
continue
|
||||
}
|
||||
if kindFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["kind"])), kindFilter) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
if limit > 0 && len(out) >= limit {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildNodeArtifactID(row, artifact map[string]interface{}, artifactIndex int) string {
|
||||
seed := fmt.Sprintf("%v|%v|%v|%d|%v|%v|%v",
|
||||
row["time"], row["node"], row["action"], artifactIndex,
|
||||
artifact["name"], artifact["source_path"], artifact["path"],
|
||||
)
|
||||
sum := sha1.Sum([]byte(seed))
|
||||
return fmt.Sprintf("%x", sum[:8])
|
||||
}
|
||||
|
||||
func sanitizeZipEntryName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "artifact.bin"
|
||||
}
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = filepath.Base(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r
|
||||
case r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '.', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '_'
|
||||
}
|
||||
}, name)
|
||||
if strings.Trim(name, "._") == "" {
|
||||
return "artifact.bin"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (s *Server) findNodeArtifactByID(id string) (map[string]interface{}, bool) {
|
||||
for _, item := range s.webUINodeArtifactsPayload(10000) {
|
||||
if strings.TrimSpace(fmt.Sprint(item["id"])) == id {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func resolveArtifactPath(workspace, raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(raw) {
|
||||
clean := filepath.Clean(raw)
|
||||
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
|
||||
return clean
|
||||
}
|
||||
return ""
|
||||
}
|
||||
root := strings.TrimSpace(workspace)
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
clean := filepath.Clean(filepath.Join(root, raw))
|
||||
if rel, err := filepath.Rel(root, clean); err != nil || strings.HasPrefix(rel, "..") {
|
||||
return ""
|
||||
}
|
||||
if info, err := os.Stat(clean); err == nil && !info.IsDir() {
|
||||
return clean
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readArtifactBytes(workspace string, item map[string]interface{}) ([]byte, string, error) {
|
||||
if content := strings.TrimSpace(fmt.Sprint(item["content_base64"])); content != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(content)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return raw, strings.TrimSpace(fmt.Sprint(item["mime_type"])), nil
|
||||
}
|
||||
for _, rawPath := range []string{fmt.Sprint(item["source_path"]), fmt.Sprint(item["path"])} {
|
||||
if path := resolveArtifactPath(workspace, rawPath); path != "" {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return b, strings.TrimSpace(fmt.Sprint(item["mime_type"])), nil
|
||||
}
|
||||
}
|
||||
if contentText := fmt.Sprint(item["content_text"]); strings.TrimSpace(contentText) != "" {
|
||||
return []byte(contentText), "text/plain; charset=utf-8", nil
|
||||
}
|
||||
return nil, "", fmt.Errorf("artifact content unavailable")
|
||||
}
|
||||
|
||||
func (s *Server) filteredNodeDispatches(nodeFilter, actionFilter string, limit int) []map[string]interface{} {
|
||||
items := s.webUINodesDispatchPayload(limit)
|
||||
if nodeFilter == "" && actionFilter == "" {
|
||||
return items
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
if nodeFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
|
||||
continue
|
||||
}
|
||||
if actionFilter != "" && !strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["action"])), actionFilter) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filteredNodeAlerts(alerts []map[string]interface{}, nodeFilter string) []map[string]interface{} {
|
||||
if nodeFilter == "" {
|
||||
return alerts
|
||||
}
|
||||
out := make([]map[string]interface{}, 0, len(alerts))
|
||||
for _, item := range alerts {
|
||||
if strings.EqualFold(strings.TrimSpace(fmt.Sprint(item["node"])), nodeFilter) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeArtifactsExport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
retentionSummary := s.applyNodeArtifactRetention()
|
||||
limit := queryBoundedPositiveInt(r, "limit", 200, 1000)
|
||||
nodeFilter := strings.TrimSpace(r.URL.Query().Get("node"))
|
||||
actionFilter := strings.TrimSpace(r.URL.Query().Get("action"))
|
||||
kindFilter := strings.TrimSpace(r.URL.Query().Get("kind"))
|
||||
artifacts := s.webUINodeArtifactsPayloadFiltered(nodeFilter, actionFilter, kindFilter, limit)
|
||||
dispatches := s.filteredNodeDispatches(nodeFilter, actionFilter, limit)
|
||||
payload := s.webUINodesPayload(r.Context())
|
||||
nodeList, _ := payload["nodes"].([]nodes.NodeInfo)
|
||||
p2p, _ := payload["p2p"].(map[string]interface{})
|
||||
alerts := filteredNodeAlerts(s.webUINodeAlertsPayload(nodeList, p2p, dispatches), nodeFilter)
|
||||
|
||||
var archive bytes.Buffer
|
||||
zw := zip.NewWriter(&archive)
|
||||
writeZipJSON := func(name string, value interface{}) error {
|
||||
entry, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(entry)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(value)
|
||||
}
|
||||
manifest := map[string]interface{}{
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"filters": map[string]interface{}{
|
||||
"node": nodeFilter,
|
||||
"action": actionFilter,
|
||||
"kind": kindFilter,
|
||||
"limit": limit,
|
||||
},
|
||||
"artifact_count": len(artifacts),
|
||||
"dispatch_count": len(dispatches),
|
||||
"alert_count": len(alerts),
|
||||
"retention": retentionSummary,
|
||||
}
|
||||
if err := writeZipJSON("manifest.json", manifest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeZipJSON("dispatches.json", dispatches); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeZipJSON("alerts.json", alerts); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := writeZipJSON("artifacts.json", artifacts); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, item := range artifacts {
|
||||
name := sanitizeZipEntryName(firstNonEmptyString(
|
||||
fmt.Sprint(item["name"]),
|
||||
fmt.Sprint(item["source_path"]),
|
||||
fmt.Sprint(item["path"]),
|
||||
fmt.Sprintf("%s.bin", fmt.Sprint(item["id"])),
|
||||
))
|
||||
raw, _, err := readArtifactBytes(s.workspacePath, item)
|
||||
entryName := filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-%s", fmt.Sprint(item["id"]), name)))
|
||||
if err != nil || len(raw) == 0 {
|
||||
entryName = filepath.ToSlash(filepath.Join("files", fmt.Sprintf("%s-metadata.json", fmt.Sprint(item["id"]))))
|
||||
raw, err = json.MarshalIndent(item, "", " ")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
entry, err := zw.Create(entryName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(raw); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
filename := "node-artifacts-export.zip"
|
||||
if nodeFilter != "" {
|
||||
filename = fmt.Sprintf("node-artifacts-%s.zip", sanitizeZipEntryName(nodeFilter))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(archive.Bytes())
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeArtifactDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
item, ok := s.findNodeArtifactByID(id)
|
||||
if !ok {
|
||||
http.Error(w, "artifact not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(fmt.Sprint(item["name"]))
|
||||
if name == "" {
|
||||
name = "artifact"
|
||||
}
|
||||
mimeType := strings.TrimSpace(fmt.Sprint(item["mime_type"]))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
if contentB64 := strings.TrimSpace(fmt.Sprint(item["content_base64"])); contentB64 != "" {
|
||||
payload, err := base64.StdEncoding.DecodeString(contentB64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid inline artifact payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||
_, _ = w.Write(payload)
|
||||
return
|
||||
}
|
||||
for _, rawPath := range []string{fmt.Sprint(item["source_path"]), fmt.Sprint(item["path"])} {
|
||||
if path := resolveArtifactPath(s.workspacePath, rawPath); path != "" {
|
||||
http.ServeFile(w, r, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
if contentText := fmt.Sprint(item["content_text"]); strings.TrimSpace(contentText) != "" {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||
_, _ = w.Write([]byte(contentText))
|
||||
return
|
||||
}
|
||||
http.Error(w, "artifact content unavailable", http.StatusNotFound)
|
||||
}
|
||||
429
pkg/api/server_providers.go
Normal file
429
pkg/api/server_providers.go
Normal file
@@ -0,0 +1,429 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cfgpkg "github.com/YspCoder/clawgo/pkg/config"
|
||||
"github.com/YspCoder/clawgo/pkg/providers"
|
||||
)
|
||||
|
||||
func (s *Server) handleWebUIProviderOAuthStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Provider string `json:"provider"`
|
||||
AccountLabel string `json:"account_label"`
|
||||
NetworkProxy string `json:"network_proxy"`
|
||||
ProviderConfig cfgpkg.ProviderConfig `json:"provider_config"`
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
body.Provider = strings.TrimSpace(r.URL.Query().Get("provider"))
|
||||
body.AccountLabel = strings.TrimSpace(r.URL.Query().Get("account_label"))
|
||||
body.NetworkProxy = strings.TrimSpace(r.URL.Query().Get("network_proxy"))
|
||||
}
|
||||
cfg, pc, err := s.resolveProviderConfig(strings.TrimSpace(body.Provider), body.ProviderConfig)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = cfg
|
||||
timeout := pc.TimeoutSec
|
||||
if timeout <= 0 {
|
||||
timeout = 90
|
||||
}
|
||||
loginMgr, err := providers.NewOAuthLoginManager(pc, time.Duration(timeout)*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
flow, err := loginMgr.StartManualFlowWithOptions(providers.OAuthLoginOptions{
|
||||
AccountLabel: body.AccountLabel,
|
||||
NetworkProxy: firstNonEmptyString(strings.TrimSpace(body.NetworkProxy), strings.TrimSpace(pc.OAuth.NetworkProxy)),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
flowID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
s.oauthFlowMu.Lock()
|
||||
s.oauthFlows[flowID] = flow
|
||||
s.oauthFlowMu.Unlock()
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"flow_id": flowID,
|
||||
"mode": flow.Mode,
|
||||
"auth_url": flow.AuthURL,
|
||||
"user_code": flow.UserCode,
|
||||
"instructions": flow.Instructions,
|
||||
"account_label": strings.TrimSpace(body.AccountLabel),
|
||||
"network_proxy": strings.TrimSpace(body.NetworkProxy),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIProviderOAuthComplete(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Provider string `json:"provider"`
|
||||
FlowID string `json:"flow_id"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
AccountLabel string `json:"account_label"`
|
||||
NetworkProxy string `json:"network_proxy"`
|
||||
ProviderConfig cfgpkg.ProviderConfig `json:"provider_config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg, pc, err := s.resolveProviderConfig(strings.TrimSpace(body.Provider), body.ProviderConfig)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
timeout := pc.TimeoutSec
|
||||
if timeout <= 0 {
|
||||
timeout = 90
|
||||
}
|
||||
loginMgr, err := providers.NewOAuthLoginManager(pc, time.Duration(timeout)*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.oauthFlowMu.Lock()
|
||||
flow := s.oauthFlows[strings.TrimSpace(body.FlowID)]
|
||||
delete(s.oauthFlows, strings.TrimSpace(body.FlowID))
|
||||
s.oauthFlowMu.Unlock()
|
||||
if flow == nil {
|
||||
http.Error(w, "oauth flow not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
session, models, err := loginMgr.CompleteManualFlowWithOptions(r.Context(), pc.APIBase, flow, body.CallbackURL, providers.OAuthLoginOptions{
|
||||
AccountLabel: strings.TrimSpace(body.AccountLabel),
|
||||
NetworkProxy: firstNonEmptyString(strings.TrimSpace(body.NetworkProxy), strings.TrimSpace(pc.OAuth.NetworkProxy)),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if session.CredentialFile != "" {
|
||||
pc.OAuth.CredentialFile = session.CredentialFile
|
||||
pc.OAuth.CredentialFiles = appendUniqueStrings(pc.OAuth.CredentialFiles, session.CredentialFile)
|
||||
}
|
||||
if err := s.saveProviderConfig(cfg, body.Provider, pc); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"account": session.Email,
|
||||
"credential_file": session.CredentialFile,
|
||||
"network_proxy": session.NetworkProxy,
|
||||
"models": models,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIProviderOAuthImport(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
providerName := strings.TrimSpace(r.FormValue("provider"))
|
||||
accountLabel := strings.TrimSpace(r.FormValue("account_label"))
|
||||
networkProxy := strings.TrimSpace(r.FormValue("network_proxy"))
|
||||
inlineCfgRaw := strings.TrimSpace(r.FormValue("provider_config"))
|
||||
var inlineCfg cfgpkg.ProviderConfig
|
||||
if inlineCfgRaw != "" {
|
||||
if err := json.Unmarshal([]byte(inlineCfgRaw), &inlineCfg); err != nil {
|
||||
http.Error(w, "invalid provider_config", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg, pc, err := s.resolveProviderConfig(providerName, inlineCfg)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "file required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
timeout := pc.TimeoutSec
|
||||
if timeout <= 0 {
|
||||
timeout = 90
|
||||
}
|
||||
loginMgr, err := providers.NewOAuthLoginManager(pc, time.Duration(timeout)*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
session, models, err := loginMgr.ImportAuthJSONWithOptions(r.Context(), pc.APIBase, header.Filename, data, providers.OAuthLoginOptions{
|
||||
AccountLabel: accountLabel,
|
||||
NetworkProxy: firstNonEmptyString(networkProxy, strings.TrimSpace(pc.OAuth.NetworkProxy)),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if session.CredentialFile != "" {
|
||||
pc.OAuth.CredentialFile = session.CredentialFile
|
||||
pc.OAuth.CredentialFiles = appendUniqueStrings(pc.OAuth.CredentialFiles, session.CredentialFile)
|
||||
}
|
||||
if err := s.saveProviderConfig(cfg, providerName, pc); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"account": session.Email,
|
||||
"credential_file": session.CredentialFile,
|
||||
"network_proxy": session.NetworkProxy,
|
||||
"models": models,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIProviderOAuthAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
providerName := strings.TrimSpace(r.URL.Query().Get("provider"))
|
||||
cfg, pc, err := s.loadProviderConfig(providerName)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = cfg
|
||||
timeout := pc.TimeoutSec
|
||||
if timeout <= 0 {
|
||||
timeout = 90
|
||||
}
|
||||
loginMgr, err := providers.NewOAuthLoginManager(pc, time.Duration(timeout)*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
accounts, err := loginMgr.ListAccounts()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "accounts": accounts})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Action string `json:"action"`
|
||||
CredentialFile string `json:"credential_file"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
action := strings.ToLower(strings.TrimSpace(body.Action))
|
||||
handler := providerOAuthAccountActionHandlers[action]
|
||||
if handler == nil {
|
||||
http.Error(w, "unsupported action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := handler(r.Context(), s, loginMgr, cfg, providerName, &pc, strings.TrimSpace(body.CredentialFile))
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
if action == "delete" && strings.Contains(strings.ToLower(err.Error()), "config") {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
type providerOAuthAccountActionHandler func(context.Context, *Server, *providers.OAuthLoginManager, *cfgpkg.Config, string, *cfgpkg.ProviderConfig, string) (map[string]interface{}, error)
|
||||
|
||||
var providerOAuthAccountActionHandlers = map[string]providerOAuthAccountActionHandler{
|
||||
"refresh": func(ctx context.Context, _ *Server, loginMgr *providers.OAuthLoginManager, _ *cfgpkg.Config, _ string, _ *cfgpkg.ProviderConfig, credentialFile string) (map[string]interface{}, error) {
|
||||
account, err := loginMgr.RefreshAccount(ctx, credentialFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "account": account}, nil
|
||||
},
|
||||
"delete": func(_ context.Context, srv *Server, loginMgr *providers.OAuthLoginManager, cfg *cfgpkg.Config, providerName string, pc *cfgpkg.ProviderConfig, credentialFile string) (map[string]interface{}, error) {
|
||||
if err := loginMgr.DeleteAccount(credentialFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pc.OAuth.CredentialFiles = removeStringItem(pc.OAuth.CredentialFiles, credentialFile)
|
||||
if strings.TrimSpace(pc.OAuth.CredentialFile) == strings.TrimSpace(credentialFile) {
|
||||
pc.OAuth.CredentialFile = ""
|
||||
if len(pc.OAuth.CredentialFiles) > 0 {
|
||||
pc.OAuth.CredentialFile = pc.OAuth.CredentialFiles[0]
|
||||
}
|
||||
}
|
||||
if err := srv.saveProviderConfig(cfg, providerName, *pc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "deleted": true}, nil
|
||||
},
|
||||
"clear_cooldown": func(_ context.Context, _ *Server, loginMgr *providers.OAuthLoginManager, _ *cfgpkg.Config, _ string, _ *cfgpkg.ProviderConfig, credentialFile string) (map[string]interface{}, error) {
|
||||
if err := loginMgr.ClearCooldown(credentialFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"ok": true, "cleared": true}, nil
|
||||
},
|
||||
}
|
||||
|
||||
func (s *Server) loadProviderConfig(name string) (*cfgpkg.Config, cfgpkg.ProviderConfig, error) {
|
||||
if strings.TrimSpace(s.configPath) == "" {
|
||||
return nil, cfgpkg.ProviderConfig{}, fmt.Errorf("config path not set")
|
||||
}
|
||||
cfg, err := cfgpkg.LoadConfig(s.configPath)
|
||||
if err != nil {
|
||||
return nil, cfgpkg.ProviderConfig{}, err
|
||||
}
|
||||
providerName := strings.TrimSpace(name)
|
||||
if providerName == "" {
|
||||
providerName = cfgpkg.PrimaryProviderName(cfg)
|
||||
}
|
||||
pc, ok := cfgpkg.ProviderConfigByName(cfg, providerName)
|
||||
if !ok {
|
||||
return nil, cfgpkg.ProviderConfig{}, fmt.Errorf("provider %q not found", providerName)
|
||||
}
|
||||
return cfg, pc, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadRuntimeProviderName(name string) (*cfgpkg.Config, string, error) {
|
||||
if strings.TrimSpace(s.configPath) == "" {
|
||||
return nil, "", fmt.Errorf("config path not set")
|
||||
}
|
||||
cfg, err := cfgpkg.LoadConfig(s.configPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
providerName := strings.TrimSpace(name)
|
||||
if providerName == "" {
|
||||
providerName = cfgpkg.PrimaryProviderName(cfg)
|
||||
}
|
||||
if !cfgpkg.ProviderExists(cfg, providerName) {
|
||||
return nil, "", fmt.Errorf("provider %q not found", providerName)
|
||||
}
|
||||
return cfg, providerName, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveProviderConfig(name string, inline cfgpkg.ProviderConfig) (*cfgpkg.Config, cfgpkg.ProviderConfig, error) {
|
||||
if hasInlineProviderConfig(inline) {
|
||||
cfg, err := cfgpkg.LoadConfig(s.configPath)
|
||||
if err != nil {
|
||||
return nil, cfgpkg.ProviderConfig{}, err
|
||||
}
|
||||
return cfg, inline, nil
|
||||
}
|
||||
return s.loadProviderConfig(name)
|
||||
}
|
||||
|
||||
func hasInlineProviderConfig(pc cfgpkg.ProviderConfig) bool {
|
||||
return strings.TrimSpace(pc.APIBase) != "" ||
|
||||
strings.TrimSpace(pc.APIKey) != "" ||
|
||||
len(pc.Models) > 0 ||
|
||||
strings.TrimSpace(pc.Auth) != "" ||
|
||||
strings.TrimSpace(pc.OAuth.Provider) != ""
|
||||
}
|
||||
|
||||
func (s *Server) saveProviderConfig(cfg *cfgpkg.Config, name string, pc cfgpkg.ProviderConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config is nil")
|
||||
}
|
||||
providerName := strings.TrimSpace(name)
|
||||
if cfg.Models.Providers == nil {
|
||||
cfg.Models.Providers = map[string]cfgpkg.ProviderConfig{}
|
||||
}
|
||||
cfg.Models.Providers[providerName] = pc
|
||||
if err := cfgpkg.SaveConfig(s.configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.onConfigAfter != nil {
|
||||
if err := s.onConfigAfter(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := requestSelfReloadSignal(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendUniqueStrings(values []string, item string) []string {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return values
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == item {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, item)
|
||||
}
|
||||
|
||||
func removeStringItem(values []string, item string) []string {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return values
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == item {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func atoiDefault(raw string, fallback int) int {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
526
pkg/api/server_rpc_facades.go
Normal file
526
pkg/api/server_rpc_facades.go
Normal file
@@ -0,0 +1,526 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
rpcpkg "github.com/YspCoder/clawgo/pkg/rpc"
|
||||
"github.com/YspCoder/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
func (s *Server) handleWebUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.configPath) == "" {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
svc := s.configRPCService()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
mode := strings.TrimSpace(r.URL.Query().Get("mode"))
|
||||
includeHot := r.URL.Query().Get("include_hot_reload_fields") == "1" || strings.EqualFold(mode, "hot")
|
||||
resp, rpcErr := svc.View(r.Context(), rpcpkg.ConfigViewRequest{
|
||||
Mode: mode,
|
||||
IncludeHotReloadInfo: includeHot,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(mode, "normalized") || includeHot {
|
||||
payload := map[string]interface{}{"ok": true, "config": resp.Config}
|
||||
if resp.RawConfig != nil {
|
||||
payload["raw_config"] = resp.RawConfig
|
||||
}
|
||||
if len(resp.HotReloadFields) > 0 {
|
||||
payload["hot_reload_fields"] = resp.HotReloadFields
|
||||
payload["hot_reload_field_details"] = resp.HotReloadFieldDetails
|
||||
}
|
||||
writeJSON(w, payload)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(resp.PrettyText))
|
||||
case http.MethodPost:
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
confirmRisky, _ := tools.MapBoolArg(body, "confirm_risky")
|
||||
delete(body, "confirm_risky")
|
||||
resp, rpcErr := svc.Save(r.Context(), rpcpkg.ConfigSaveRequest{
|
||||
Mode: strings.TrimSpace(r.URL.Query().Get("mode")),
|
||||
ConfirmRisky: confirmRisky,
|
||||
Config: body,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
message := rpcErr.Message
|
||||
status := rpcHTTPStatus(rpcErr)
|
||||
if status == http.StatusInternalServerError && strings.TrimSpace(message) != "" && !strings.Contains(strings.ToLower(message), "reload failed") {
|
||||
message = "config saved but reload failed: " + message
|
||||
}
|
||||
payload := map[string]interface{}{"ok": false, "error": message}
|
||||
if resp != nil && resp.RequiresConfirm {
|
||||
payload["requires_confirm"] = true
|
||||
payload["changed_fields"] = resp.ChangedFields
|
||||
}
|
||||
if resp != nil && resp.Details != nil {
|
||||
payload["details"] = resp.Details
|
||||
}
|
||||
writeJSONStatus(w, status, payload)
|
||||
return
|
||||
}
|
||||
out := map[string]interface{}{"ok": true, "reloaded": true}
|
||||
if strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("mode")), "normalized") {
|
||||
view, viewErr := svc.View(r.Context(), rpcpkg.ConfigViewRequest{Mode: "normalized"})
|
||||
if viewErr == nil {
|
||||
out["config"] = view.Config
|
||||
}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIProviderModels(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.providerRPCService().UpdateModels(r.Context(), rpcpkg.UpdateProviderModelsRequest{
|
||||
Provider: strings.TrimSpace(body.Provider),
|
||||
Model: body.Model,
|
||||
Models: body.Models,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"models": resp.Models,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIProviderRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
resp, rpcErr := s.providerRPCService().RuntimeView(r.Context(), rpcpkg.ProviderRuntimeViewRequest{
|
||||
Provider: strings.TrimSpace(r.URL.Query().Get("provider")),
|
||||
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
|
||||
Reason: strings.TrimSpace(r.URL.Query().Get("reason")),
|
||||
Target: strings.TrimSpace(r.URL.Query().Get("target")),
|
||||
Sort: strings.TrimSpace(r.URL.Query().Get("sort")),
|
||||
ChangesOnly: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("changes_only")), "true"),
|
||||
WindowSec: atoiDefault(strings.TrimSpace(r.URL.Query().Get("window_sec")), 0),
|
||||
Limit: atoiDefault(strings.TrimSpace(r.URL.Query().Get("limit")), 0),
|
||||
Cursor: atoiDefault(strings.TrimSpace(r.URL.Query().Get("cursor")), 0),
|
||||
HealthBelow: atoiDefault(strings.TrimSpace(r.URL.Query().Get("health_below")), 0),
|
||||
CooldownUntilBeforeSec: atoiDefault(strings.TrimSpace(r.URL.Query().Get("cooldown_until_before_sec")), 0),
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"view": resp.View,
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Provider string `json:"provider"`
|
||||
Action string `json:"action"`
|
||||
OnlyExpiring bool `json:"only_expiring"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.providerRPCService().RuntimeAction(r.Context(), rpcpkg.ProviderRuntimeActionRequest{
|
||||
Provider: strings.TrimSpace(body.Provider),
|
||||
Action: strings.TrimSpace(body.Action),
|
||||
OnlyExpiring: body.OnlyExpiring,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
result := map[string]interface{}{"ok": true}
|
||||
for key, value := range resp.Result {
|
||||
result[key] = value
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeDispatchReplay(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Node string `json:"node"`
|
||||
Action string `json:"action"`
|
||||
Mode string `json:"mode"`
|
||||
Task string `json:"task"`
|
||||
Model string `json:"model"`
|
||||
Args map[string]interface{} `json:"args"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.nodeRPCService().Dispatch(r.Context(), rpcpkg.DispatchNodeRequest{
|
||||
Node: strings.TrimSpace(body.Node),
|
||||
Action: strings.TrimSpace(body.Action),
|
||||
Mode: strings.TrimSpace(body.Mode),
|
||||
Task: body.Task,
|
||||
Model: body.Model,
|
||||
Args: body.Args,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"result": resp.Result,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.nodeRPCService().ListArtifacts(r.Context(), rpcpkg.ListNodeArtifactsRequest{
|
||||
Node: strings.TrimSpace(r.URL.Query().Get("node")),
|
||||
Action: strings.TrimSpace(r.URL.Query().Get("action")),
|
||||
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
|
||||
Limit: queryBoundedPositiveInt(r, "limit", 200, 1000),
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"items": resp.Items,
|
||||
"artifact_retention": resp.ArtifactRetention,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeArtifactDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.nodeRPCService().DeleteArtifact(r.Context(), rpcpkg.DeleteNodeArtifactRequest{ID: strings.TrimSpace(body.ID)})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"id": resp.ID,
|
||||
"deleted_file": resp.DeletedFile,
|
||||
"deleted_audit": resp.DeletedAudit,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUINodeArtifactPrune(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Node string `json:"node"`
|
||||
Action string `json:"action"`
|
||||
Kind string `json:"kind"`
|
||||
KeepLatest int `json:"keep_latest"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := s.nodeRPCService().PruneArtifacts(r.Context(), rpcpkg.PruneNodeArtifactsRequest{
|
||||
Node: strings.TrimSpace(body.Node),
|
||||
Action: strings.TrimSpace(body.Action),
|
||||
Kind: strings.TrimSpace(body.Kind),
|
||||
KeepLatest: body.KeepLatest,
|
||||
Limit: body.Limit,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"pruned": resp.Pruned,
|
||||
"deleted_files": resp.DeletedFiles,
|
||||
"kept": resp.Kept,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUICron(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s.onCron == nil {
|
||||
http.Error(w, "cron handler not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
svc := s.cronRPCService()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
resp, rpcErr := svc.List(r.Context(), rpcpkg.ListCronJobsRequest{})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "jobs": resp.Jobs})
|
||||
} else {
|
||||
resp, rpcErr := svc.Get(r.Context(), rpcpkg.GetCronJobRequest{ID: id})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "job": resp.Job})
|
||||
}
|
||||
case http.MethodPost:
|
||||
args := map[string]interface{}{}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&args)
|
||||
}
|
||||
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
|
||||
args["id"] = id
|
||||
}
|
||||
action := "create"
|
||||
if a := tools.MapStringArg(args, "action"); a != "" {
|
||||
action = strings.ToLower(strings.TrimSpace(a))
|
||||
}
|
||||
resp, rpcErr := svc.Mutate(r.Context(), rpcpkg.MutateCronJobRequest{
|
||||
Action: action,
|
||||
Args: args,
|
||||
})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "result": resp.Result})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUISubagentsRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if s.onSubagents == nil {
|
||||
http.Error(w, "subagent runtime handler not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
|
||||
args := map[string]interface{}{}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
if action == "" {
|
||||
action = "list"
|
||||
}
|
||||
for key, values := range r.URL.Query() {
|
||||
if key == "action" || key == "token" || len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
args[key] = strings.TrimSpace(values[0])
|
||||
}
|
||||
case http.MethodPost:
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body == nil {
|
||||
body = map[string]interface{}{}
|
||||
}
|
||||
if action == "" {
|
||||
if raw := stringFromMap(body, "action"); raw != "" {
|
||||
action = strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
delete(body, "action")
|
||||
args = body
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
result, rpcErr := s.handleSubagentLegacyAction(r.Context(), action, args)
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "result": result})
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
svc := s.workspaceRPCService()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
if path == "" {
|
||||
resp, rpcErr := svc.ListFiles(r.Context(), rpcpkg.ListWorkspaceFilesRequest{Scope: "memory"})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "files": resp.Files})
|
||||
return
|
||||
}
|
||||
resp, rpcErr := svc.ReadFile(r.Context(), rpcpkg.ReadWorkspaceFileRequest{Scope: "memory", Path: path})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
if !resp.Found {
|
||||
http.Error(w, os.ErrNotExist.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "path": resp.Path, "content": resp.Content})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := svc.WriteFile(r.Context(), rpcpkg.WriteWorkspaceFileRequest{Scope: "memory", Path: body.Path, Content: body.Content})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "path": resp.Path})
|
||||
case http.MethodDelete:
|
||||
resp, rpcErr := svc.DeleteFile(r.Context(), rpcpkg.DeleteWorkspaceFileRequest{Scope: "memory", Path: r.URL.Query().Get("path")})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "deleted": resp.Deleted, "path": resp.Path})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebUIWorkspaceFile(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
svc := s.workspaceRPCService()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
path := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
if path == "" {
|
||||
resp, rpcErr := svc.ListFiles(r.Context(), rpcpkg.ListWorkspaceFilesRequest{Scope: "workspace"})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "files": resp.Files})
|
||||
return
|
||||
}
|
||||
resp, rpcErr := svc.ReadFile(r.Context(), rpcpkg.ReadWorkspaceFileRequest{Scope: "workspace", Path: path})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "path": resp.Path, "found": resp.Found, "content": resp.Content})
|
||||
case http.MethodPost:
|
||||
var body struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, rpcErr := svc.WriteFile(r.Context(), rpcpkg.WriteWorkspaceFileRequest{Scope: "workspace", Path: body.Path, Content: body.Content})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "path": resp.Path, "saved": resp.Saved})
|
||||
case http.MethodDelete:
|
||||
resp, rpcErr := svc.DeleteFile(r.Context(), rpcpkg.DeleteWorkspaceFileRequest{Scope: "workspace", Path: r.URL.Query().Get("path")})
|
||||
if rpcErr != nil {
|
||||
http.Error(w, rpcErr.Message, rpcHTTPStatus(rpcErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "deleted": resp.Deleted, "path": resp.Path})
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
787
pkg/api/server_skills.go
Normal file
787
pkg/api/server_skills.go
Normal file
@@ -0,0 +1,787 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/YspCoder/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
func (s *Server) handleWebUISkills(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkAuth(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
skillsDir := filepath.Join(s.workspacePath, "skills")
|
||||
if strings.TrimSpace(skillsDir) == "" {
|
||||
http.Error(w, "workspace not configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(skillsDir, 0755)
|
||||
|
||||
resolveSkillPath := func(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("name required")
|
||||
}
|
||||
cands := []string{
|
||||
filepath.Join(skillsDir, name),
|
||||
filepath.Join(skillsDir, name+".disabled"),
|
||||
filepath.Join("/root/clawgo/workspace/skills", name),
|
||||
filepath.Join("/root/clawgo/workspace/skills", name+".disabled"),
|
||||
}
|
||||
for _, p := range cands {
|
||||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context()))
|
||||
clawhubInstalled := clawhubPath != ""
|
||||
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
|
||||
skillPath, err := resolveSkillPath(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(r.URL.Query().Get("files")) == "1" {
|
||||
var files []string
|
||||
_ = filepath.WalkDir(skillPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(skillPath, path)
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return nil
|
||||
}
|
||||
files = append(files, filepath.ToSlash(rel))
|
||||
return nil
|
||||
})
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "id": id, "files": files})
|
||||
return
|
||||
}
|
||||
if f := strings.TrimSpace(r.URL.Query().Get("file")); f != "" {
|
||||
clean, content, found, err := readRelativeTextFile(skillPath, f)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), relativeFilePathStatus(err))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.Error(w, os.ErrNotExist.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "id": id, "file": filepath.ToSlash(clean), "content": content})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type skillItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []string `json:"tools"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdateChecked bool `json:"update_checked"`
|
||||
RemoteFound bool `json:"remote_found,omitempty"`
|
||||
RemoteVersion string `json:"remote_version,omitempty"`
|
||||
CheckError string `json:"check_error,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
candDirs := []string{skillsDir, filepath.Join("/root/clawgo/workspace", "skills")}
|
||||
seenDirs := map[string]struct{}{}
|
||||
seenSkills := map[string]struct{}{}
|
||||
items := make([]skillItem, 0)
|
||||
checkUpdates := strings.TrimSpace(r.URL.Query().Get("check_updates")) == "1"
|
||||
|
||||
for _, dir := range candDirs {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenDirs[dir]; ok {
|
||||
continue
|
||||
}
|
||||
seenDirs[dir] = struct{}{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
enabled := !strings.HasSuffix(name, ".disabled")
|
||||
baseName := strings.TrimSuffix(name, ".disabled")
|
||||
if _, ok := seenSkills[baseName]; ok {
|
||||
continue
|
||||
}
|
||||
seenSkills[baseName] = struct{}{}
|
||||
desc, skillTools, sys := readSkillMeta(filepath.Join(dir, name, "SKILL.md"))
|
||||
if desc == "" || len(skillTools) == 0 || sys == "" {
|
||||
d2, t2, s2 := readSkillMeta(filepath.Join(dir, baseName, "SKILL.md"))
|
||||
if desc == "" {
|
||||
desc = d2
|
||||
}
|
||||
if len(skillTools) == 0 {
|
||||
skillTools = t2
|
||||
}
|
||||
if sys == "" {
|
||||
sys = s2
|
||||
}
|
||||
}
|
||||
if skillTools == nil {
|
||||
skillTools = []string{}
|
||||
}
|
||||
it := skillItem{ID: baseName, Name: baseName, Description: desc, Tools: skillTools, SystemPrompt: sys, Enabled: enabled, UpdateChecked: checkUpdates && clawhubInstalled, Source: dir}
|
||||
if checkUpdates && clawhubInstalled {
|
||||
found, version, checkErr := queryClawHubSkillVersion(r.Context(), baseName)
|
||||
it.RemoteFound = found
|
||||
it.RemoteVersion = version
|
||||
if checkErr != nil {
|
||||
it.CheckError = checkErr.Error()
|
||||
}
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"skills": items,
|
||||
"source": "clawhub",
|
||||
"clawhub_installed": clawhubInstalled,
|
||||
"clawhub_path": clawhubPath,
|
||||
})
|
||||
|
||||
case http.MethodPost:
|
||||
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||
if strings.Contains(ct, "multipart/form-data") {
|
||||
imported, err := importSkillArchiveFromMultipart(r, skillsDir)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "imported": imported})
|
||||
return
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
action := strings.ToLower(stringFromMap(body, "action"))
|
||||
if action == "install_clawhub" {
|
||||
output, err := ensureClawHubReady(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"output": output,
|
||||
"installed": true,
|
||||
"clawhub_path": resolveClawHubBinary(r.Context()),
|
||||
})
|
||||
return
|
||||
}
|
||||
id := stringFromMap(body, "id")
|
||||
name := strings.TrimSpace(firstNonEmptyString(stringFromMap(body, "name"), id))
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
enabledPath := filepath.Join(skillsDir, name)
|
||||
disabledPath := enabledPath + ".disabled"
|
||||
type skillActionHandler func() bool
|
||||
handlers := map[string]skillActionHandler{
|
||||
"install": func() bool {
|
||||
clawhubPath := strings.TrimSpace(resolveClawHubBinary(r.Context()))
|
||||
if clawhubPath == "" {
|
||||
http.Error(w, "clawhub is not installed. please install clawhub first.", http.StatusPreconditionFailed)
|
||||
return false
|
||||
}
|
||||
ignoreSuspicious, _ := tools.MapBoolArg(body, "ignore_suspicious")
|
||||
args := []string{"install", name}
|
||||
if ignoreSuspicious {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
cmd := exec.CommandContext(r.Context(), clawhubPath, args...)
|
||||
cmd.Dir = strings.TrimSpace(s.workspacePath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
outText := string(out)
|
||||
lower := strings.ToLower(outText)
|
||||
if strings.Contains(lower, "rate limit exceeded") || strings.Contains(lower, "too many requests") {
|
||||
http.Error(w, fmt.Sprintf("clawhub rate limit exceeded. please retry later or configure auth token.\n%s", outText), http.StatusTooManyRequests)
|
||||
return false
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("install failed: %v\n%s", err, outText), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "installed": name, "output": string(out)})
|
||||
return true
|
||||
},
|
||||
"enable": func() bool {
|
||||
if _, err := os.Stat(disabledPath); err == nil {
|
||||
if err := os.Rename(disabledPath, enabledPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
return true
|
||||
},
|
||||
"disable": func() bool {
|
||||
if _, err := os.Stat(enabledPath); err == nil {
|
||||
if err := os.Rename(enabledPath, disabledPath); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
return true
|
||||
},
|
||||
"write_file": func() bool {
|
||||
skillPath, err := resolveSkillPath(name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
content := rawStringFromMap(body, "content")
|
||||
filePath := stringFromMap(body, "file")
|
||||
clean, err := writeRelativeTextFile(skillPath, filePath, content, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), relativeFilePathStatus(err))
|
||||
return false
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "name": name, "file": filepath.ToSlash(clean)})
|
||||
return true
|
||||
},
|
||||
"create": func() bool {
|
||||
return createOrUpdateSkill(w, enabledPath, name, body, true)
|
||||
},
|
||||
"update": func() bool {
|
||||
return createOrUpdateSkill(w, enabledPath, name, body, false)
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
handler()
|
||||
return
|
||||
}
|
||||
http.Error(w, "unsupported action", http.StatusBadRequest)
|
||||
|
||||
case http.MethodDelete:
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pathA := filepath.Join(skillsDir, id)
|
||||
pathB := pathA + ".disabled"
|
||||
deleted := false
|
||||
if err := os.RemoveAll(pathA); err == nil {
|
||||
deleted = true
|
||||
}
|
||||
if err := os.RemoveAll(pathB); err == nil {
|
||||
deleted = true
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true, "deleted": deleted, "id": id})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func buildSkillMarkdown(name, desc string, toolsList []string, systemPrompt string) string {
|
||||
if desc == "" {
|
||||
desc = "No description provided."
|
||||
}
|
||||
if len(toolsList) == 0 {
|
||||
toolsList = []string{""}
|
||||
}
|
||||
toolLines := make([]string, 0, len(toolsList))
|
||||
for _, t := range toolsList {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
toolLines = append(toolLines, "- "+t)
|
||||
}
|
||||
if len(toolLines) == 0 {
|
||||
toolLines = []string{"- (none)"}
|
||||
}
|
||||
return fmt.Sprintf(`---
|
||||
name: %s
|
||||
description: %s
|
||||
---
|
||||
|
||||
# %s
|
||||
|
||||
%s
|
||||
|
||||
## Tools
|
||||
%s
|
||||
|
||||
## System Prompt
|
||||
%s
|
||||
`, name, desc, name, desc, strings.Join(toolLines, "\n"), systemPrompt)
|
||||
}
|
||||
|
||||
func createOrUpdateSkill(w http.ResponseWriter, enabledPath, name string, body map[string]interface{}, checkExists bool) bool {
|
||||
desc := rawStringFromMap(body, "description")
|
||||
sys := rawStringFromMap(body, "system_prompt")
|
||||
toolsList := stringListFromMap(body, "tools")
|
||||
if checkExists {
|
||||
if _, err := os.Stat(enabledPath); err == nil {
|
||||
http.Error(w, "skill already exists", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(enabledPath, "scripts"), 0755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
skillMD := buildSkillMarkdown(name, desc, toolsList, sys)
|
||||
if err := os.WriteFile(filepath.Join(enabledPath, "SKILL.md"), []byte(skillMD), 0644); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return false
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"ok": true})
|
||||
return true
|
||||
}
|
||||
|
||||
func readSkillMeta(path string) (desc string, toolsList []string, systemPrompt string) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", []string{}, ""
|
||||
}
|
||||
s := string(b)
|
||||
reDesc := regexp.MustCompile(`(?m)^description:\s*(.+)$`)
|
||||
reTools := regexp.MustCompile(`(?m)^##\s*Tools\s*$`)
|
||||
rePrompt := regexp.MustCompile(`(?m)^##\s*System Prompt\s*$`)
|
||||
if m := reDesc.FindStringSubmatch(s); len(m) > 1 {
|
||||
desc = m[1]
|
||||
}
|
||||
if loc := reTools.FindStringIndex(s); loc != nil {
|
||||
block := s[loc[1]:]
|
||||
if p := rePrompt.FindStringIndex(block); p != nil {
|
||||
block = block[:p[0]]
|
||||
}
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
line = strings.TrimPrefix(line, "-")
|
||||
if line != "" {
|
||||
toolsList = append(toolsList, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolsList == nil {
|
||||
toolsList = []string{}
|
||||
}
|
||||
if loc := rePrompt.FindStringIndex(s); loc != nil {
|
||||
systemPrompt = s[loc[1]:]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func queryClawHubSkillVersion(ctx context.Context, skill string) (found bool, version string, err error) {
|
||||
if skill == "" {
|
||||
return false, "", fmt.Errorf("skill empty")
|
||||
}
|
||||
clawhubPath := strings.TrimSpace(resolveClawHubBinary(ctx))
|
||||
if clawhubPath == "" {
|
||||
return false, "", fmt.Errorf("clawhub not installed")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, clawhubPath, "search", skill, "--json")
|
||||
out, runErr := cmd.Output()
|
||||
if runErr != nil {
|
||||
return false, "", runErr
|
||||
}
|
||||
var payload interface{}
|
||||
if err := json.Unmarshal(out, &payload); err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
lowerSkill := strings.ToLower(skill)
|
||||
var walk func(v interface{}) (bool, string)
|
||||
walk = func(v interface{}) (bool, string) {
|
||||
switch t := v.(type) {
|
||||
case map[string]interface{}:
|
||||
name := strings.ToLower(strings.TrimSpace(anyToString(t["name"])))
|
||||
if name == "" {
|
||||
name = strings.ToLower(strings.TrimSpace(anyToString(t["id"])))
|
||||
}
|
||||
if name == lowerSkill || strings.Contains(name, lowerSkill) {
|
||||
ver := anyToString(t["version"])
|
||||
if ver == "" {
|
||||
ver = anyToString(t["latest_version"])
|
||||
}
|
||||
return true, ver
|
||||
}
|
||||
for _, vv := range t {
|
||||
if ok, ver := walk(vv); ok {
|
||||
return ok, ver
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, vv := range t {
|
||||
if ok, ver := walk(vv); ok {
|
||||
return ok, ver
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
ok, ver := walk(payload)
|
||||
return ok, ver, nil
|
||||
}
|
||||
|
||||
func ensureClawHubReady(ctx context.Context) (string, error) {
|
||||
outs := make([]string, 0, 4)
|
||||
if p := resolveClawHubBinary(ctx); p != "" {
|
||||
return "clawhub already installed at: " + p, nil
|
||||
}
|
||||
nodeOut, err := ensureNodeRuntime(ctx)
|
||||
if nodeOut != "" {
|
||||
outs = append(outs, nodeOut)
|
||||
}
|
||||
if err != nil {
|
||||
return strings.Join(outs, "\n"), err
|
||||
}
|
||||
clawOut, err := runInstallCommand(ctx, "npm i -g clawhub")
|
||||
if clawOut != "" {
|
||||
outs = append(outs, clawOut)
|
||||
}
|
||||
if err != nil {
|
||||
return strings.Join(outs, "\n"), err
|
||||
}
|
||||
if p := resolveClawHubBinary(ctx); p != "" {
|
||||
outs = append(outs, "clawhub installed at: "+p)
|
||||
return strings.Join(outs, "\n"), nil
|
||||
}
|
||||
return strings.Join(outs, "\n"), fmt.Errorf("installed clawhub but executable still not found in PATH")
|
||||
}
|
||||
|
||||
func importSkillArchiveFromMultipart(r *http.Request, skillsDir string) ([]string, error) {
|
||||
if err := r.ParseMultipartForm(128 << 20); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, h, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file required")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
uploadDir := filepath.Join(os.TempDir(), "clawgo_skill_uploads")
|
||||
_ = os.MkdirAll(uploadDir, 0755)
|
||||
archivePath := filepath.Join(uploadDir, fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(h.Filename)))
|
||||
out, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := io.Copy(out, f); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(archivePath)
|
||||
return nil, err
|
||||
}
|
||||
_ = out.Close()
|
||||
defer os.Remove(archivePath)
|
||||
|
||||
extractDir, err := os.MkdirTemp("", "clawgo_skill_extract_*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(extractDir)
|
||||
|
||||
if err := extractArchive(archivePath, extractDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
name string
|
||||
dir string
|
||||
}
|
||||
candidates := make([]candidate, 0)
|
||||
seen := map[string]struct{}{}
|
||||
err = filepath.WalkDir(extractDir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(d.Name(), "SKILL.md") {
|
||||
dir := filepath.Dir(path)
|
||||
rel, relErr := filepath.Rel(extractDir, dir)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
if rel == "" {
|
||||
rel = "."
|
||||
}
|
||||
name := filepath.Base(rel)
|
||||
if rel == "." {
|
||||
name = archiveBaseName(h.Filename)
|
||||
}
|
||||
name = sanitizeSkillName(name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return nil
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
candidates = append(candidates, candidate{name: name, dir: dir})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("no SKILL.md found in archive")
|
||||
}
|
||||
|
||||
imported := make([]string, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
dst := filepath.Join(skillsDir, c.name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil, fmt.Errorf("skill already exists: %s", c.name)
|
||||
}
|
||||
if _, err := os.Stat(dst + ".disabled"); err == nil {
|
||||
return nil, fmt.Errorf("disabled skill already exists: %s", c.name)
|
||||
}
|
||||
if err := copyDir(c.dir, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imported = append(imported, c.name)
|
||||
}
|
||||
sort.Strings(imported)
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
func archiveBaseName(filename string) string {
|
||||
name := filepath.Base(strings.TrimSpace(filename))
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".tar.gz"):
|
||||
return name[:len(name)-len(".tar.gz")]
|
||||
case strings.HasSuffix(lower, ".tgz"):
|
||||
return name[:len(name)-len(".tgz")]
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return name[:len(name)-len(".zip")]
|
||||
case strings.HasSuffix(lower, ".tar"):
|
||||
return name[:len(name)-len(".tar")]
|
||||
default:
|
||||
ext := filepath.Ext(name)
|
||||
return strings.TrimSuffix(name, ext)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeSkillName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, ch := range strings.ToLower(name) {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
|
||||
b.WriteRune(ch)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash {
|
||||
b.WriteRune('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
if out == "" || out == "." {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractArchive(archivePath, targetDir string) error {
|
||||
lower := strings.ToLower(archivePath)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return extractZip(archivePath, targetDir)
|
||||
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
|
||||
return extractTarGz(archivePath, targetDir)
|
||||
case strings.HasSuffix(lower, ".tar"):
|
||||
return extractTar(archivePath, targetDir)
|
||||
default:
|
||||
return fmt.Errorf("unsupported archive format: %s", filepath.Base(archivePath))
|
||||
}
|
||||
}
|
||||
|
||||
func extractZip(archivePath, targetDir string) error {
|
||||
zr, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, f := range zr.File {
|
||||
if err := writeArchivedEntry(targetDir, f.Name, f.FileInfo().IsDir(), func() (io.ReadCloser, error) {
|
||||
return f.Open()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(archivePath, targetDir string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
return extractTarReader(tar.NewReader(gz), targetDir)
|
||||
}
|
||||
|
||||
func extractTar(archivePath, targetDir string) error {
|
||||
f, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return extractTarReader(tar.NewReader(f), targetDir)
|
||||
}
|
||||
|
||||
func extractTarReader(tr *tar.Reader, targetDir string) error {
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := writeArchivedEntry(targetDir, hdr.Name, true, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
name := hdr.Name
|
||||
if err := writeArchivedEntry(targetDir, name, false, func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(tr), nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeArchivedEntry(targetDir, name string, isDir bool, opener func() (io.ReadCloser, error)) error {
|
||||
clean := filepath.Clean(strings.TrimSpace(name))
|
||||
clean = strings.TrimPrefix(clean, string(filepath.Separator))
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
for strings.HasPrefix(clean, "../") {
|
||||
clean = strings.TrimPrefix(clean, "../")
|
||||
}
|
||||
if clean == "." || clean == "" {
|
||||
return nil
|
||||
}
|
||||
dst := filepath.Join(targetDir, clean)
|
||||
absTarget, _ := filepath.Abs(targetDir)
|
||||
absDst, _ := filepath.Abs(dst)
|
||||
if !strings.HasPrefix(absDst, absTarget+string(filepath.Separator)) && absDst != absTarget {
|
||||
return fmt.Errorf("invalid archive entry path: %s", name)
|
||||
}
|
||||
if isDir {
|
||||
return os.MkdirAll(dst, 0755)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := opener()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, rc)
|
||||
return err
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
srcPath := filepath.Join(src, e.Name())
|
||||
dstPath := filepath.Join(dst, e.Name())
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
in, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
_ = in.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
_ = in.Close()
|
||||
return err
|
||||
}
|
||||
_ = out.Close()
|
||||
_ = in.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1106,6 +1106,196 @@ func TestHandleWebUINodeArtifactsListAndDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubagentRPCSpawn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetSubagentHandler(func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error) {
|
||||
if action != "spawn" {
|
||||
t.Fatalf("unexpected action: %s", action)
|
||||
}
|
||||
if fmt.Sprint(args["agent_id"]) != "coder" || fmt.Sprint(args["task"]) != "ship it" {
|
||||
t.Fatalf("unexpected args: %+v", args)
|
||||
}
|
||||
return map[string]interface{}{"message": "spawned"}, nil
|
||||
})
|
||||
|
||||
body := `{"method":"subagent.spawn","request_id":"req-1","params":{"agent_id":"coder","task":"ship it","channel":"webui","chat_id":"group"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rpc/subagent", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleSubagentRPC(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"request_id":"req-1"`) || !strings.Contains(rec.Body.String(), `"message":"spawned"`) {
|
||||
t.Fatalf("unexpected rpc body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNodeRPCDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetNodeDispatchHandler(func(ctx context.Context, req nodes.Request, mode string) (nodes.Response, error) {
|
||||
if req.Node != "edge-a" || req.Action != "screen_snapshot" || mode != "relay" {
|
||||
t.Fatalf("unexpected request: %+v mode=%s", req, mode)
|
||||
}
|
||||
return nodes.Response{
|
||||
OK: true,
|
||||
Node: req.Node,
|
||||
Action: req.Action,
|
||||
Payload: map[string]interface{}{
|
||||
"used_transport": "relay",
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
body := `{"method":"node.dispatch","request_id":"req-2","params":{"node":"edge-a","action":"screen_snapshot","mode":"relay","args":{"quality":"high"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rpc/node", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleNodeRPC(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"request_id":"req-2"`) || !strings.Contains(rec.Body.String(), `"used_transport":"relay"`) {
|
||||
t.Fatalf("unexpected rpc body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProviderRPCListModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.json")
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
cfg.Logging.Enabled = false
|
||||
pc := cfg.Models.Providers["openai"]
|
||||
pc.APIBase = "https://example.invalid/v1"
|
||||
pc.APIKey = "test-key"
|
||||
pc.Models = []string{"gpt-5.4", "gpt-5.4-mini"}
|
||||
cfg.Models.Providers["openai"] = pc
|
||||
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetConfigPath(cfgPath)
|
||||
|
||||
body := `{"method":"provider.list_models","request_id":"req-p1","params":{"provider":"openai"}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rpc/provider", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleProviderRPC(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"gpt-5.4"`) || !strings.Contains(rec.Body.String(), `"request_id":"req-p1"`) {
|
||||
t.Fatalf("unexpected provider rpc body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProviderRPCCountTokensUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.json")
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
cfg.Logging.Enabled = false
|
||||
pc := cfg.Models.Providers["openai"]
|
||||
pc.APIBase = "https://example.invalid/v1"
|
||||
pc.APIKey = "test-key"
|
||||
pc.Models = []string{"gpt-5.4"}
|
||||
cfg.Models.Providers["openai"] = pc
|
||||
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetConfigPath(cfgPath)
|
||||
|
||||
body := `{"method":"provider.count_tokens","request_id":"req-p2","params":{"provider":"openai","messages":[{"role":"user","content":"hello"}]}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/rpc/provider", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleProviderRPC(rec, req)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected 503, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"code":"unavailable"`) {
|
||||
t.Fatalf("expected unavailable rpc error, got: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUIProviderModelsUsesRPCFacade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.json")
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
cfg.Logging.Enabled = false
|
||||
pc := cfg.Models.Providers["openai"]
|
||||
pc.APIBase = "https://example.invalid/v1"
|
||||
pc.APIKey = "test-key"
|
||||
pc.Models = []string{"gpt-5.4-mini"}
|
||||
cfg.Models.Providers["openai"] = pc
|
||||
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetConfigPath(cfgPath)
|
||||
srv.SetConfigAfterHook(func() error { return nil })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/provider/models", strings.NewReader(`{"provider":"openai","model":"gpt-5.4"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleWebUIProviderModels(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"gpt-5.4"`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUIProviderRuntimeUsesRPCFacade(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfgPath := filepath.Join(tmp, "config.json")
|
||||
cfg := cfgpkg.DefaultConfig()
|
||||
cfg.Logging.Enabled = false
|
||||
pc := cfg.Models.Providers["openai"]
|
||||
pc.APIBase = "https://example.invalid/v1"
|
||||
pc.APIKey = "test-key"
|
||||
pc.Models = []string{"gpt-5.4-mini"}
|
||||
cfg.Models.Providers["openai"] = pc
|
||||
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer("127.0.0.1", 0, "", nodes.NewManager())
|
||||
srv.SetConfigPath(cfgPath)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/provider/runtime?provider=openai&limit=5", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
srv.handleWebUIProviderRuntime(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"view"`) {
|
||||
t.Fatalf("unexpected response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebUINodeArtifactsExport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -1173,51 +1173,58 @@ func (c *TelegramChannel) handleAction(ctx context.Context, chatID int64, action
|
||||
if !ok && action != "send" && action != "stream" && action != "finalize" {
|
||||
return fmt.Errorf("message_id required for action=%s", action)
|
||||
}
|
||||
switch action {
|
||||
case "edit":
|
||||
htmlContent := clampTelegramHTML(msg.Content, telegramSafeHTMLMaxRunes)
|
||||
editCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
_, err := c.bot.EditMessageText(editCtx, &telego.EditMessageTextParams{ChatID: telegoutil.ID(chatID), MessageID: messageID, Text: htmlContent, ParseMode: telego.ModeHTML})
|
||||
return err
|
||||
case "stream":
|
||||
return c.handleStreamAction(ctx, chatID, msg, false)
|
||||
case "finalize":
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
// Final pass to recover rich formatting after conservative plain streaming.
|
||||
if err := c.handleStreamAction(ctx, chatID, bus.OutboundMessage{
|
||||
ChatID: msg.ChatID,
|
||||
ReplyToID: msg.ReplyToID,
|
||||
Content: msg.Content,
|
||||
Action: "stream",
|
||||
}, true); err != nil {
|
||||
return err
|
||||
handlers := map[string]func() error{
|
||||
"edit": func() error {
|
||||
htmlContent := clampTelegramHTML(msg.Content, telegramSafeHTMLMaxRunes)
|
||||
editCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
_, err := c.bot.EditMessageText(editCtx, &telego.EditMessageTextParams{ChatID: telegoutil.ID(chatID), MessageID: messageID, Text: htmlContent, ParseMode: telego.ModeHTML})
|
||||
return err
|
||||
},
|
||||
"stream": func() error {
|
||||
return c.handleStreamAction(ctx, chatID, msg, false)
|
||||
},
|
||||
"finalize": func() error {
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
// Final pass to recover rich formatting after conservative plain streaming.
|
||||
if err := c.handleStreamAction(ctx, chatID, bus.OutboundMessage{
|
||||
ChatID: msg.ChatID,
|
||||
ReplyToID: msg.ReplyToID,
|
||||
Content: msg.Content,
|
||||
Action: "stream",
|
||||
}, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
streamKey := telegramStreamKey(chatID, msg.ReplyToID)
|
||||
c.streamMu.Lock()
|
||||
delete(c.streamState, streamKey)
|
||||
c.streamMu.Unlock()
|
||||
return nil
|
||||
case "delete":
|
||||
delCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
return c.bot.DeleteMessage(delCtx, &telego.DeleteMessageParams{ChatID: telegoutil.ID(chatID), MessageID: messageID})
|
||||
case "react":
|
||||
reactCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
emoji := strings.TrimSpace(msg.Emoji)
|
||||
if emoji == "" {
|
||||
return fmt.Errorf("emoji required for react action")
|
||||
}
|
||||
return c.bot.SetMessageReaction(reactCtx, &telego.SetMessageReactionParams{
|
||||
ChatID: telegoutil.ID(chatID),
|
||||
MessageID: messageID,
|
||||
Reaction: []telego.ReactionType{&telego.ReactionTypeEmoji{Emoji: emoji}},
|
||||
})
|
||||
default:
|
||||
return fmt.Errorf("unsupported telegram action: %s", action)
|
||||
streamKey := telegramStreamKey(chatID, msg.ReplyToID)
|
||||
c.streamMu.Lock()
|
||||
delete(c.streamState, streamKey)
|
||||
c.streamMu.Unlock()
|
||||
return nil
|
||||
},
|
||||
"delete": func() error {
|
||||
delCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
return c.bot.DeleteMessage(delCtx, &telego.DeleteMessageParams{ChatID: telegoutil.ID(chatID), MessageID: messageID})
|
||||
},
|
||||
"react": func() error {
|
||||
reactCtx, cancel := withTelegramAPITimeout(ctx)
|
||||
defer cancel()
|
||||
emoji := strings.TrimSpace(msg.Emoji)
|
||||
if emoji == "" {
|
||||
return fmt.Errorf("emoji required for react action")
|
||||
}
|
||||
return c.bot.SetMessageReaction(reactCtx, &telego.SetMessageReactionParams{
|
||||
ChatID: telegoutil.ID(chatID),
|
||||
MessageID: messageID,
|
||||
Reaction: []telego.ReactionType{&telego.ReactionTypeEmoji{Emoji: emoji}},
|
||||
})
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return fmt.Errorf("unsupported telegram action: %s", action)
|
||||
}
|
||||
|
||||
func parseTelegramMessageID(raw string) (int, bool) {
|
||||
|
||||
@@ -18,15 +18,6 @@ func truncateString(s string, maxLen int) string {
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
func safeCloseSignal(v interface{}) {
|
||||
ch, ok := v.(chan struct{})
|
||||
if !ok || ch == nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = recover() }()
|
||||
close(ch)
|
||||
}
|
||||
|
||||
type cancelGuard struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
|
||||
@@ -47,8 +47,53 @@ type DispatchPolicy struct {
|
||||
|
||||
var defaultManager = NewManager()
|
||||
|
||||
var nodeActionCapabilityChecks = map[string]func(Capabilities) bool{
|
||||
"run": func(c Capabilities) bool { return c.Run },
|
||||
"agent_task": func(c Capabilities) bool { return c.Model },
|
||||
"camera_snap": func(c Capabilities) bool { return c.Camera },
|
||||
"camera_clip": func(c Capabilities) bool { return c.Camera },
|
||||
"screen_record": func(c Capabilities) bool { return c.Screen },
|
||||
"screen_snapshot": func(c Capabilities) bool { return c.Screen },
|
||||
"location_get": func(c Capabilities) bool { return c.Location },
|
||||
"canvas_snapshot": func(c Capabilities) bool { return c.Canvas },
|
||||
"canvas_action": func(c Capabilities) bool { return c.Canvas },
|
||||
}
|
||||
|
||||
var realtimePreferredActions = map[string]struct{}{
|
||||
"camera_snap": {},
|
||||
"camera_clip": {},
|
||||
"screen_record": {},
|
||||
"screen_snapshot": {},
|
||||
"canvas_snapshot": {},
|
||||
"canvas_action": {},
|
||||
}
|
||||
|
||||
var wireMessageHandlers = map[string]func(*Manager, WireMessage) bool{
|
||||
"node_response": handleWireNodeResponse,
|
||||
}
|
||||
|
||||
func DefaultManager() *Manager { return defaultManager }
|
||||
|
||||
func handleWireNodeResponse(m *Manager, msg WireMessage) bool {
|
||||
if strings.TrimSpace(msg.ID) == "" {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
ch := m.pending[msg.ID]
|
||||
if ch != nil {
|
||||
delete(m.pending, msg.ID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if ch == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
m := &Manager{
|
||||
nodes: map[string]NodeInfo{},
|
||||
@@ -199,28 +244,10 @@ func (m *Manager) RegisterWireSender(nodeID string, sender WireSender) {
|
||||
}
|
||||
|
||||
func (m *Manager) HandleWireMessage(msg WireMessage) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(msg.Type)) {
|
||||
case "node_response":
|
||||
if strings.TrimSpace(msg.ID) == "" {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
ch := m.pending[msg.ID]
|
||||
if ch != nil {
|
||||
delete(m.pending, msg.ID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if ch == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
if handler := wireMessageHandlers[strings.ToLower(strings.TrimSpace(msg.Type))]; handler != nil {
|
||||
return handler(m, msg)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) SendWireRequest(ctx context.Context, nodeID string, req Request) (Response, error) {
|
||||
@@ -314,22 +341,10 @@ func nodeSupportsRequest(n NodeInfo, req Request) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch action {
|
||||
case "run":
|
||||
return n.Capabilities.Run
|
||||
case "agent_task":
|
||||
return n.Capabilities.Model
|
||||
case "camera_snap", "camera_clip":
|
||||
return n.Capabilities.Camera
|
||||
case "screen_record", "screen_snapshot":
|
||||
return n.Capabilities.Screen
|
||||
case "location_get":
|
||||
return n.Capabilities.Location
|
||||
case "canvas_snapshot", "canvas_action":
|
||||
return n.Capabilities.Canvas
|
||||
default:
|
||||
return n.Capabilities.Invoke
|
||||
if check := nodeActionCapabilityChecks[action]; check != nil {
|
||||
return check(n.Capabilities)
|
||||
}
|
||||
return n.Capabilities.Invoke
|
||||
}
|
||||
|
||||
func (m *Manager) PickFor(action string) (NodeInfo, bool) {
|
||||
@@ -595,12 +610,8 @@ func nodeHasAgent(n NodeInfo, agentID string) bool {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
_, ok := realtimePreferredActions[strings.ToLower(strings.TrimSpace(action))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *Manager) reaperLoop() {
|
||||
|
||||
@@ -111,31 +111,24 @@ type HTTPRelayTransport struct {
|
||||
|
||||
func (s *HTTPRelayTransport) Name() string { return "relay" }
|
||||
|
||||
var actionHTTPPaths = map[string]string{
|
||||
"run": "/run",
|
||||
"invoke": "/invoke",
|
||||
"agent_task": "/agent/task",
|
||||
"camera_snap": "/camera/snap",
|
||||
"camera_clip": "/camera/clip",
|
||||
"screen_record": "/screen/record",
|
||||
"screen_snapshot": "/screen/snapshot",
|
||||
"location_get": "/location/get",
|
||||
"canvas_snapshot": "/canvas/snapshot",
|
||||
"canvas_action": "/canvas/action",
|
||||
}
|
||||
|
||||
func actionHTTPPath(action string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(action)) {
|
||||
case "run":
|
||||
return "/run"
|
||||
case "invoke":
|
||||
return "/invoke"
|
||||
case "agent_task":
|
||||
return "/agent/task"
|
||||
case "camera_snap":
|
||||
return "/camera/snap"
|
||||
case "camera_clip":
|
||||
return "/camera/clip"
|
||||
case "screen_record":
|
||||
return "/screen/record"
|
||||
case "screen_snapshot":
|
||||
return "/screen/snapshot"
|
||||
case "location_get":
|
||||
return "/location/get"
|
||||
case "canvas_snapshot":
|
||||
return "/canvas/snapshot"
|
||||
case "canvas_action":
|
||||
return "/canvas/action"
|
||||
default:
|
||||
return "/invoke"
|
||||
if path := actionHTTPPaths[strings.ToLower(strings.TrimSpace(action))]; path != "" {
|
||||
return path
|
||||
}
|
||||
return "/invoke"
|
||||
}
|
||||
|
||||
func DoEndpointRequest(ctx context.Context, client *http.Client, endpoint, token string, req Request) (Response, error) {
|
||||
|
||||
@@ -186,6 +186,23 @@ func (t *WebRTCTransport) currentSignaler(nodeID string) WireSender {
|
||||
return t.signal[strings.TrimSpace(nodeID)]
|
||||
}
|
||||
|
||||
var webRTCSignalHandlers = map[string]func(*gatewayRTCSession, WireMessage) error{
|
||||
"signal_answer": func(session *gatewayRTCSession, msg WireMessage) error {
|
||||
var desc webrtc.SessionDescription
|
||||
if err := mapInto(msg.Payload, &desc); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.SetRemoteDescription(desc)
|
||||
},
|
||||
"signal_candidate": func(session *gatewayRTCSession, msg WireMessage) error {
|
||||
var candidate webrtc.ICECandidateInit
|
||||
if err := mapInto(msg.Payload, &candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.AddICECandidate(candidate)
|
||||
},
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) HandleSignal(msg WireMessage) error {
|
||||
nodeID := strings.TrimSpace(msg.From)
|
||||
if nodeID == "" {
|
||||
@@ -195,22 +212,10 @@ func (t *WebRTCTransport) HandleSignal(msg WireMessage) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(msg.Type)) {
|
||||
case "signal_answer":
|
||||
var desc webrtc.SessionDescription
|
||||
if err := mapInto(msg.Payload, &desc); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.SetRemoteDescription(desc)
|
||||
case "signal_candidate":
|
||||
var candidate webrtc.ICECandidateInit
|
||||
if err := mapInto(msg.Payload, &candidate); err != nil {
|
||||
return err
|
||||
}
|
||||
return session.pc.AddICECandidate(candidate)
|
||||
default:
|
||||
return fmt.Errorf("unsupported signal type: %s", msg.Type)
|
||||
if handler := webRTCSignalHandlers[strings.ToLower(strings.TrimSpace(msg.Type))]; handler != nil {
|
||||
return handler(session, msg)
|
||||
}
|
||||
return fmt.Errorf("unsupported signal type: %s", msg.Type)
|
||||
}
|
||||
|
||||
func (t *WebRTCTransport) Send(ctx context.Context, req Request) (Response, error) {
|
||||
|
||||
63
pkg/rpc/admin.go
Normal file
63
pkg/rpc/admin.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package rpc
|
||||
|
||||
import "context"
|
||||
|
||||
type ConfigService interface {
|
||||
View(context.Context, ConfigViewRequest) (*ConfigViewResponse, *Error)
|
||||
Save(context.Context, ConfigSaveRequest) (*ConfigSaveResponse, *Error)
|
||||
}
|
||||
|
||||
type CronService interface {
|
||||
List(context.Context, ListCronJobsRequest) (*ListCronJobsResponse, *Error)
|
||||
Get(context.Context, GetCronJobRequest) (*GetCronJobResponse, *Error)
|
||||
Mutate(context.Context, MutateCronJobRequest) (*MutateCronJobResponse, *Error)
|
||||
}
|
||||
|
||||
type ConfigViewRequest struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
IncludeHotReloadInfo bool `json:"include_hot_reload_info,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigViewResponse struct {
|
||||
Config interface{} `json:"config,omitempty"`
|
||||
RawConfig interface{} `json:"raw_config,omitempty"`
|
||||
PrettyText string `json:"pretty_text,omitempty"`
|
||||
HotReloadFields []string `json:"hot_reload_fields,omitempty"`
|
||||
HotReloadFieldDetails []map[string]interface{} `json:"hot_reload_field_details,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigSaveRequest struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ConfirmRisky bool `json:"confirm_risky,omitempty"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
type ConfigSaveResponse struct {
|
||||
Saved bool `json:"saved"`
|
||||
RequiresConfirm bool `json:"requires_confirm,omitempty"`
|
||||
ChangedFields []string `json:"changed_fields,omitempty"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type ListCronJobsRequest struct{}
|
||||
|
||||
type ListCronJobsResponse struct {
|
||||
Jobs []interface{} `json:"jobs"`
|
||||
}
|
||||
|
||||
type GetCronJobRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type GetCronJobResponse struct {
|
||||
Job interface{} `json:"job,omitempty"`
|
||||
}
|
||||
|
||||
type MutateCronJobRequest struct {
|
||||
Action string `json:"action"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type MutateCronJobResponse struct {
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
}
|
||||
37
pkg/rpc/envelope.go
Normal file
37
pkg/rpc/envelope.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
func (r Request) NormalizedMethod() string {
|
||||
return strings.ToLower(strings.TrimSpace(r.Method))
|
||||
}
|
||||
|
||||
func DecodeParams(raw json.RawMessage, target interface{}) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, target)
|
||||
}
|
||||
192
pkg/rpc/node.go
Normal file
192
pkg/rpc/node.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/YspCoder/clawgo/pkg/nodes"
|
||||
)
|
||||
|
||||
type NodeService interface {
|
||||
Register(context.Context, RegisterNodeRequest) (*RegisterNodeResponse, *Error)
|
||||
Heartbeat(context.Context, HeartbeatNodeRequest) (*HeartbeatNodeResponse, *Error)
|
||||
Dispatch(context.Context, DispatchNodeRequest) (*DispatchNodeResponse, *Error)
|
||||
ListArtifacts(context.Context, ListNodeArtifactsRequest) (*ListNodeArtifactsResponse, *Error)
|
||||
GetArtifact(context.Context, GetNodeArtifactRequest) (*GetNodeArtifactResponse, *Error)
|
||||
DeleteArtifact(context.Context, DeleteNodeArtifactRequest) (*DeleteNodeArtifactResponse, *Error)
|
||||
PruneArtifacts(context.Context, PruneNodeArtifactsRequest) (*PruneNodeArtifactsResponse, *Error)
|
||||
}
|
||||
|
||||
type RegisterNodeRequest struct {
|
||||
Node nodes.NodeInfo `json:"node"`
|
||||
}
|
||||
|
||||
type RegisterNodeResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type HeartbeatNodeRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type HeartbeatNodeResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DispatchNodeRequest struct {
|
||||
Node string `json:"node"`
|
||||
Action string `json:"action"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Task string `json:"task,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type DispatchNodeResponse struct {
|
||||
Result nodes.Response `json:"result"`
|
||||
}
|
||||
|
||||
type ArtifactSummary struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ArtifactContentRef struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ArtifactDeleteResult struct {
|
||||
ID string `json:"id"`
|
||||
DeletedFile bool `json:"deleted_file"`
|
||||
DeletedAudit bool `json:"deleted_audit"`
|
||||
}
|
||||
|
||||
type ArtifactPruneResult struct {
|
||||
Pruned int `json:"pruned"`
|
||||
DeletedFiles int `json:"deleted_files"`
|
||||
Kept int `json:"kept"`
|
||||
}
|
||||
|
||||
type ListNodeArtifactsRequest struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type ListNodeArtifactsResponse struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
ArtifactRetention map[string]interface{} `json:"artifact_retention,omitempty"`
|
||||
}
|
||||
|
||||
type GetNodeArtifactRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type GetNodeArtifactResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Artifact map[string]interface{} `json:"artifact,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteNodeArtifactRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type DeleteNodeArtifactResponse struct {
|
||||
ArtifactDeleteResult
|
||||
}
|
||||
|
||||
type PruneNodeArtifactsRequest struct {
|
||||
Node string `json:"node,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
KeepLatest int `json:"keep_latest,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type PruneNodeArtifactsResponse struct {
|
||||
ArtifactPruneResult
|
||||
}
|
||||
|
||||
type ProviderService interface {
|
||||
ListModels(context.Context, ListProviderModelsRequest) (*ListProviderModelsResponse, *Error)
|
||||
UpdateModels(context.Context, UpdateProviderModelsRequest) (*UpdateProviderModelsResponse, *Error)
|
||||
Chat(context.Context, ProviderChatRequest) (*ProviderChatResponse, *Error)
|
||||
CountTokens(context.Context, ProviderCountTokensRequest) (*ProviderCountTokensResponse, *Error)
|
||||
RuntimeView(context.Context, ProviderRuntimeViewRequest) (*ProviderRuntimeViewResponse, *Error)
|
||||
RuntimeAction(context.Context, ProviderRuntimeActionRequest) (*ProviderRuntimeActionResponse, *Error)
|
||||
}
|
||||
|
||||
type ListProviderModelsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type ListProviderModelsResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Default string `json:"default_model,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProviderModelsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProviderModelsResponse struct {
|
||||
Provider string `json:"provider"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderChatRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
Tools []map[string]interface{} `json:"tools,omitempty"`
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderChatResponse struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
ToolCalls []map[string]interface{} `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderCountTokensRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
Tools []map[string]interface{} `json:"tools,omitempty"`
|
||||
Options map[string]interface{} `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderCountTokensResponse struct {
|
||||
Usage map[string]interface{} `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderRuntimeViewRequest struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Sort string `json:"sort,omitempty"`
|
||||
ChangesOnly bool `json:"changes_only,omitempty"`
|
||||
WindowSec int `json:"window_sec,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Cursor int `json:"cursor,omitempty"`
|
||||
HealthBelow int `json:"health_below,omitempty"`
|
||||
CooldownUntilBeforeSec int `json:"cooldown_until_before_sec,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderRuntimeViewResponse struct {
|
||||
View map[string]interface{} `json:"view"`
|
||||
}
|
||||
|
||||
type ProviderRuntimeActionRequest struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Action string `json:"action"`
|
||||
OnlyExpiring bool `json:"only_expiring,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderRuntimeActionResponse struct {
|
||||
Result map[string]interface{} `json:"result"`
|
||||
}
|
||||
56
pkg/rpc/registry.go
Normal file
56
pkg/rpc/registry.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MethodHandler func(context.Context, json.RawMessage) (interface{}, *Error)
|
||||
|
||||
type Registry struct {
|
||||
methods map[string]MethodHandler
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{methods: map[string]MethodHandler{}}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(method string, handler MethodHandler) {
|
||||
if r == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
method = strings.ToLower(strings.TrimSpace(method))
|
||||
if method == "" {
|
||||
return
|
||||
}
|
||||
r.methods[method] = handler
|
||||
}
|
||||
|
||||
func (r *Registry) Handle(ctx context.Context, req Request) (interface{}, *Error) {
|
||||
if r == nil {
|
||||
return nil, &Error{Code: "internal", Message: "rpc registry unavailable"}
|
||||
}
|
||||
handler := r.methods[req.NormalizedMethod()]
|
||||
if handler == nil {
|
||||
return nil, &Error{
|
||||
Code: "invalid_argument",
|
||||
Message: "unknown method",
|
||||
Details: map[string]interface{}{"method": strings.TrimSpace(req.Method)},
|
||||
}
|
||||
}
|
||||
return handler(ctx, req.Params)
|
||||
}
|
||||
|
||||
func RegisterJSON[T any](r *Registry, method string, handler func(context.Context, T) (interface{}, *Error)) {
|
||||
if r == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
r.Register(method, func(ctx context.Context, raw json.RawMessage) (interface{}, *Error) {
|
||||
var params T
|
||||
if err := DecodeParams(raw, ¶ms); err != nil {
|
||||
return nil, &Error{Code: "invalid_argument", Message: err.Error()}
|
||||
}
|
||||
return handler(ctx, params)
|
||||
})
|
||||
}
|
||||
87
pkg/rpc/subagent.go
Normal file
87
pkg/rpc/subagent.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/YspCoder/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
type SubagentService interface {
|
||||
List(context.Context, ListSubagentsRequest) (*ListSubagentsResponse, *Error)
|
||||
Snapshot(context.Context, SnapshotRequest) (*SnapshotResponse, *Error)
|
||||
Get(context.Context, GetSubagentRequest) (*GetSubagentResponse, *Error)
|
||||
Spawn(context.Context, SpawnSubagentRequest) (*SpawnSubagentResponse, *Error)
|
||||
DispatchAndWait(context.Context, DispatchAndWaitRequest) (*DispatchAndWaitResponse, *Error)
|
||||
Registry(context.Context, RegistryRequest) (*RegistryResponse, *Error)
|
||||
}
|
||||
|
||||
type ListSubagentsRequest struct{}
|
||||
|
||||
type ListSubagentsResponse struct {
|
||||
Items []*tools.SubagentTask `json:"items"`
|
||||
}
|
||||
|
||||
type SnapshotRequest struct {
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type SnapshotResponse struct {
|
||||
Snapshot tools.RuntimeSnapshot `json:"snapshot"`
|
||||
}
|
||||
|
||||
type GetSubagentRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type GetSubagentResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Task *tools.SubagentTask `json:"task,omitempty"`
|
||||
}
|
||||
|
||||
type SpawnSubagentRequest struct {
|
||||
Task string `json:"task"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
RetryBackoffMS int `json:"retry_backoff_ms,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||||
MaxTaskChars int `json:"max_task_chars,omitempty"`
|
||||
MaxResultChars int `json:"max_result_chars,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
}
|
||||
|
||||
type SpawnSubagentResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DispatchAndWaitRequest struct {
|
||||
Task string `json:"task"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
ParentRunID string `json:"parent_run_id,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
ChatID string `json:"chat_id,omitempty"`
|
||||
MaxRetries int `json:"max_retries,omitempty"`
|
||||
RetryBackoffMS int `json:"retry_backoff_ms,omitempty"`
|
||||
TimeoutSec int `json:"timeout_sec,omitempty"`
|
||||
MaxTaskChars int `json:"max_task_chars,omitempty"`
|
||||
MaxResultChars int `json:"max_result_chars,omitempty"`
|
||||
WaitTimeoutSec int `json:"wait_timeout_sec,omitempty"`
|
||||
}
|
||||
|
||||
type DispatchAndWaitResponse struct {
|
||||
Task *tools.SubagentTask `json:"task,omitempty"`
|
||||
Reply *tools.RouterReply `json:"reply,omitempty"`
|
||||
Merged string `json:"merged,omitempty"`
|
||||
}
|
||||
|
||||
type RegistryRequest struct{}
|
||||
|
||||
type RegistryResponse struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
}
|
||||
50
pkg/rpc/workspace.go
Normal file
50
pkg/rpc/workspace.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package rpc
|
||||
|
||||
import "context"
|
||||
|
||||
type WorkspaceService interface {
|
||||
ListFiles(context.Context, ListWorkspaceFilesRequest) (*ListWorkspaceFilesResponse, *Error)
|
||||
ReadFile(context.Context, ReadWorkspaceFileRequest) (*ReadWorkspaceFileResponse, *Error)
|
||||
WriteFile(context.Context, WriteWorkspaceFileRequest) (*WriteWorkspaceFileResponse, *Error)
|
||||
DeleteFile(context.Context, DeleteWorkspaceFileRequest) (*DeleteWorkspaceFileResponse, *Error)
|
||||
}
|
||||
|
||||
type ListWorkspaceFilesRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
type ListWorkspaceFilesResponse struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
|
||||
type ReadWorkspaceFileRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ReadWorkspaceFileResponse struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Found bool `json:"found,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type WriteWorkspaceFileRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type WriteWorkspaceFileResponse struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Saved bool `json:"saved,omitempty"`
|
||||
}
|
||||
|
||||
type DeleteWorkspaceFileRequest struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type DeleteWorkspaceFileResponse struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
@@ -50,15 +50,14 @@ func (t *BrowserTool) Parameters() map[string]interface{} {
|
||||
func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
action := MapStringArg(args, "action")
|
||||
url := MapStringArg(args, "url")
|
||||
|
||||
switch action {
|
||||
case "screenshot":
|
||||
return t.takeScreenshot(ctx, url)
|
||||
case "content":
|
||||
return t.fetchDynamicContent(ctx, url)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown browser action: %s", action)
|
||||
handlers := map[string]func(context.Context, string) (string, error){
|
||||
"screenshot": t.takeScreenshot,
|
||||
"content": t.fetchDynamicContent,
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler(ctx, url)
|
||||
}
|
||||
return "", fmt.Errorf("unknown browser action: %s", action)
|
||||
}
|
||||
|
||||
func (t *BrowserTool) takeScreenshot(ctx context.Context, url string) (string, error) {
|
||||
|
||||
@@ -34,6 +34,7 @@ func (t *CronTool) Parameters() map[string]interface{} {
|
||||
}
|
||||
|
||||
func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
_ = ctx
|
||||
if t.cs == nil {
|
||||
return "Error: cron service not available", nil
|
||||
}
|
||||
@@ -42,40 +43,41 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]interface{}) (st
|
||||
action = "list"
|
||||
}
|
||||
id := MapStringArg(args, "id")
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
jobs := t.cs.ListJobs(true)
|
||||
b, _ := json.Marshal(jobs)
|
||||
return string(b), nil
|
||||
case "delete":
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=delete", ErrMissingField)
|
||||
}
|
||||
ok := t.cs.RemoveJob(id)
|
||||
if !ok {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("deleted job: %s", id), nil
|
||||
case "enable":
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=enable", ErrMissingField)
|
||||
}
|
||||
job := t.cs.EnableJob(id, true)
|
||||
if job == nil {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("enabled job: %s", id), nil
|
||||
case "disable":
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=disable", ErrMissingField)
|
||||
}
|
||||
job := t.cs.EnableJob(id, false)
|
||||
if job == nil {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("disabled job: %s", id), nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedAction, action)
|
||||
handlers := map[string]func() (string, error){
|
||||
"list": func() (string, error) {
|
||||
b, _ := json.Marshal(t.cs.ListJobs(true))
|
||||
return string(b), nil
|
||||
},
|
||||
"delete": func() (string, error) {
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=delete", ErrMissingField)
|
||||
}
|
||||
if !t.cs.RemoveJob(id) {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("deleted job: %s", id), nil
|
||||
},
|
||||
"enable": func() (string, error) {
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=enable", ErrMissingField)
|
||||
}
|
||||
if t.cs.EnableJob(id, true) == nil {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("enabled job: %s", id), nil
|
||||
},
|
||||
"disable": func() (string, error) {
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: id for action=disable", ErrMissingField)
|
||||
}
|
||||
if t.cs.EnableJob(id, false) == nil {
|
||||
return fmt.Sprintf("job not found: %s", id), nil
|
||||
}
|
||||
return fmt.Sprintf("disabled job: %s", id), nil
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedAction, action)
|
||||
}
|
||||
|
||||
124
pkg/tools/mcp.go
124
pkg/tools/mcp.go
@@ -131,66 +131,72 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) (str
|
||||
return "", err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
switch action {
|
||||
case "list_tools":
|
||||
out, err := client.listAll(callCtx, "tools/list", "tools")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
case "call_tool":
|
||||
toolName := strings.TrimSpace(mcpStringArg(args, "tool"))
|
||||
if toolName == "" {
|
||||
return "", fmt.Errorf("tool is required for action=call_tool")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": mcpObjectArg(args, "arguments"),
|
||||
}
|
||||
out, err := client.request(callCtx, "tools/call", params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
case "list_resources":
|
||||
out, err := client.listAll(callCtx, "resources/list", "resources")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
case "read_resource":
|
||||
resourceURI := strings.TrimSpace(mcpStringArg(args, "uri"))
|
||||
if resourceURI == "" {
|
||||
return "", fmt.Errorf("uri is required for action=read_resource")
|
||||
}
|
||||
out, err := client.request(callCtx, "resources/read", map[string]interface{}{"uri": resourceURI})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
case "list_prompts":
|
||||
out, err := client.listAll(callCtx, "prompts/list", "prompts")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
case "get_prompt":
|
||||
promptName := strings.TrimSpace(mcpStringArg(args, "prompt"))
|
||||
if promptName == "" {
|
||||
return "", fmt.Errorf("prompt is required for action=get_prompt")
|
||||
}
|
||||
out, err := client.request(callCtx, "prompts/get", map[string]interface{}{
|
||||
"name": promptName,
|
||||
"arguments": mcpObjectArg(args, "arguments"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported action %q", action)
|
||||
handlers := map[string]func() (string, error){
|
||||
"list_tools": func() (string, error) {
|
||||
out, err := client.listAll(callCtx, "tools/list", "tools")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
"call_tool": func() (string, error) {
|
||||
toolName := strings.TrimSpace(mcpStringArg(args, "tool"))
|
||||
if toolName == "" {
|
||||
return "", fmt.Errorf("tool is required for action=call_tool")
|
||||
}
|
||||
out, err := client.request(callCtx, "tools/call", map[string]interface{}{
|
||||
"name": toolName,
|
||||
"arguments": mcpObjectArg(args, "arguments"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
"list_resources": func() (string, error) {
|
||||
out, err := client.listAll(callCtx, "resources/list", "resources")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
"read_resource": func() (string, error) {
|
||||
resourceURI := strings.TrimSpace(mcpStringArg(args, "uri"))
|
||||
if resourceURI == "" {
|
||||
return "", fmt.Errorf("uri is required for action=read_resource")
|
||||
}
|
||||
out, err := client.request(callCtx, "resources/read", map[string]interface{}{"uri": resourceURI})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
"list_prompts": func() (string, error) {
|
||||
out, err := client.listAll(callCtx, "prompts/list", "prompts")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
"get_prompt": func() (string, error) {
|
||||
promptName := strings.TrimSpace(mcpStringArg(args, "prompt"))
|
||||
if promptName == "" {
|
||||
return "", fmt.Errorf("prompt is required for action=get_prompt")
|
||||
}
|
||||
out, err := client.request(callCtx, "prompts/get", map[string]interface{}{
|
||||
"name": promptName,
|
||||
"arguments": mcpObjectArg(args, "arguments"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prettyJSON(out)
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "", fmt.Errorf("unsupported action %q", action)
|
||||
}
|
||||
|
||||
func (t *MCPTool) DiscoverTools(ctx context.Context) []Tool {
|
||||
|
||||
@@ -144,25 +144,37 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{})
|
||||
}
|
||||
messageID := MapStringArg(args, "message_id")
|
||||
emoji := MapStringArg(args, "emoji")
|
||||
|
||||
switch action {
|
||||
case "send":
|
||||
if content == "" && media == "" {
|
||||
return "", fmt.Errorf("%w: message/content or media for action=send", ErrMissingField)
|
||||
validators := map[string]func() error{
|
||||
"send": func() error {
|
||||
if content == "" && media == "" {
|
||||
return fmt.Errorf("%w: message/content or media for action=send", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
"edit": func() error {
|
||||
if messageID == "" || content == "" {
|
||||
return fmt.Errorf("%w: message_id and message/content for action=edit", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
"delete": func() error {
|
||||
if messageID == "" {
|
||||
return fmt.Errorf("%w: message_id for action=delete", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
"react": func() error {
|
||||
if messageID == "" || emoji == "" {
|
||||
return fmt.Errorf("%w: message_id and emoji for action=react", ErrMissingField)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
if validate := validators[action]; validate != nil {
|
||||
if err := validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "edit":
|
||||
if messageID == "" || content == "" {
|
||||
return "", fmt.Errorf("%w: message_id and message/content for action=edit", ErrMissingField)
|
||||
}
|
||||
case "delete":
|
||||
if messageID == "" {
|
||||
return "", fmt.Errorf("%w: message_id for action=delete", ErrMissingField)
|
||||
}
|
||||
case "react":
|
||||
if messageID == "" || emoji == "" {
|
||||
return "", fmt.Errorf("%w: message_id and emoji for action=react", ErrMissingField)
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedAction, action)
|
||||
}
|
||||
|
||||
|
||||
@@ -54,81 +54,86 @@ func (t *NodesTool) Execute(ctx context.Context, args map[string]interface{}) (s
|
||||
if t.manager == nil {
|
||||
return "", fmt.Errorf("nodes manager not configured")
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "status", "describe":
|
||||
if nodeID != "" {
|
||||
n, ok := t.manager.Get(nodeID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("node not found: %s", nodeID)
|
||||
var statusHandler func() (string, error)
|
||||
handlers := map[string]func() (string, error){
|
||||
"status": func() (string, error) {
|
||||
if nodeID != "" {
|
||||
n, ok := t.manager.Get(nodeID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("node not found: %s", nodeID)
|
||||
}
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b), nil
|
||||
}
|
||||
b, _ := json.Marshal(n)
|
||||
b, _ := json.Marshal(t.manager.List())
|
||||
return string(b), nil
|
||||
}
|
||||
b, _ := json.Marshal(t.manager.List())
|
||||
return string(b), nil
|
||||
default:
|
||||
reqArgs := map[string]interface{}{}
|
||||
if raw, ok := args["args"].(map[string]interface{}); ok {
|
||||
for k, v := range raw {
|
||||
reqArgs[k] = v
|
||||
}
|
||||
}
|
||||
if rawPaths := MapStringListArg(args, "artifact_paths"); len(rawPaths) > 0 {
|
||||
reqArgs["artifact_paths"] = rawPaths
|
||||
}
|
||||
if cmd, ok := args["command"].([]interface{}); ok && len(cmd) > 0 {
|
||||
reqArgs["command"] = cmd
|
||||
}
|
||||
if facing := MapStringArg(args, "facing"); facing != "" {
|
||||
f := strings.ToLower(strings.TrimSpace(facing))
|
||||
if f != "front" && f != "back" && f != "both" {
|
||||
return "", fmt.Errorf("invalid_args: facing must be front|back|both")
|
||||
}
|
||||
reqArgs["facing"] = f
|
||||
}
|
||||
if di := MapIntArg(args, "duration_ms", 0); di > 0 {
|
||||
if di <= 0 || di > 300000 {
|
||||
return "", fmt.Errorf("invalid_args: duration_ms must be in 1..300000")
|
||||
}
|
||||
reqArgs["duration_ms"] = di
|
||||
}
|
||||
task := MapStringArg(args, "task")
|
||||
model := MapStringArg(args, "model")
|
||||
if action == "agent_task" && strings.TrimSpace(task) == "" {
|
||||
return "", fmt.Errorf("invalid_args: agent_task requires task")
|
||||
}
|
||||
if action == "canvas_action" {
|
||||
if act := MapStringArg(reqArgs, "action"); act == "" {
|
||||
return "", fmt.Errorf("invalid_args: canvas_action requires args.action")
|
||||
}
|
||||
}
|
||||
if nodeID == "" {
|
||||
if picked, ok := t.manager.PickRequest(nodes.Request{Action: action, Task: task, Model: model, Args: reqArgs}, mode); ok {
|
||||
nodeID = picked.ID
|
||||
}
|
||||
}
|
||||
if nodeID == "" {
|
||||
return "", fmt.Errorf("no eligible node found for action=%s", action)
|
||||
}
|
||||
req := nodes.Request{Action: action, Node: nodeID, Task: task, Model: model, Args: reqArgs}
|
||||
if !t.manager.SupportsRequest(nodeID, req) {
|
||||
return "", fmt.Errorf("node %s does not support action=%s", nodeID, action)
|
||||
}
|
||||
if t.router == nil {
|
||||
return "", fmt.Errorf("nodes transport router not configured")
|
||||
}
|
||||
started := time.Now()
|
||||
resp, err := t.router.Dispatch(ctx, req, mode)
|
||||
durationMs := int(time.Since(started).Milliseconds())
|
||||
if err != nil {
|
||||
t.writeAudit(req, nodes.Response{OK: false, Code: "transport_error", Error: err.Error(), Node: nodeID, Action: action}, mode, durationMs)
|
||||
return "", err
|
||||
}
|
||||
t.writeAudit(req, resp, mode, durationMs)
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
},
|
||||
}
|
||||
statusHandler = handlers["status"]
|
||||
handlers["describe"] = func() (string, error) { return statusHandler() }
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
reqArgs := map[string]interface{}{}
|
||||
if raw, ok := args["args"].(map[string]interface{}); ok {
|
||||
for k, v := range raw {
|
||||
reqArgs[k] = v
|
||||
}
|
||||
}
|
||||
if rawPaths := MapStringListArg(args, "artifact_paths"); len(rawPaths) > 0 {
|
||||
reqArgs["artifact_paths"] = rawPaths
|
||||
}
|
||||
if cmd, ok := args["command"].([]interface{}); ok && len(cmd) > 0 {
|
||||
reqArgs["command"] = cmd
|
||||
}
|
||||
if facing := MapStringArg(args, "facing"); facing != "" {
|
||||
f := strings.ToLower(strings.TrimSpace(facing))
|
||||
if f != "front" && f != "back" && f != "both" {
|
||||
return "", fmt.Errorf("invalid_args: facing must be front|back|both")
|
||||
}
|
||||
reqArgs["facing"] = f
|
||||
}
|
||||
if di := MapIntArg(args, "duration_ms", 0); di > 0 {
|
||||
if di <= 0 || di > 300000 {
|
||||
return "", fmt.Errorf("invalid_args: duration_ms must be in 1..300000")
|
||||
}
|
||||
reqArgs["duration_ms"] = di
|
||||
}
|
||||
task := MapStringArg(args, "task")
|
||||
model := MapStringArg(args, "model")
|
||||
if action == "agent_task" && strings.TrimSpace(task) == "" {
|
||||
return "", fmt.Errorf("invalid_args: agent_task requires task")
|
||||
}
|
||||
if action == "canvas_action" {
|
||||
if act := MapStringArg(reqArgs, "action"); act == "" {
|
||||
return "", fmt.Errorf("invalid_args: canvas_action requires args.action")
|
||||
}
|
||||
}
|
||||
if nodeID == "" {
|
||||
if picked, ok := t.manager.PickRequest(nodes.Request{Action: action, Task: task, Model: model, Args: reqArgs}, mode); ok {
|
||||
nodeID = picked.ID
|
||||
}
|
||||
}
|
||||
if nodeID == "" {
|
||||
return "", fmt.Errorf("no eligible node found for action=%s", action)
|
||||
}
|
||||
req := nodes.Request{Action: action, Node: nodeID, Task: task, Model: model, Args: reqArgs}
|
||||
if !t.manager.SupportsRequest(nodeID, req) {
|
||||
return "", fmt.Errorf("node %s does not support action=%s", nodeID, action)
|
||||
}
|
||||
if t.router == nil {
|
||||
return "", fmt.Errorf("nodes transport router not configured")
|
||||
}
|
||||
started := time.Now()
|
||||
resp, err := t.router.Dispatch(ctx, req, mode)
|
||||
durationMs := int(time.Since(started).Milliseconds())
|
||||
if err != nil {
|
||||
t.writeAudit(req, nodes.Response{OK: false, Code: "transport_error", Error: err.Error(), Node: nodeID, Action: action}, mode, durationMs)
|
||||
return "", err
|
||||
}
|
||||
t.writeAudit(req, resp, mode, durationMs)
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (t *NodesTool) writeAudit(req nodes.Request, resp nodes.Response, mode string, durationMs int) {
|
||||
|
||||
@@ -29,54 +29,58 @@ func (t *ProcessTool) Execute(ctx context.Context, args map[string]interface{})
|
||||
if sid == "" {
|
||||
sid = MapStringArg(args, "sessionId")
|
||||
}
|
||||
switch action {
|
||||
case "list":
|
||||
b, _ := json.Marshal(t.m.List())
|
||||
return string(b), nil
|
||||
case "log":
|
||||
off := MapIntArg(args, "offset", 0)
|
||||
lim := MapIntArg(args, "limit", 0)
|
||||
return t.m.Log(sid, off, lim)
|
||||
case "kill":
|
||||
if err := t.m.Kill(sid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "killed", nil
|
||||
case "poll":
|
||||
timeout := MapIntArg(args, "timeout_ms", 0)
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
s, ok := t.m.Get(sid)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if timeout > 0 {
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(time.Duration(timeout) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
handlers := map[string]func() (string, error){
|
||||
"list": func() (string, error) {
|
||||
b, _ := json.Marshal(t.m.List())
|
||||
return string(b), nil
|
||||
},
|
||||
"log": func() (string, error) {
|
||||
return t.m.Log(sid, MapIntArg(args, "offset", 0), MapIntArg(args, "limit", 0))
|
||||
},
|
||||
"kill": func() (string, error) {
|
||||
if err := t.m.Kill(sid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
off := MapIntArg(args, "offset", 0)
|
||||
lim := MapIntArg(args, "limit", 0)
|
||||
if lim <= 0 {
|
||||
lim = 1200
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
chunk, _ := t.m.Log(sid, off, lim)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
resp := map[string]interface{}{"id": s.ID, "running": s.ExitCode == nil, "started_at": s.StartedAt.Format(time.RFC3339), "log": chunk, "next_offset": off + len(chunk)}
|
||||
if s.ExitCode != nil {
|
||||
resp["exit_code"] = *s.ExitCode
|
||||
resp["ended_at"] = s.EndedAt.Format(time.RFC3339)
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
default:
|
||||
return "", nil
|
||||
return "killed", nil
|
||||
},
|
||||
"poll": func() (string, error) {
|
||||
timeout := MapIntArg(args, "timeout_ms", 0)
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
s, ok := t.m.Get(sid)
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if timeout > 0 {
|
||||
select {
|
||||
case <-s.done:
|
||||
case <-time.After(time.Duration(timeout) * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
off := MapIntArg(args, "offset", 0)
|
||||
lim := MapIntArg(args, "limit", 0)
|
||||
if lim <= 0 {
|
||||
lim = 1200
|
||||
}
|
||||
if off < 0 {
|
||||
off = 0
|
||||
}
|
||||
chunk, _ := t.m.Log(sid, off, lim)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
resp := map[string]interface{}{"id": s.ID, "running": s.ExitCode == nil, "started_at": s.StartedAt.Format(time.RFC3339), "log": chunk, "next_offset": off + len(chunk)}
|
||||
if s.ExitCode != nil {
|
||||
resp["exit_code"] = *s.ExitCode
|
||||
resp["ended_at"] = s.EndedAt.Format(time.RFC3339)
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return string(b), nil
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -73,185 +73,189 @@ func (t *SessionsTool) Execute(ctx context.Context, args map[string]interface{})
|
||||
kindFilter[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
type sessionActionHandler func() (string, error)
|
||||
handlers := map[string]sessionActionHandler{
|
||||
"list": func() (string, error) {
|
||||
if t.listFn == nil {
|
||||
return "sessions list unavailable", nil
|
||||
}
|
||||
items := t.listFn(limit * 3)
|
||||
if len(items) == 0 {
|
||||
return "No sessions.", nil
|
||||
}
|
||||
if len(kindFilter) > 0 {
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
k := strings.ToLower(strings.TrimSpace(s.Kind))
|
||||
if _, ok := kindFilter[k]; ok {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if activeMinutes > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(activeMinutes) * time.Minute)
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
if s.UpdatedAt.After(cutoff) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if query != "" {
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
blob := strings.ToLower(s.Key + "\n" + s.Kind + "\n" + s.Summary)
|
||||
if strings.Contains(blob, query) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "No sessions (after filters).", nil
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Sessions:\n")
|
||||
for _, s := range items {
|
||||
sb.WriteString(fmt.Sprintf("- %s kind=%s compactions=%d updated=%s\n", s.Key, s.Kind, s.CompactionCount, s.UpdatedAt.Format(time.RFC3339)))
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
"history": func() (string, error) {
|
||||
if t.historyFn == nil {
|
||||
return "sessions history unavailable", nil
|
||||
}
|
||||
key := MapStringArg(args, "key")
|
||||
if key == "" {
|
||||
return "key is required for history", nil
|
||||
}
|
||||
raw := t.historyFn(key, 0)
|
||||
if len(raw) == 0 {
|
||||
return "No history.", nil
|
||||
}
|
||||
type indexedMsg struct {
|
||||
idx int
|
||||
msg providers.Message
|
||||
}
|
||||
window := make([]indexedMsg, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
window = append(window, indexedMsg{idx: i + 1, msg: m})
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
if t.listFn == nil {
|
||||
return "sessions list unavailable", nil
|
||||
}
|
||||
items := t.listFn(limit * 3)
|
||||
if len(items) == 0 {
|
||||
return "No sessions.", nil
|
||||
}
|
||||
if len(kindFilter) > 0 {
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
k := strings.ToLower(strings.TrimSpace(s.Kind))
|
||||
if _, ok := kindFilter[k]; ok {
|
||||
filtered = append(filtered, s)
|
||||
// Window selectors are 1-indexed (human-friendly)
|
||||
if around > 0 {
|
||||
center := around - 1
|
||||
if center < 0 {
|
||||
center = 0
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if activeMinutes > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(activeMinutes) * time.Minute)
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
if s.UpdatedAt.After(cutoff) {
|
||||
filtered = append(filtered, s)
|
||||
if center >= len(window) {
|
||||
center = len(window) - 1
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if query != "" {
|
||||
filtered := make([]SessionInfo, 0, len(items))
|
||||
for _, s := range items {
|
||||
blob := strings.ToLower(s.Key + "\n" + s.Kind + "\n" + s.Summary)
|
||||
if strings.Contains(blob, query) {
|
||||
filtered = append(filtered, s)
|
||||
half := limit / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "No sessions (after filters).", nil
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) })
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Sessions:\n")
|
||||
for _, s := range items {
|
||||
sb.WriteString(fmt.Sprintf("- %s kind=%s compactions=%d updated=%s\n", s.Key, s.Kind, s.CompactionCount, s.UpdatedAt.Format(time.RFC3339)))
|
||||
}
|
||||
return sb.String(), nil
|
||||
case "history":
|
||||
if t.historyFn == nil {
|
||||
return "sessions history unavailable", nil
|
||||
}
|
||||
key := MapStringArg(args, "key")
|
||||
if key == "" {
|
||||
return "key is required for history", nil
|
||||
}
|
||||
raw := t.historyFn(key, 0)
|
||||
if len(raw) == 0 {
|
||||
return "No history.", nil
|
||||
}
|
||||
type indexedMsg struct {
|
||||
idx int
|
||||
msg providers.Message
|
||||
}
|
||||
window := make([]indexedMsg, 0, len(raw))
|
||||
for i, m := range raw {
|
||||
window = append(window, indexedMsg{idx: i + 1, msg: m})
|
||||
}
|
||||
|
||||
// Window selectors are 1-indexed (human-friendly)
|
||||
if around > 0 {
|
||||
center := around - 1
|
||||
if center < 0 {
|
||||
center = 0
|
||||
}
|
||||
if center >= len(window) {
|
||||
center = len(window) - 1
|
||||
}
|
||||
half := limit / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
start := center - half
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := center + half + 1
|
||||
if end > len(window) {
|
||||
end = len(window)
|
||||
}
|
||||
window = window[start:end]
|
||||
} else {
|
||||
start := 0
|
||||
end := len(window)
|
||||
if after > 0 {
|
||||
start = after
|
||||
if start > len(window) {
|
||||
start = len(window)
|
||||
}
|
||||
}
|
||||
if before > 0 {
|
||||
end = before - 1
|
||||
if end < 0 {
|
||||
end = 0
|
||||
start := center - half
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := center + half + 1
|
||||
if end > len(window) {
|
||||
end = len(window)
|
||||
}
|
||||
window = window[start:end]
|
||||
} else {
|
||||
start := 0
|
||||
end := len(window)
|
||||
if after > 0 {
|
||||
start = after
|
||||
if start > len(window) {
|
||||
start = len(window)
|
||||
}
|
||||
}
|
||||
if before > 0 {
|
||||
end = before - 1
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
if end > len(window) {
|
||||
end = len(window)
|
||||
}
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
window = window[start:end]
|
||||
}
|
||||
if start > end {
|
||||
start = end
|
||||
}
|
||||
window = window[start:end]
|
||||
}
|
||||
|
||||
if !includeTools {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == "tool" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
if roleFilter != "" {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == roleFilter {
|
||||
if !includeTools {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == "tool" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
if fromMeSet {
|
||||
targetRole := "user"
|
||||
if fromMe {
|
||||
targetRole = "assistant"
|
||||
}
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == targetRole {
|
||||
filtered = append(filtered, m)
|
||||
if roleFilter != "" {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == roleFilter {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
if query != "" {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
blob := strings.ToLower(m.msg.Role + "\n" + m.msg.Content)
|
||||
if strings.Contains(blob, query) {
|
||||
filtered = append(filtered, m)
|
||||
if fromMeSet {
|
||||
targetRole := "user"
|
||||
if fromMe {
|
||||
targetRole = "assistant"
|
||||
}
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
if strings.ToLower(m.msg.Role) == targetRole {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
if len(window) == 0 {
|
||||
return "No history (after filters).", nil
|
||||
}
|
||||
if len(window) > limit {
|
||||
window = window[len(window)-limit:]
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("History for %s:\n", key))
|
||||
for _, item := range window {
|
||||
content := item.msg.Content
|
||||
if len(content) > 180 {
|
||||
content = content[:180] + "..."
|
||||
if query != "" {
|
||||
filtered := make([]indexedMsg, 0, len(window))
|
||||
for _, m := range window {
|
||||
blob := strings.ToLower(m.msg.Role + "\n" + m.msg.Content)
|
||||
if strings.Contains(blob, query) {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
window = filtered
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- [#%d][%s] %s\n", item.idx, item.msg.Role, content))
|
||||
}
|
||||
return sb.String(), nil
|
||||
default:
|
||||
return "unsupported action", nil
|
||||
if len(window) == 0 {
|
||||
return "No history (after filters).", nil
|
||||
}
|
||||
if len(window) > limit {
|
||||
window = window[len(window)-limit:]
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("History for %s:\n", key))
|
||||
for _, item := range window {
|
||||
content := item.msg.Content
|
||||
if len(content) > 180 {
|
||||
content = content[:180] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- [#%d][%s] %s\n", item.idx, item.msg.Role, content))
|
||||
}
|
||||
return sb.String(), nil
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "unsupported action", nil
|
||||
}
|
||||
|
||||
@@ -358,7 +358,6 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
|
||||
CreatedAt: task.Updated,
|
||||
})
|
||||
sm.persistTaskLocked(task, "failed", task.Result)
|
||||
sm.notifyTaskWaitersLocked(task.ID)
|
||||
} else {
|
||||
task.Status = RuntimeStatusCompleted
|
||||
task.Result = applySubagentResultQuota(result, task.MaxResultChars)
|
||||
@@ -376,7 +375,6 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
|
||||
CreatedAt: task.Updated,
|
||||
})
|
||||
sm.persistTaskLocked(task, "completed", task.Result)
|
||||
sm.notifyTaskWaitersLocked(task.ID)
|
||||
}
|
||||
sm.mu.Unlock()
|
||||
|
||||
@@ -405,6 +403,9 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
|
||||
},
|
||||
})
|
||||
}
|
||||
sm.mu.Lock()
|
||||
sm.notifyTaskWaitersLocked(task.ID)
|
||||
sm.mu.Unlock()
|
||||
}
|
||||
|
||||
func (sm *SubagentManager) recordEKG(task *SubagentTask, runErr error) {
|
||||
|
||||
@@ -69,16 +69,20 @@ func (t *SubagentConfigTool) SetConfigPath(path string) {
|
||||
|
||||
func (t *SubagentConfigTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
|
||||
_ = ctx
|
||||
switch stringArgFromMap(args, "action") {
|
||||
case "upsert":
|
||||
result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshalSubagentConfigPayload(result)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported action")
|
||||
action := stringArgFromMap(args, "action")
|
||||
handlers := map[string]func() (string, error){
|
||||
"upsert": func() (string, error) {
|
||||
result, err := UpsertConfigSubagent(t.getConfigPath(), cloneSubagentConfigArgs(args))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshalSubagentConfigPayload(result)
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s", ErrUnsupportedAction, action)
|
||||
}
|
||||
|
||||
func (t *SubagentConfigTool) getConfigPath() string {
|
||||
|
||||
@@ -563,149 +563,171 @@ func (t *SubagentProfileTool) Execute(ctx context.Context, args map[string]inter
|
||||
}
|
||||
action := strings.ToLower(MapStringArg(args, "action"))
|
||||
agentID := normalizeSubagentIdentifier(MapStringArg(args, "agent_id"))
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
items, err := t.store.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "No subagent profiles.", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagent Profiles:\n")
|
||||
for i, p := range items {
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] role=%s memory_ns=%s\n", i+1, p.AgentID, p.Status, p.Role, p.MemoryNamespace))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
case "get":
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
p, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
b, _ := json.MarshalIndent(p, "", " ")
|
||||
return string(b), nil
|
||||
case "create":
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
if _, ok, err := t.store.Get(agentID); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return "subagent profile already exists", nil
|
||||
}
|
||||
p := SubagentProfile{
|
||||
AgentID: agentID,
|
||||
Name: stringArg(args, "name"),
|
||||
NotifyMainPolicy: stringArg(args, "notify_main_policy"),
|
||||
Role: stringArg(args, "role"),
|
||||
SystemPromptFile: stringArg(args, "system_prompt_file"),
|
||||
MemoryNamespace: stringArg(args, "memory_namespace"),
|
||||
Status: stringArg(args, "status"),
|
||||
ToolAllowlist: parseStringList(args["tool_allowlist"]),
|
||||
MaxRetries: profileIntArg(args, "max_retries"),
|
||||
RetryBackoff: profileIntArg(args, "retry_backoff_ms"),
|
||||
TimeoutSec: profileIntArg(args, "timeout_sec"),
|
||||
MaxTaskChars: profileIntArg(args, "max_task_chars"),
|
||||
MaxResultChars: profileIntArg(args, "max_result_chars"),
|
||||
}
|
||||
saved, err := t.store.Upsert(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Created subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil
|
||||
case "update":
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
existing, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
next := *existing
|
||||
if _, ok := args["name"]; ok {
|
||||
next.Name = stringArg(args, "name")
|
||||
}
|
||||
if _, ok := args["role"]; ok {
|
||||
next.Role = stringArg(args, "role")
|
||||
}
|
||||
if _, ok := args["notify_main_policy"]; ok {
|
||||
next.NotifyMainPolicy = stringArg(args, "notify_main_policy")
|
||||
}
|
||||
if _, ok := args["system_prompt_file"]; ok {
|
||||
next.SystemPromptFile = stringArg(args, "system_prompt_file")
|
||||
}
|
||||
if _, ok := args["memory_namespace"]; ok {
|
||||
next.MemoryNamespace = stringArg(args, "memory_namespace")
|
||||
}
|
||||
if _, ok := args["status"]; ok {
|
||||
next.Status = stringArg(args, "status")
|
||||
}
|
||||
if _, ok := args["tool_allowlist"]; ok {
|
||||
next.ToolAllowlist = parseStringList(args["tool_allowlist"])
|
||||
}
|
||||
if _, ok := args["max_retries"]; ok {
|
||||
next.MaxRetries = profileIntArg(args, "max_retries")
|
||||
}
|
||||
if _, ok := args["retry_backoff_ms"]; ok {
|
||||
next.RetryBackoff = profileIntArg(args, "retry_backoff_ms")
|
||||
}
|
||||
if _, ok := args["timeout_sec"]; ok {
|
||||
next.TimeoutSec = profileIntArg(args, "timeout_sec")
|
||||
}
|
||||
if _, ok := args["max_task_chars"]; ok {
|
||||
next.MaxTaskChars = profileIntArg(args, "max_task_chars")
|
||||
}
|
||||
if _, ok := args["max_result_chars"]; ok {
|
||||
next.MaxResultChars = profileIntArg(args, "max_result_chars")
|
||||
}
|
||||
saved, err := t.store.Upsert(next)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Updated subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil
|
||||
case "enable", "disable":
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
existing, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
if action == "enable" {
|
||||
type subagentProfileActionHandler func() (string, error)
|
||||
handlers := map[string]subagentProfileActionHandler{
|
||||
"list": func() (string, error) {
|
||||
items, err := t.store.List()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "No subagent profiles.", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagent Profiles:\n")
|
||||
for i, p := range items {
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] role=%s memory_ns=%s\n", i+1, p.AgentID, p.Status, p.Role, p.MemoryNamespace))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
},
|
||||
"get": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
p, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
b, _ := json.MarshalIndent(p, "", " ")
|
||||
return string(b), nil
|
||||
},
|
||||
"create": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
if _, ok, err := t.store.Get(agentID); err != nil {
|
||||
return "", err
|
||||
} else if ok {
|
||||
return "subagent profile already exists", nil
|
||||
}
|
||||
p := SubagentProfile{
|
||||
AgentID: agentID,
|
||||
Name: stringArg(args, "name"),
|
||||
NotifyMainPolicy: stringArg(args, "notify_main_policy"),
|
||||
Role: stringArg(args, "role"),
|
||||
SystemPromptFile: stringArg(args, "system_prompt_file"),
|
||||
MemoryNamespace: stringArg(args, "memory_namespace"),
|
||||
Status: stringArg(args, "status"),
|
||||
ToolAllowlist: parseStringList(args["tool_allowlist"]),
|
||||
MaxRetries: profileIntArg(args, "max_retries"),
|
||||
RetryBackoff: profileIntArg(args, "retry_backoff_ms"),
|
||||
TimeoutSec: profileIntArg(args, "timeout_sec"),
|
||||
MaxTaskChars: profileIntArg(args, "max_task_chars"),
|
||||
MaxResultChars: profileIntArg(args, "max_result_chars"),
|
||||
}
|
||||
saved, err := t.store.Upsert(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Created subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil
|
||||
},
|
||||
"update": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
existing, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
next := *existing
|
||||
if _, ok := args["name"]; ok {
|
||||
next.Name = stringArg(args, "name")
|
||||
}
|
||||
if _, ok := args["role"]; ok {
|
||||
next.Role = stringArg(args, "role")
|
||||
}
|
||||
if _, ok := args["notify_main_policy"]; ok {
|
||||
next.NotifyMainPolicy = stringArg(args, "notify_main_policy")
|
||||
}
|
||||
if _, ok := args["system_prompt_file"]; ok {
|
||||
next.SystemPromptFile = stringArg(args, "system_prompt_file")
|
||||
}
|
||||
if _, ok := args["memory_namespace"]; ok {
|
||||
next.MemoryNamespace = stringArg(args, "memory_namespace")
|
||||
}
|
||||
if _, ok := args["status"]; ok {
|
||||
next.Status = stringArg(args, "status")
|
||||
}
|
||||
if _, ok := args["tool_allowlist"]; ok {
|
||||
next.ToolAllowlist = parseStringList(args["tool_allowlist"])
|
||||
}
|
||||
if _, ok := args["max_retries"]; ok {
|
||||
next.MaxRetries = profileIntArg(args, "max_retries")
|
||||
}
|
||||
if _, ok := args["retry_backoff_ms"]; ok {
|
||||
next.RetryBackoff = profileIntArg(args, "retry_backoff_ms")
|
||||
}
|
||||
if _, ok := args["timeout_sec"]; ok {
|
||||
next.TimeoutSec = profileIntArg(args, "timeout_sec")
|
||||
}
|
||||
if _, ok := args["max_task_chars"]; ok {
|
||||
next.MaxTaskChars = profileIntArg(args, "max_task_chars")
|
||||
}
|
||||
if _, ok := args["max_result_chars"]; ok {
|
||||
next.MaxResultChars = profileIntArg(args, "max_result_chars")
|
||||
}
|
||||
saved, err := t.store.Upsert(next)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Updated subagent profile: %s (role=%s status=%s)", saved.AgentID, saved.Role, saved.Status), nil
|
||||
},
|
||||
"enable": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
existing, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
existing.Status = "active"
|
||||
} else {
|
||||
saved, err := t.store.Upsert(*existing)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil
|
||||
},
|
||||
"disable": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
existing, ok, err := t.store.Get(agentID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "subagent profile not found", nil
|
||||
}
|
||||
existing.Status = "disabled"
|
||||
}
|
||||
saved, err := t.store.Upsert(*existing)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil
|
||||
case "delete":
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
if err := t.store.Delete(agentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted subagent profile: %s", agentID), nil
|
||||
default:
|
||||
return "unsupported action", nil
|
||||
saved, err := t.store.Upsert(*existing)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Subagent profile %s set to %s", saved.AgentID, saved.Status), nil
|
||||
},
|
||||
"delete": func() (string, error) {
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
if err := t.store.Delete(agentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted subagent profile: %s", agentID), nil
|
||||
},
|
||||
}
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "unsupported action", nil
|
||||
}
|
||||
|
||||
func stringArg(args map[string]interface{}, key string) string {
|
||||
|
||||
@@ -53,164 +53,202 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{}
|
||||
agentID := MapStringArg(args, "agent_id")
|
||||
limit := MapIntArg(args, "limit", 20)
|
||||
recentMinutes := MapIntArg(args, "recent_minutes", 0)
|
||||
|
||||
switch action {
|
||||
case "list":
|
||||
tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes)
|
||||
if len(tasks) == 0 {
|
||||
return "No subagents.", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents:\n")
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created })
|
||||
for i, task := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d retry=%d timeout=%ds\n",
|
||||
i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
case "info":
|
||||
if strings.EqualFold(strings.TrimSpace(id), "all") {
|
||||
type subagentActionHandler func() (string, error)
|
||||
var threadHandler subagentActionHandler
|
||||
handlers := map[string]subagentActionHandler{
|
||||
"list": func() (string, error) {
|
||||
tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes)
|
||||
if len(tasks) == 0 {
|
||||
return "No subagents.", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents:\n")
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created })
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents Summary:\n")
|
||||
for i, task := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d retry=%d timeout=%ds\n",
|
||||
i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec))
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s session=%s allowlist=%d retry=%d timeout=%ds\n",
|
||||
i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
info := fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s",
|
||||
task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.MemoryNS,
|
||||
task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec, task.MaxTaskChars, task.MaxResultChars,
|
||||
task.Created, task.Updated, len(task.Steering), task.Task, task.Result)
|
||||
if events, err := t.manager.Events(task.ID, 6); err == nil && len(events) > 0 {
|
||||
},
|
||||
"info": func() (string, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(id), "all") {
|
||||
tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes)
|
||||
if len(tasks) == 0 {
|
||||
return "No subagents.", nil
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i].Created > tasks[j].Created })
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Subagents Summary:\n")
|
||||
for i, task := range tasks {
|
||||
sb.WriteString(fmt.Sprintf("- #%d %s [%s] label=%s agent=%s role=%s steering=%d allowlist=%d retry=%d timeout=%ds\n",
|
||||
i+1, task.ID, task.Status, task.Label, task.AgentID, task.Role, len(task.Steering), len(task.ToolAllowlist), task.MaxRetries, task.TimeoutSec))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
info := fmt.Sprintf("ID: %s\nStatus: %s\nLabel: %s\nAgent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nMemory Namespace: %s\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\nMax Task Chars: %d\nMax Result Chars: %d\nCreated: %d\nUpdated: %d\nSteering Count: %d\nTask: %s\nResult:\n%s",
|
||||
task.ID, task.Status, task.Label, task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.MemoryNS,
|
||||
task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec, task.MaxTaskChars, task.MaxResultChars,
|
||||
task.Created, task.Updated, len(task.Steering), task.Task, task.Result)
|
||||
if events, err := t.manager.Events(task.ID, 6); err == nil && len(events) > 0 {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(info)
|
||||
sb.WriteString("\nEvents:\n")
|
||||
for _, evt := range events {
|
||||
sb.WriteString(formatSubagentEventLog(evt) + "\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
return info, nil
|
||||
},
|
||||
"kill": func() (string, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(id), "all") {
|
||||
tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes)
|
||||
if len(tasks) == 0 {
|
||||
return "No subagents.", nil
|
||||
}
|
||||
killed := 0
|
||||
for _, task := range tasks {
|
||||
if t.manager.KillTask(task.ID) {
|
||||
killed++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("subagent kill requested for %d tasks", killed), nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.KillTask(resolvedID) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "subagent kill requested", nil
|
||||
},
|
||||
"steer": func() (string, error) {
|
||||
if message == "" {
|
||||
return "message is required for steer", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.SteerTask(resolvedID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "steering message accepted", nil
|
||||
},
|
||||
"send": func() (string, error) {
|
||||
if message == "" {
|
||||
return "message is required for send", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.SendTaskMessage(resolvedID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "message sent", nil
|
||||
},
|
||||
"reply": func() (string, error) {
|
||||
if message == "" {
|
||||
return "message is required for reply", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.ReplyToTask(resolvedID, messageID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "reply sent", nil
|
||||
},
|
||||
"ack": func() (string, error) {
|
||||
if messageID == "" {
|
||||
return "message_id is required for ack", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.AckTaskMessage(resolvedID, messageID) {
|
||||
return "subagent or message not found", nil
|
||||
}
|
||||
return "message acked", nil
|
||||
},
|
||||
"thread": func() (string, error) {
|
||||
if threadID == "" {
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
threadID = task.ThreadID
|
||||
}
|
||||
if threadID == "" {
|
||||
return "thread_id is required", nil
|
||||
}
|
||||
thread, ok := t.manager.Thread(threadID)
|
||||
if !ok {
|
||||
return "thread not found", nil
|
||||
}
|
||||
msgs, err := t.manager.ThreadMessages(threadID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(info)
|
||||
sb.WriteString("\nEvents:\n")
|
||||
for _, evt := range events {
|
||||
sb.WriteString(formatSubagentEventLog(evt) + "\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
}
|
||||
return info, nil
|
||||
case "kill":
|
||||
if strings.EqualFold(strings.TrimSpace(id), "all") {
|
||||
tasks := t.filterRecent(t.manager.ListTasks(), recentMinutes)
|
||||
if len(tasks) == 0 {
|
||||
return "No subagents.", nil
|
||||
}
|
||||
killed := 0
|
||||
for _, task := range tasks {
|
||||
if t.manager.KillTask(task.ID) {
|
||||
killed++
|
||||
sb.WriteString(fmt.Sprintf("Thread: %s\nOwner: %s\nStatus: %s\nParticipants: %s\nTopic: %s\n",
|
||||
thread.ThreadID, thread.Owner, thread.Status, strings.Join(thread.Participants, ","), thread.Topic))
|
||||
if len(msgs) > 0 {
|
||||
sb.WriteString("Messages:\n")
|
||||
for _, msg := range msgs {
|
||||
sb.WriteString(fmt.Sprintf("- %s %s -> %s type=%s reply_to=%s status=%s\n %s\n",
|
||||
msg.MessageID, msg.FromAgent, msg.ToAgent, msg.Type, msg.ReplyTo, msg.Status, msg.Content))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("subagent kill requested for %d tasks", killed), nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.KillTask(resolvedID) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "subagent kill requested", nil
|
||||
case "steer":
|
||||
if message == "" {
|
||||
return "message is required for steer", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.SteerTask(resolvedID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "steering message accepted", nil
|
||||
case "send":
|
||||
if message == "" {
|
||||
return "message is required for send", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.SendTaskMessage(resolvedID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "message sent", nil
|
||||
case "reply":
|
||||
if message == "" {
|
||||
return "message is required for reply", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.ReplyToTask(resolvedID, messageID, message) {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
return "reply sent", nil
|
||||
case "ack":
|
||||
if messageID == "" {
|
||||
return "message_id is required for ack", nil
|
||||
}
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
if !t.manager.AckTaskMessage(resolvedID, messageID) {
|
||||
return "subagent or message not found", nil
|
||||
}
|
||||
return "message acked", nil
|
||||
case "thread", "trace":
|
||||
if threadID == "" {
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
},
|
||||
"inbox": func() (string, error) {
|
||||
if agentID == "" {
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
agentID = task.AgentID
|
||||
}
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
msgs, err := t.manager.Inbox(agentID, limit)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
return "", err
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
if len(msgs) == 0 {
|
||||
return "No inbox messages.", nil
|
||||
}
|
||||
threadID = task.ThreadID
|
||||
}
|
||||
if threadID == "" {
|
||||
return "thread_id is required", nil
|
||||
}
|
||||
thread, ok := t.manager.Thread(threadID)
|
||||
if !ok {
|
||||
return "thread not found", nil
|
||||
}
|
||||
msgs, err := t.manager.ThreadMessages(threadID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Thread: %s\nOwner: %s\nStatus: %s\nParticipants: %s\nTopic: %s\n",
|
||||
thread.ThreadID, thread.Owner, thread.Status, strings.Join(thread.Participants, ","), thread.Topic))
|
||||
if len(msgs) > 0 {
|
||||
sb.WriteString("Messages:\n")
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Inbox for %s:\n", agentID))
|
||||
for _, msg := range msgs {
|
||||
sb.WriteString(fmt.Sprintf("- %s %s -> %s type=%s reply_to=%s status=%s\n %s\n",
|
||||
msg.MessageID, msg.FromAgent, msg.ToAgent, msg.Type, msg.ReplyTo, msg.Status, msg.Content))
|
||||
sb.WriteString(fmt.Sprintf("- %s thread=%s from=%s type=%s status=%s\n %s\n",
|
||||
msg.MessageID, msg.ThreadID, msg.FromAgent, msg.Type, msg.Status, msg.Content))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
case "inbox":
|
||||
if agentID == "" {
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
},
|
||||
"log": func() (string, error) {
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
@@ -219,72 +257,50 @@ func (t *SubagentsTool) Execute(ctx context.Context, args map[string]interface{}
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
agentID = task.AgentID
|
||||
}
|
||||
if agentID == "" {
|
||||
return "agent_id is required", nil
|
||||
}
|
||||
msgs, err := t.manager.Inbox(agentID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return "No inbox messages.", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Inbox for %s:\n", agentID))
|
||||
for _, msg := range msgs {
|
||||
sb.WriteString(fmt.Sprintf("- %s thread=%s from=%s type=%s status=%s\n %s\n",
|
||||
msg.MessageID, msg.ThreadID, msg.FromAgent, msg.Type, msg.Status, msg.Content))
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
case "log":
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
task, ok := t.manager.GetTask(resolvedID)
|
||||
if !ok {
|
||||
return "subagent not found", nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Subagent %s Log\n", task.ID))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", task.Status))
|
||||
sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n",
|
||||
task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec))
|
||||
if len(task.Steering) > 0 {
|
||||
sb.WriteString("Steering Messages:\n")
|
||||
for _, m := range task.Steering {
|
||||
sb.WriteString("- " + m + "\n")
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Subagent %s Log\n", task.ID))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s\n", task.Status))
|
||||
sb.WriteString(fmt.Sprintf("Agent ID: %s\nRole: %s\nSession Key: %s\nThread ID: %s\nCorrelation ID: %s\nWaiting Reply: %t\nTool Allowlist: %v\nMax Retries: %d\nRetry Count: %d\nRetry Backoff(ms): %d\nTimeout(s): %d\n",
|
||||
task.AgentID, task.Role, task.SessionKey, task.ThreadID, task.CorrelationID, task.WaitingReply, task.ToolAllowlist, task.MaxRetries, task.RetryCount, task.RetryBackoff, task.TimeoutSec))
|
||||
if len(task.Steering) > 0 {
|
||||
sb.WriteString("Steering Messages:\n")
|
||||
for _, m := range task.Steering {
|
||||
sb.WriteString("- " + m + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if events, err := t.manager.Events(task.ID, 20); err == nil && len(events) > 0 {
|
||||
sb.WriteString("Events:\n")
|
||||
for _, evt := range events {
|
||||
sb.WriteString(formatSubagentEventLog(evt) + "\n")
|
||||
if events, err := t.manager.Events(task.ID, 20); err == nil && len(events) > 0 {
|
||||
sb.WriteString("Events:\n")
|
||||
for _, evt := range events {
|
||||
sb.WriteString(formatSubagentEventLog(evt) + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(task.Result) != "" {
|
||||
result := strings.TrimSpace(task.Result)
|
||||
if len(result) > 500 {
|
||||
result = result[:500] + "..."
|
||||
if strings.TrimSpace(task.Result) != "" {
|
||||
result := strings.TrimSpace(task.Result)
|
||||
if len(result) > 500 {
|
||||
result = result[:500] + "..."
|
||||
}
|
||||
sb.WriteString("Result Preview:\n" + result)
|
||||
}
|
||||
sb.WriteString("Result Preview:\n" + result)
|
||||
}
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
case "resume":
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
label, ok := t.manager.ResumeTask(ctx, resolvedID)
|
||||
if !ok {
|
||||
return "subagent resume failed", nil
|
||||
}
|
||||
return fmt.Sprintf("subagent resumed as %s", label), nil
|
||||
default:
|
||||
return "unsupported action", nil
|
||||
return strings.TrimSpace(sb.String()), nil
|
||||
},
|
||||
"resume": func() (string, error) {
|
||||
resolvedID, err := t.resolveTaskID(id)
|
||||
if err != nil {
|
||||
return err.Error(), nil
|
||||
}
|
||||
label, ok := t.manager.ResumeTask(ctx, resolvedID)
|
||||
if !ok {
|
||||
return "subagent resume failed", nil
|
||||
}
|
||||
return fmt.Sprintf("subagent resumed as %s", label), nil
|
||||
},
|
||||
}
|
||||
threadHandler = handlers["thread"]
|
||||
handlers["trace"] = func() (string, error) { return threadHandler() }
|
||||
if handler := handlers[action]; handler != nil {
|
||||
return handler()
|
||||
}
|
||||
return "unsupported action", nil
|
||||
}
|
||||
|
||||
func (t *SubagentsTool) resolveTaskID(idOrIndex string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user