fix(teams): auto-copy media files to team workspace on task creation, scope task_number per chat

- Add RunMediaPaths context key to track media files from current run
- Collect persisted media paths in agent loop after enrichment
- Auto-copy media files to {workspace}/attachments/ when leader creates task
- Append attached files hint in dispatch content so members know what to read
- Scope task_number per (team_id, chat_id) instead of global per team
- Fix NULL chat_id comparison with COALESCE
- Use hard link first, copy fallback to save disk space
- Validate filenames and use restrictive file permissions (0640)
This commit is contained in:
viettranx committed 2026-03-18 12:58:09 +07:00
1 parent 4fc072697f
commit c7d0bc19f8
6 files changed
+222 -4

No files matched your search

+24 -2
View File
@@ -243,7 +243,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// buildMessages resolves context files once and also detects BOOTSTRAP.md presence
// (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection.
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter)
messages, hadBootstrap := l.buildMessages(ctx, history, summary, req.Message, req.ExtraSystemPrompt, req.SessionKey, req.Channel, req.ChannelType, req.PeerKind, req.UserID, req.HistoryLimit, req.SkillFilter, req.LightContext)
// 1b. Determine image routing strategy.
// If read_image tool has a dedicated vision provider, images are NOT attached inline
@@ -355,7 +355,29 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
// knows images were received and stored (consistent with audio/video enrichment).
l.enrichImageIDs(messages, mediaRefs)
// 2f. Cross-session task reminder: notify team leads about pending and in-progress tasks.
// 2f. Collect all media file paths for team workspace auto-collect.
// When the leader calls team_tasks(create), these paths are copied to the
// team workspace so members can access attached files.
if len(mediaRefs) > 0 && l.mediaStore != nil {
var mediaPaths []string
for _, ref := range mediaRefs {
if p, err := l.mediaStore.LoadPath(ref.ID); err == nil {
mediaPaths = append(mediaPaths, p)
}
}
if len(mediaPaths) > 0 {
ctx = tools.WithRunMediaPaths(ctx, mediaPaths)
// Extract original filenames from <media:document name="X" path="Y"> tags
// in the last user message (enriched in step 2b above).
if lastMsg := messages[len(messages)-1]; lastMsg.Role == "user" {
if nameMap := tools.ExtractMediaNameMap(lastMsg.Content); len(nameMap) > 0 {
ctx = tools.WithRunMediaNames(ctx, nameMap)
}
}
}
}
// 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks.
// Stale recovery (expired lock → pending) is handled by the background TaskTicker.
if l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID {
+3 -2
View File
@@ -89,10 +89,11 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData)
return fmt.Errorf("lock team: %w", err)
}
// Scope task_number per (team_id, chat_id) so each conversation starts from 1.
var taskNumber int
err = tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = $1`,
task.TeamID,
`SELECT COALESCE(MAX(task_number), 0) + 1 FROM team_tasks WHERE team_id = $1 AND COALESCE(chat_id, '') = $2`,
task.TeamID, task.ChatID,
).Scan(&taskNumber)
if err != nil {
return fmt.Errorf("compute task_number: %w", err)
+29
View File
@@ -368,6 +368,35 @@ func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch {
return v
}
// --- Run media file paths (for team workspace auto-collect) ---
const ctxRunMediaPaths toolContextKey = "tool_run_media_paths"
// WithRunMediaPaths stores the absolute file paths of media files received
// in the current run. Used by team_tasks to auto-copy files to team workspace.
func WithRunMediaPaths(ctx context.Context, paths []string) context.Context {
return context.WithValue(ctx, ctxRunMediaPaths, paths)
}
// RunMediaPathsFromCtx returns media file paths from the current run.
func RunMediaPathsFromCtx(ctx context.Context) []string {
v, _ := ctx.Value(ctxRunMediaPaths).([]string)
return v
}
const ctxRunMediaNames toolContextKey = "tool_run_media_names"
// WithRunMediaNames stores the mapping from media file path to original filename.
func WithRunMediaNames(ctx context.Context, names map[string]string) context.Context {
return context.WithValue(ctx, ctxRunMediaNames, names)
}
// RunMediaNamesFromCtx returns the media path → original filename mapping.
func RunMediaNamesFromCtx(ctx context.Context) map[string]string {
v, _ := ctx.Value(ctxRunMediaNames).(map[string]string)
return v
}
// --- Per-agent sandbox config override ---
const ctxSandboxCfg toolContextKey = "tool_sandbox_config"
+27
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -119,6 +120,32 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil {
taskMeta["team_workspace"] = teamWsDir
}
// Auto-collect media files from current run to team workspace.
// When leader received files from user and creates a task, copy those
// files to the team workspace so members can access them via read_file.
// Also rewrite any media paths in the description to point to the workspace copy,
// since members can't access the original .media/ paths outside their workspace.
if mediaPaths := RunMediaPathsFromCtx(ctx); len(mediaPaths) > 0 {
if wsDir, _ := taskMeta["team_workspace"].(string); wsDir != "" {
nameMap := RunMediaNamesFromCtx(ctx)
if copiedPaths := copyMediaToWorkspace(mediaPaths, wsDir, nameMap); len(copiedPaths) > 0 {
// Store as []any so type assertion works both before and after JSON round-trip.
files := make([]any, len(copiedPaths))
for i, p := range copiedPaths {
files[i] = p
}
taskMeta["attached_files"] = files
// Rewrite media paths in description so members see workspace paths.
for i, src := range mediaPaths {
if i < len(copiedPaths) {
description = strings.ReplaceAll(description, src, copiedPaths[i])
}
}
}
}
}
// Preserve original blocked_by list for blocker-result forwarding when task unblocks.
if len(blockedBy) > 0 {
ids := make([]string, len(blockedBy))
+10
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
@@ -69,6 +70,15 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T
if ws := taskTeamWorkspace(task); ws != "" {
content += fmt.Sprintf("\n\n[Team workspace: %s — use read_file/write_file/list_files to access shared files. All files you write are visible to the team lead and other members.]", ws)
}
// List attached files so member knows what's available to read.
if files, ok := task.Metadata["attached_files"].([]any); ok && len(files) > 0 {
content += "\n\n[Attached files in team workspace — use read_file to access:]"
for _, f := range files {
if path, ok := f.(string); ok {
content += "\n- attachments/" + filepath.Base(path)
}
}
}
// Use task's stored channel/chat as primary source for routing.
// Falls back to ctx values for initial dispatch (task just created, fields match ctx).
+129
View File
@@ -0,0 +1,129 @@
package tools
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)
// mediaDocNameRe extracts name and path attributes from <media:document> tags.
var mediaDocNameRe = regexp.MustCompile(`<media:document\b[^>]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"`)
// ExtractMediaNameMap parses message content for <media:document name="X" path="Y"> tags
// and returns a map from absolute file path to original filename.
func ExtractMediaNameMap(content string) map[string]string {
nameMap := make(map[string]string)
for _, m := range mediaDocNameRe.FindAllStringSubmatch(content, -1) {
if len(m) == 3 {
nameMap[m[2]] = m[1] // path → name
}
}
// Also match path before name: <media:document path="Y" name="X">
altRe := regexp.MustCompile(`<media:document\b[^>]*\bpath="([^"]+)"[^>]*\bname="([^"]+)"`)
for _, m := range altRe.FindAllStringSubmatch(content, -1) {
if len(m) == 3 {
if _, exists := nameMap[m[1]]; !exists {
nameMap[m[1]] = m[2] // path → name
}
}
}
return nameMap
}
// copyMediaToWorkspace copies media files to the team workspace attachments dir.
// Uses original filenames from nameMap when available; falls back to UUID filename.
// Appends -1, -2 suffix on collision. Skips files that already exist with same size.
// Returns absolute paths of copied files.
func copyMediaToWorkspace(mediaPaths []string, wsDir string, nameMap map[string]string) []string {
if len(mediaPaths) == 0 || wsDir == "" {
return nil
}
attachDir := filepath.Join(wsDir, "attachments")
if err := os.MkdirAll(attachDir, 0750); err != nil {
slog.Warn("workspace_media: failed to create attachments dir", "dir", attachDir, "error", err)
return nil
}
var copied []string
for _, src := range mediaPaths {
srcInfo, err := os.Stat(src)
if err != nil {
slog.Debug("workspace_media: source file not found", "path", src, "error", err)
continue
}
// Use original filename if available, otherwise fall back to UUID name.
baseName := filepath.Base(src)
if origName, ok := nameMap[src]; ok && origName != "" {
baseName = origName
}
if baseName == "." || baseName == ".." || baseName == "" {
slog.Debug("workspace_media: skipping invalid filename", "path", src)
continue
}
dst := filepath.Join(attachDir, baseName)
// Check if already copied (same name + same size = skip).
if dstInfo, err := os.Stat(dst); err == nil {
if dstInfo.Size() == srcInfo.Size() {
copied = append(copied, dst)
continue
}
// Name collision with different file — add suffix.
dst = deduplicatePath(attachDir, baseName)
}
// Hard link first (same filesystem, no disk usage), fallback to copy.
if err := os.Link(src, dst); err != nil {
if err := copyFile(src, dst); err != nil {
slog.Warn("workspace_media: failed to copy file", "src", src, "dst", dst, "error", err)
continue
}
}
slog.Info("workspace_media: copied to team workspace",
"original", baseName, "src", filepath.Base(src), "dst", dst)
copied = append(copied, dst)
}
return copied
}
// deduplicatePath appends -1, -2, etc. to the filename until it's unique.
func deduplicatePath(dir, name string) string {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 1; i < 100; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext))
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
}
return filepath.Join(dir, name) // fallback
}
// copyFile copies src to dst using io.Copy.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}