mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
265 lines
7.5 KiB
Go
265 lines
7.5 KiB
Go
package telegram
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/mymmrac/telego"
|
|
tu "github.com/mymmrac/telego/telegoutil"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const maxSubagentsInList = 30
|
|
|
|
// subagentStatusIcon returns an icon for each subagent task status.
|
|
func subagentStatusIcon(status string) string {
|
|
switch status {
|
|
case "queued":
|
|
return "⏳"
|
|
case "waiting_child":
|
|
return "↪️"
|
|
case "completed":
|
|
return "✅"
|
|
case "failed":
|
|
return "❌"
|
|
case "cancelled":
|
|
return "⏹"
|
|
default: // running
|
|
return "🔄"
|
|
}
|
|
}
|
|
|
|
// formatTokenCount formats token counts as "1.2k" for readability.
|
|
func formatTokenCount(n int64) string {
|
|
if n >= 1000 {
|
|
return fmt.Sprintf("%.1fk", float64(n)/1000)
|
|
}
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
|
|
// handleSubagentsList handles /subagents — lists subagent tasks from DB.
|
|
func (c *Channel) handleSubagentsList(ctx context.Context, chatID int64, isGroup bool, setThread func(*telego.SendMessageParams)) {
|
|
chatIDObj := tu.ID(chatID)
|
|
|
|
send := func(text string) {
|
|
msg := tu.Message(chatIDObj, text)
|
|
setThread(msg)
|
|
c.bot.SendMessage(ctx, msg)
|
|
}
|
|
|
|
if c.subagentTaskStore == nil {
|
|
send("Subagent task tracking is not available.")
|
|
return
|
|
}
|
|
|
|
rootAgentID, err := c.resolveAgentUUID(ctx)
|
|
if err != nil {
|
|
slog.Warn("subagents command: resolve agent UUID failed", "error", err)
|
|
send("Subagent tasks are not available (agent could not be resolved).")
|
|
return
|
|
}
|
|
|
|
tasks, err := c.subagentTaskStore.ListByParent(ctx, rootAgentID, "")
|
|
if err != nil {
|
|
slog.Warn("subagents command: ListByParent failed", "error", err)
|
|
send("Failed to list subagent tasks. Please try again.")
|
|
return
|
|
}
|
|
tasks = filterSelfCloneTasks(tasks)
|
|
|
|
if len(tasks) == 0 {
|
|
send("No subagent tasks found.")
|
|
return
|
|
}
|
|
|
|
total := len(tasks)
|
|
if total > maxSubagentsInList {
|
|
tasks = tasks[:maxSubagentsInList]
|
|
}
|
|
|
|
var sb strings.Builder
|
|
if total > maxSubagentsInList {
|
|
sb.WriteString(fmt.Sprintf("Subagent tasks (showing %d of %d):\n\n", maxSubagentsInList, total))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("Subagent tasks (%d):\n\n", total))
|
|
}
|
|
|
|
for i, t := range tasks {
|
|
model := ""
|
|
if t.Model != nil && *t.Model != "" {
|
|
model = *t.Model
|
|
}
|
|
tokens := fmt.Sprintf("%s/%s tokens", formatTokenCount(t.InputTokens), formatTokenCount(t.OutputTokens))
|
|
if model != "" {
|
|
sb.WriteString(fmt.Sprintf("%d. %s %s (%s, %s)\n", i+1, subagentStatusIcon(t.Status), truncateStr(t.Subject, 40), model, tokens))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("%d. %s %s (%s)\n", i+1, subagentStatusIcon(t.Status), truncateStr(t.Subject, 40), tokens))
|
|
}
|
|
}
|
|
sb.WriteString("\nTap a button below to view details.")
|
|
|
|
var rows [][]telego.InlineKeyboardButton
|
|
for i, t := range tasks {
|
|
label := fmt.Sprintf("%d. %s %s", i+1, subagentStatusIcon(t.Status), truncateStr(t.Subject, 35))
|
|
rows = append(rows, []telego.InlineKeyboardButton{
|
|
{Text: label, CallbackData: "sa:" + t.ID.String()},
|
|
})
|
|
}
|
|
|
|
msg := tu.Message(chatIDObj, sb.String())
|
|
setThread(msg)
|
|
if len(rows) > 0 {
|
|
msg.ReplyMarkup = &telego.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
}
|
|
c.bot.SendMessage(ctx, msg)
|
|
}
|
|
|
|
// handleSubagentDetail handles /subagent <id> — shows detail for a subagent task.
|
|
func (c *Channel) handleSubagentDetail(ctx context.Context, chatID int64, text string, isGroup bool, setThread func(*telego.SendMessageParams)) {
|
|
chatIDObj := tu.ID(chatID)
|
|
|
|
send := func(t string) {
|
|
for _, chunk := range chunkPlainText(t, telegramMaxMessageLen) {
|
|
msg := tu.Message(chatIDObj, chunk)
|
|
setThread(msg)
|
|
c.bot.SendMessage(ctx, msg)
|
|
}
|
|
}
|
|
|
|
parts := strings.SplitN(text, " ", 2)
|
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
|
send("Usage: /subagent <task_id>")
|
|
return
|
|
}
|
|
idArg := strings.TrimSpace(parts[1])
|
|
|
|
if c.subagentTaskStore == nil {
|
|
send("Subagent task tracking is not available.")
|
|
return
|
|
}
|
|
|
|
taskID, err := uuid.Parse(idArg)
|
|
if err != nil {
|
|
send(fmt.Sprintf("Invalid task ID %q. Use /subagents to list tasks.", idArg))
|
|
return
|
|
}
|
|
|
|
rootAgentID, err := c.resolveAgentUUID(ctx)
|
|
if err != nil {
|
|
slog.Warn("subagent command: resolve agent UUID failed", "error", err)
|
|
send("Subagent tasks are not available (agent could not be resolved).")
|
|
return
|
|
}
|
|
task, err := c.subagentTaskStore.Get(ctx, rootAgentID, taskID)
|
|
if err != nil {
|
|
slog.Warn("subagent command: Get failed", "id", idArg, "error", err)
|
|
send("Failed to load subagent task. Please try again.")
|
|
return
|
|
}
|
|
if task == nil {
|
|
send(fmt.Sprintf("Task %q not found. Use /subagents to see available tasks.", idArg[:8]))
|
|
return
|
|
}
|
|
if isDelegationCompletion(task) {
|
|
send(fmt.Sprintf("Task %q is a delegation result; retrieve it with the delegate tool.", idArg[:8]))
|
|
return
|
|
}
|
|
|
|
send(formatSubagentDetail(task))
|
|
}
|
|
|
|
// handleSubagentCallback handles "sa:" callback prefix from inline keyboard buttons.
|
|
func (c *Channel) handleSubagentCallback(ctx context.Context, query *telego.CallbackQuery) {
|
|
taskIDStr := strings.TrimPrefix(query.Data, "sa:")
|
|
|
|
chat := query.Message.GetChat()
|
|
chatIDObj := tu.ID(chat.ID)
|
|
|
|
send := func(text string) {
|
|
for _, chunk := range chunkPlainText(text, telegramMaxMessageLen) {
|
|
msg := tu.Message(chatIDObj, chunk)
|
|
c.bot.SendMessage(ctx, msg)
|
|
}
|
|
}
|
|
|
|
if c.subagentTaskStore == nil {
|
|
send("Subagent task tracking is not available.")
|
|
return
|
|
}
|
|
|
|
taskID, err := uuid.Parse(taskIDStr)
|
|
if err != nil {
|
|
send("Invalid task ID.")
|
|
return
|
|
}
|
|
|
|
rootAgentID, err := c.resolveAgentUUID(ctx)
|
|
if err != nil {
|
|
slog.Warn("subagent callback: resolve agent UUID failed", "error", err)
|
|
send("Subagent tasks are not available (agent could not be resolved).")
|
|
return
|
|
}
|
|
task, err := c.subagentTaskStore.Get(ctx, rootAgentID, taskID)
|
|
if err != nil {
|
|
slog.Warn("subagent callback: Get failed", "id", taskIDStr, "error", err)
|
|
send("Failed to load subagent task.")
|
|
return
|
|
}
|
|
if task == nil {
|
|
send(fmt.Sprintf("Task %s not found.", taskIDStr[:8]))
|
|
return
|
|
}
|
|
if isDelegationCompletion(task) {
|
|
send("This task is a delegation result and is not part of /subagents.")
|
|
return
|
|
}
|
|
|
|
send(formatSubagentDetail(task))
|
|
}
|
|
|
|
// formatSubagentDetail formats a single subagent task for display.
|
|
func formatSubagentDetail(t *store.SubagentTaskData) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Subagent: %s\n", t.Subject))
|
|
sb.WriteString(fmt.Sprintf("ID: %s\n", t.ID.String()))
|
|
sb.WriteString(fmt.Sprintf("Status: %s %s\n", subagentStatusIcon(t.Status), t.Status))
|
|
if t.Model != nil && *t.Model != "" {
|
|
sb.WriteString(fmt.Sprintf("Model: %s\n", *t.Model))
|
|
}
|
|
sb.WriteString(fmt.Sprintf("Depth: %d\n", t.Depth))
|
|
sb.WriteString(fmt.Sprintf("Iterations: %d\n", t.Iterations))
|
|
sb.WriteString(fmt.Sprintf("Tokens: %s in / %s out\n", formatTokenCount(t.InputTokens), formatTokenCount(t.OutputTokens)))
|
|
if !t.CreatedAt.IsZero() {
|
|
sb.WriteString(fmt.Sprintf("Created: %s\n", t.CreatedAt.Format("2006-01-02 15:04")))
|
|
}
|
|
if t.Description != "" {
|
|
sb.WriteString(fmt.Sprintf("\nPrompt:\n%s\n", truncateStr(t.Description, 500)))
|
|
}
|
|
if t.Result != nil && *t.Result != "" {
|
|
sb.WriteString(fmt.Sprintf("\nResult:\n%s\n", truncateStr(*t.Result, 1000)))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func filterSelfCloneTasks(tasks []store.SubagentTaskData) []store.SubagentTaskData {
|
|
filtered := tasks[:0]
|
|
for i := range tasks {
|
|
if !isDelegationCompletion(&tasks[i]) {
|
|
filtered = append(filtered, tasks[i])
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func isDelegationCompletion(task *store.SubagentTaskData) bool {
|
|
if task == nil || task.Metadata == nil {
|
|
return false
|
|
}
|
|
kind, _ := task.Metadata["completion_kind"].(string)
|
|
return kind == "delegate"
|
|
}
|