diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 6095d4a2..e462d9da 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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 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 { diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 78b602e1..040d4a2f 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -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) diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index 9068537d..67ac8c8f 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -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" diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 2285018e..5dbd7d3e 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -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)) diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index b299b6aa..110c5f10 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -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). diff --git a/internal/tools/workspace_media.go b/internal/tools/workspace_media.go new file mode 100644 index 00000000..9e7501ab --- /dev/null +++ b/internal/tools/workspace_media.go @@ -0,0 +1,129 @@ +package tools + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" +) + +// mediaDocNameRe extracts name and path attributes from tags. +var mediaDocNameRe = regexp.MustCompile(`]*\bname="([^"]+)"[^>]*\bpath="([^"]+)"`) + +// ExtractMediaNameMap parses message content for 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: + altRe := regexp.MustCompile(`]*\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() +}