mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
2c5ae04c01
- IsSharedWorkspace() reads team.settings.workspace_scope
- Shared: workspace at teams/{teamID}/ (all chats share)
- Isolated (default): workspace at teams/{teamID}/{chatID}/
- Remove _default fallback; isolated mode requires chat_id
- Update loop.go, task creation, task listing, message dispatch,
workspace API (list/read/delete), task board snapshot
- Update UI descriptions to reflect per-conversation scoping
365 lines
11 KiB
Go
365 lines
11 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// TeamMessageTool exposes the team mailbox to agents.
|
|
// Actions: send, broadcast, read.
|
|
type TeamMessageTool struct {
|
|
manager *TeamToolManager
|
|
}
|
|
|
|
func NewTeamMessageTool(manager *TeamToolManager) *TeamMessageTool {
|
|
return &TeamMessageTool{manager: manager}
|
|
}
|
|
|
|
func (t *TeamMessageTool) Name() string { return "team_message" }
|
|
|
|
func (t *TeamMessageTool) Description() string {
|
|
return "Send and receive messages within your team. Actions: send, broadcast, read. See TEAM.md for your teammates."
|
|
}
|
|
|
|
func (t *TeamMessageTool) Parameters() map[string]any {
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"action": map[string]any{
|
|
"type": "string",
|
|
"description": "'send', 'broadcast', or 'read'",
|
|
},
|
|
"to": map[string]any{
|
|
"type": "string",
|
|
"description": "Target agent key (required for action=send)",
|
|
},
|
|
"text": map[string]any{
|
|
"type": "string",
|
|
"description": "Message content (required for action=send and action=broadcast)",
|
|
},
|
|
"media": map[string]any{
|
|
"type": "array",
|
|
"description": "Optional file paths to attach as media (for action=send)",
|
|
"items": map[string]any{
|
|
"type": "string",
|
|
},
|
|
},
|
|
},
|
|
"required": []string{"action"},
|
|
}
|
|
}
|
|
|
|
func (t *TeamMessageTool) Execute(ctx context.Context, args map[string]any) *Result {
|
|
action, _ := args["action"].(string)
|
|
|
|
switch action {
|
|
case "send":
|
|
return t.executeSend(ctx, args)
|
|
case "broadcast":
|
|
return t.executeBroadcast(ctx, args)
|
|
case "read":
|
|
return t.executeRead(ctx)
|
|
default:
|
|
return ErrorResult(fmt.Sprintf("unknown action: %s (use send, broadcast, or read)", action))
|
|
}
|
|
}
|
|
|
|
func (t *TeamMessageTool) executeSend(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
toKey, _ := args["to"].(string)
|
|
if toKey == "" {
|
|
return ErrorResult("to parameter is required for send action")
|
|
}
|
|
text, _ := args["text"].(string)
|
|
if text == "" {
|
|
return ErrorResult("text parameter is required for send action")
|
|
}
|
|
|
|
toAgentID, err := t.manager.resolveAgentByKey(toKey)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
// Validate recipient is in the same team (prevent cross-team messaging).
|
|
members, err := t.manager.cachedListMembers(ctx, team.ID, agentID)
|
|
if err != nil {
|
|
return ErrorResult("failed to verify team membership: " + err.Error())
|
|
}
|
|
isMember := false
|
|
for _, m := range members {
|
|
if m.AgentID == toAgentID {
|
|
isMember = true
|
|
break
|
|
}
|
|
}
|
|
if !isMember {
|
|
return ErrorResult(fmt.Sprintf("agent %q is not a member of your team", toKey))
|
|
}
|
|
|
|
// Parse optional media paths
|
|
var mediaFiles []bus.MediaFile
|
|
if rawMedia, ok := args["media"]; ok {
|
|
if mediaArr, ok := rawMedia.([]any); ok {
|
|
for _, item := range mediaArr {
|
|
if path, ok := item.(string); ok && path != "" {
|
|
mediaFiles = append(mediaFiles, bus.MediaFile{Path: path})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist to DB
|
|
msg := &store.TeamMessageData{
|
|
TeamID: team.ID,
|
|
FromAgentID: agentID,
|
|
ToAgentID: &toAgentID,
|
|
Content: text,
|
|
MessageType: store.TeamMessageTypeChat,
|
|
}
|
|
if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil {
|
|
return ErrorResult("failed to send message: " + err.Error())
|
|
}
|
|
|
|
// Auto-create a "message" task so team messages appear in the Tasks tab.
|
|
subject := text
|
|
if len(subject) > 100 {
|
|
subject = subject[:100] + "..."
|
|
}
|
|
now := time.Now()
|
|
lockExpires := now.Add(30 * time.Minute)
|
|
taskData := &store.TeamTaskData{
|
|
TeamID: team.ID,
|
|
Subject: subject,
|
|
Description: text,
|
|
Status: store.TeamTaskStatusInProgress,
|
|
TaskType: "message",
|
|
CreatedByAgentID: &agentID,
|
|
OwnerAgentID: &toAgentID,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
LockedAt: &now,
|
|
LockExpiresAt: &lockExpires,
|
|
}
|
|
var teamTaskID uuid.UUID
|
|
slog.Info("team_message: creating auto-task",
|
|
"team_id", team.ID,
|
|
"from_agent", agentID,
|
|
"to_agent", toAgentID,
|
|
"status", taskData.Status,
|
|
"task_type", taskData.TaskType,
|
|
"user_id", taskData.UserID,
|
|
"channel", taskData.Channel,
|
|
"chat_id", taskData.ChatID,
|
|
)
|
|
if err := t.manager.teamStore.CreateTask(ctx, taskData); err != nil {
|
|
slog.Warn("team_message: failed to auto-create task",
|
|
"error", err,
|
|
"team_id", team.ID,
|
|
"from_agent", agentID,
|
|
"to_agent", toAgentID,
|
|
)
|
|
} else {
|
|
teamTaskID = taskData.ID
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{
|
|
TeamID: team.ID.String(),
|
|
TaskID: teamTaskID.String(),
|
|
Subject: subject,
|
|
Status: store.TeamTaskStatusInProgress,
|
|
OwnerAgentKey: toKey,
|
|
OwnerDisplayName: t.manager.agentDisplayName(ctx, toKey),
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
ActorType: "agent",
|
|
ActorID: t.manager.agentKeyFromID(ctx, agentID),
|
|
})
|
|
}
|
|
|
|
// Real-time delivery via message bus
|
|
fromKey := t.manager.agentKeyFromID(ctx, agentID)
|
|
t.publishTeammateMessage(fromKey, toKey, text, mediaFiles, teamTaskID, team.ID, team.Settings, ctx)
|
|
|
|
preview := text
|
|
if len(preview) > 100 {
|
|
preview = preview[:100] + "..."
|
|
}
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamMessageSent, protocol.TeamMessageEventPayload{
|
|
TeamID: team.ID.String(),
|
|
FromAgentKey: fromKey,
|
|
FromDisplayName: t.manager.agentDisplayName(ctx, fromKey),
|
|
ToAgentKey: toKey,
|
|
ToDisplayName: t.manager.agentDisplayName(ctx, toKey),
|
|
MessageType: string(store.TeamMessageTypeChat),
|
|
Preview: preview,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Message sent to %s.", toKey))
|
|
}
|
|
|
|
func (t *TeamMessageTool) executeBroadcast(ctx context.Context, args map[string]any) *Result {
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
if err := t.manager.requireLead(ctx, team, agentID); err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
text, _ := args["text"].(string)
|
|
if text == "" {
|
|
return ErrorResult("text parameter is required for broadcast action")
|
|
}
|
|
|
|
// Persist to DB (to_agent_id = NULL means broadcast)
|
|
msg := &store.TeamMessageData{
|
|
TeamID: team.ID,
|
|
FromAgentID: agentID,
|
|
ToAgentID: nil,
|
|
Content: text,
|
|
MessageType: store.TeamMessageTypeBroadcast,
|
|
}
|
|
if err := t.manager.teamStore.SendMessage(ctx, msg); err != nil {
|
|
return ErrorResult("failed to broadcast message: " + err.Error())
|
|
}
|
|
|
|
// Real-time delivery to all teammates via message bus
|
|
fromKey := t.manager.agentKeyFromID(ctx, agentID)
|
|
members, err := t.manager.cachedListMembers(ctx, team.ID, agentID)
|
|
if err == nil {
|
|
for _, m := range members {
|
|
if m.AgentID == agentID {
|
|
continue // don't send to self
|
|
}
|
|
t.publishTeammateMessage(fromKey, m.AgentKey, text, nil, uuid.Nil, team.ID, team.Settings, ctx)
|
|
}
|
|
}
|
|
|
|
preview := text
|
|
if len(preview) > 100 {
|
|
preview = preview[:100] + "..."
|
|
}
|
|
t.manager.broadcastTeamEvent(protocol.EventTeamMessageSent, protocol.TeamMessageEventPayload{
|
|
TeamID: team.ID.String(),
|
|
FromAgentKey: fromKey,
|
|
FromDisplayName: t.manager.agentDisplayName(ctx, fromKey),
|
|
ToAgentKey: "broadcast",
|
|
MessageType: string(store.TeamMessageTypeBroadcast),
|
|
Preview: preview,
|
|
UserID: store.UserIDFromContext(ctx),
|
|
Channel: ToolChannelFromCtx(ctx),
|
|
ChatID: ToolChatIDFromCtx(ctx),
|
|
})
|
|
|
|
return NewResult(fmt.Sprintf("Broadcast sent to all teammates."))
|
|
}
|
|
|
|
func (t *TeamMessageTool) executeRead(ctx context.Context) *Result {
|
|
team, agentID, err := t.manager.resolveTeam(ctx)
|
|
if err != nil {
|
|
return ErrorResult(err.Error())
|
|
}
|
|
|
|
messages, err := t.manager.teamStore.GetUnread(ctx, team.ID, agentID)
|
|
if err != nil {
|
|
return ErrorResult("failed to get unread messages: " + err.Error())
|
|
}
|
|
|
|
// Mark all as read
|
|
for _, msg := range messages {
|
|
_ = t.manager.teamStore.MarkRead(ctx, msg.ID)
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"messages": messages,
|
|
"count": len(messages),
|
|
}
|
|
if len(messages) >= 50 {
|
|
resp["note"] = "Showing latest 50 unread messages. Read again after processing these to get more."
|
|
resp["has_more"] = true
|
|
}
|
|
out, _ := json.Marshal(resp)
|
|
return SilentResult(string(out))
|
|
}
|
|
|
|
// publishTeammateMessage sends a real-time notification via the message bus.
|
|
// Uses "teammate:{fromKey}" sender prefix so the consumer can route it.
|
|
func (t *TeamMessageTool) publishTeammateMessage(fromKey, toKey, text string, media []bus.MediaFile, teamTaskID uuid.UUID, teamID uuid.UUID, teamSettings json.RawMessage, ctx context.Context) {
|
|
if t.manager.msgBus == nil {
|
|
return
|
|
}
|
|
|
|
userID := store.UserIDFromContext(ctx)
|
|
chatID := ToolChatIDFromCtx(ctx)
|
|
originChannel := ToolChannelFromCtx(ctx)
|
|
originPeerKind := ToolPeerKindFromCtx(ctx)
|
|
|
|
slog.Info("team_message: publishTeammateMessage",
|
|
"from", fromKey, "to", toKey,
|
|
"origin_channel", originChannel,
|
|
"chat_id", chatID,
|
|
"origin_peer_kind", originPeerKind,
|
|
"user_id", userID,
|
|
"team_task_id", teamTaskID,
|
|
)
|
|
|
|
teamMeta := map[string]string{
|
|
"origin_channel": originChannel,
|
|
"origin_peer_kind": originPeerKind,
|
|
"origin_chat_id": chatID,
|
|
"origin_user_id": userID,
|
|
"from_agent": fromKey,
|
|
"to_agent": toKey,
|
|
"team_id": teamID.String(),
|
|
}
|
|
if localKey := ToolLocalKeyFromCtx(ctx); localKey != "" {
|
|
teamMeta["origin_local_key"] = localKey
|
|
}
|
|
if teamTaskID != uuid.Nil {
|
|
teamMeta["team_task_id"] = teamTaskID.String()
|
|
}
|
|
// Pass team workspace so the receiving agent can access shared files.
|
|
wsChat := chatID
|
|
if IsSharedWorkspace(teamSettings) {
|
|
wsChat = ""
|
|
}
|
|
if ws, err := WorkspaceDir(t.manager.dataDir, teamID, wsChat); err == nil {
|
|
teamMeta["team_workspace"] = ws
|
|
}
|
|
// Propagate trace context so the receiving agent's trace links back.
|
|
if traceID := tracing.TraceIDFromContext(ctx); traceID != uuid.Nil {
|
|
teamMeta["origin_trace_id"] = traceID.String()
|
|
}
|
|
if rootSpanID := tracing.ParentSpanIDFromContext(ctx); rootSpanID != uuid.Nil {
|
|
teamMeta["origin_root_span_id"] = rootSpanID.String()
|
|
}
|
|
t.manager.msgBus.PublishInbound(bus.InboundMessage{
|
|
Channel: "system",
|
|
SenderID: fmt.Sprintf("teammate:%s", fromKey),
|
|
ChatID: chatID,
|
|
Content: fmt.Sprintf("[Team message from %s]: %s", fromKey, text),
|
|
Media: media,
|
|
UserID: userID,
|
|
AgentID: toKey,
|
|
Metadata: teamMeta,
|
|
})
|
|
}
|