Skip to content

Commit 37d28c6

Browse files
committed
fix(daemon): seed Claude image attachments
1 parent 2f1f90c commit 37d28c6

11 files changed

Lines changed: 453 additions & 74 deletions

File tree

server/internal/daemon/client.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ type (
266266
func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) (*HeartbeatResponse, error) {
267267
var resp HeartbeatResponse
268268
if err := c.postJSON(ctx, "/api/daemon/heartbeat", map[string]any{
269-
"runtime_id": runtimeID,
270-
"supports_batch_import": true,
269+
"runtime_id": runtimeID,
270+
"supports_batch_import": true,
271271
}, &resp); err != nil {
272272
return nil, err
273273
}
@@ -414,6 +414,60 @@ func (c *Client) GetWorkspaceRepos(ctx context.Context, workspaceID string) (*Wo
414414
return &resp, nil
415415
}
416416

417+
type AttachmentResponse struct {
418+
ID string `json:"id"`
419+
DownloadURL string `json:"download_url"`
420+
Filename string `json:"filename"`
421+
ContentType string `json:"content_type"`
422+
SizeBytes int64 `json:"size_bytes"`
423+
}
424+
425+
func (c *Client) DownloadAttachment(ctx context.Context, attachmentID string) (*AttachmentResponse, []byte, error) {
426+
var att AttachmentResponse
427+
if err := c.getJSON(ctx, fmt.Sprintf("/api/attachments/%s", attachmentID), &att); err != nil {
428+
return nil, nil, err
429+
}
430+
if att.DownloadURL == "" {
431+
return nil, nil, fmt.Errorf("attachment %s has no download_url", attachmentID)
432+
}
433+
data, err := c.downloadFile(ctx, att.DownloadURL)
434+
if err != nil {
435+
return nil, nil, err
436+
}
437+
return &att, data, nil
438+
}
439+
440+
func (c *Client) downloadFile(ctx context.Context, downloadURL string) ([]byte, error) {
441+
isRelative := !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://")
442+
if isRelative {
443+
if c.baseURL == "" {
444+
return nil, fmt.Errorf("download URL %q is relative but client has no base URL", downloadURL)
445+
}
446+
downloadURL = c.baseURL + downloadURL
447+
}
448+
449+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
450+
if err != nil {
451+
return nil, err
452+
}
453+
if isRelative && c.token != "" {
454+
req.Header.Set("Authorization", "Bearer "+c.token)
455+
c.setIdentityHeaders(req)
456+
}
457+
458+
resp, err := c.client.Do(req)
459+
if err != nil {
460+
return nil, err
461+
}
462+
defer resp.Body.Close()
463+
464+
if resp.StatusCode >= 400 {
465+
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
466+
return nil, &requestError{Method: http.MethodGet, Path: downloadURL, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))}
467+
}
468+
return io.ReadAll(io.LimitReader(resp.Body, 100*1024*1024+1))
469+
}
470+
417471
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
418472
var body io.Reader
419473
if reqBody != nil {

server/internal/daemon/client_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,74 @@ func TestClient_VersionOmittedWhenUnset(t *testing.T) {
8282
}
8383
}
8484

85+
func TestClient_DownloadAttachmentDownloadsRelativeURLWithAuthHeaders(t *testing.T) {
86+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
switch r.URL.Path {
88+
case "/api/attachments/att-1":
89+
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
90+
t.Errorf("metadata request Authorization = %q, want Bearer tok", got)
91+
}
92+
w.Header().Set("Content-Type", "application/json")
93+
json.NewEncoder(w).Encode(map[string]any{
94+
"id": "att-1",
95+
"download_url": "/download/att-1",
96+
"filename": "shot.png",
97+
"content_type": "image/png",
98+
})
99+
case "/download/att-1":
100+
if got := r.Header.Get("Authorization"); got != "Bearer tok" {
101+
t.Errorf("download request Authorization = %q, want Bearer tok", got)
102+
}
103+
if got := r.Header.Get("X-Client-Platform"); got != "daemon" {
104+
t.Errorf("download request X-Client-Platform = %q, want daemon", got)
105+
}
106+
w.Write([]byte("png-bytes"))
107+
default:
108+
t.Errorf("unexpected path: %s", r.URL.Path)
109+
http.NotFound(w, r)
110+
}
111+
}))
112+
defer srv.Close()
113+
114+
c := NewClient(srv.URL)
115+
c.SetToken("tok")
116+
117+
meta, data, err := c.DownloadAttachment(context.Background(), "att-1")
118+
if err != nil {
119+
t.Fatalf("DownloadAttachment: %v", err)
120+
}
121+
if meta.Filename != "shot.png" || meta.ContentType != "image/png" {
122+
t.Fatalf("unexpected attachment metadata: %+v", meta)
123+
}
124+
if string(data) != "png-bytes" {
125+
t.Fatalf("downloaded data = %q, want png-bytes", string(data))
126+
}
127+
}
128+
129+
func TestClient_DownloadFileAbsoluteURLOmitsAuthHeaders(t *testing.T) {
130+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
if got := r.Header.Get("Authorization"); got != "" {
132+
t.Errorf("absolute download Authorization = %q, want empty", got)
133+
}
134+
if got := r.Header.Get("X-Client-Platform"); got != "" {
135+
t.Errorf("absolute download X-Client-Platform = %q, want empty", got)
136+
}
137+
w.Write([]byte("image-data"))
138+
}))
139+
defer srv.Close()
140+
141+
c := NewClient("https://api.example.test")
142+
c.SetToken("tok")
143+
144+
data, err := c.downloadFile(context.Background(), srv.URL+"/signed")
145+
if err != nil {
146+
t.Fatalf("downloadFile: %v", err)
147+
}
148+
if string(data) != "image-data" {
149+
t.Fatalf("downloaded data = %q, want image-data", string(data))
150+
}
151+
}
152+
85153
func TestNormalizeGOOS(t *testing.T) {
86154
cases := map[string]string{
87155
"darwin": "macos",

server/internal/daemon/daemon.go

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2237,6 +2237,56 @@ func providerNeedsInlineSystemPrompt(provider string) bool {
22372237
}
22382238
}
22392239

2240+
const (
2241+
maxClaudeInitialImages = 4
2242+
maxClaudeInitialImageBytes = 10 * 1024 * 1024
2243+
)
2244+
2245+
func (d *Daemon) claudeInitialImages(ctx context.Context, task Task, taskLog *slog.Logger) []agent.InputImage {
2246+
attachments := make([]AttachmentMeta, 0, len(task.IssueAttachments)+len(task.ChatMessageAttachments))
2247+
attachments = append(attachments, task.IssueAttachments...)
2248+
attachments = append(attachments, task.ChatMessageAttachments...)
2249+
if len(attachments) == 0 {
2250+
return nil
2251+
}
2252+
2253+
images := make([]agent.InputImage, 0, min(len(attachments), maxClaudeInitialImages))
2254+
for _, attachment := range attachments {
2255+
if len(images) >= maxClaudeInitialImages {
2256+
break
2257+
}
2258+
if attachment.ID == "" || !strings.HasPrefix(strings.ToLower(attachment.ContentType), "image/") {
2259+
continue
2260+
}
2261+
meta, data, err := d.client.DownloadAttachment(ctx, attachment.ID)
2262+
if err != nil {
2263+
taskLog.Warn("claude initial image download failed", "attachment_id", attachment.ID, "error", err)
2264+
continue
2265+
}
2266+
if len(data) == 0 || len(data) > maxClaudeInitialImageBytes {
2267+
taskLog.Warn("claude initial image skipped due to size", "attachment_id", attachment.ID, "bytes", len(data))
2268+
continue
2269+
}
2270+
mediaType := attachment.ContentType
2271+
if meta != nil && meta.ContentType != "" {
2272+
mediaType = meta.ContentType
2273+
}
2274+
if !strings.HasPrefix(strings.ToLower(mediaType), "image/") {
2275+
continue
2276+
}
2277+
filename := attachment.Filename
2278+
if meta != nil && meta.Filename != "" {
2279+
filename = meta.Filename
2280+
}
2281+
images = append(images, agent.InputImage{
2282+
Filename: filename,
2283+
MediaType: mediaType,
2284+
Data: data,
2285+
})
2286+
}
2287+
return images
2288+
}
2289+
22402290
func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot int, taskLog *slog.Logger) (TaskResult, error) {
22412291
// Refuse to spawn an agent without a workspace. An empty workspace_id
22422292
// here would make MULTICA_WORKSPACE_ID empty in the agent env, and the
@@ -2275,25 +2325,25 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
22752325
// Repos are passed as metadata only — the agent checks them out on demand
22762326
// via `multica repo checkout <url>`.
22772327
taskCtx := execenv.TaskContextForEnv{
2278-
IssueID: task.IssueID,
2279-
TriggerCommentID: task.TriggerCommentID,
2280-
AgentID: agentID,
2281-
AgentName: agentName,
2282-
AgentInstructions: instructions,
2283-
AgentSkills: convertSkillsForEnv(skills),
2284-
Repos: convertReposForEnv(task.Repos),
2285-
ProjectID: task.ProjectID,
2286-
ProjectTitle: task.ProjectTitle,
2287-
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
2288-
ChatSessionID: task.ChatSessionID,
2289-
AutopilotRunID: task.AutopilotRunID,
2290-
AutopilotID: task.AutopilotID,
2291-
AutopilotTitle: task.AutopilotTitle,
2292-
AutopilotDescription: task.AutopilotDescription,
2293-
AutopilotSource: task.AutopilotSource,
2294-
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
2295-
QuickCreatePrompt: task.QuickCreatePrompt,
2296-
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
2328+
IssueID: task.IssueID,
2329+
TriggerCommentID: task.TriggerCommentID,
2330+
AgentID: agentID,
2331+
AgentName: agentName,
2332+
AgentInstructions: instructions,
2333+
AgentSkills: convertSkillsForEnv(skills),
2334+
Repos: convertReposForEnv(task.Repos),
2335+
ProjectID: task.ProjectID,
2336+
ProjectTitle: task.ProjectTitle,
2337+
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
2338+
ChatSessionID: task.ChatSessionID,
2339+
AutopilotRunID: task.AutopilotRunID,
2340+
AutopilotID: task.AutopilotID,
2341+
AutopilotTitle: task.AutopilotTitle,
2342+
AutopilotDescription: task.AutopilotDescription,
2343+
AutopilotSource: task.AutopilotSource,
2344+
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
2345+
QuickCreatePrompt: task.QuickCreatePrompt,
2346+
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
22972347
RequestingUserName: task.RequestingUserName,
22982348
RequestingUserProfileDescription: task.RequestingUserProfileDescription,
22992349
}
@@ -2522,6 +2572,9 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
25222572
McpConfig: mcpConfig,
25232573
ThinkingLevel: thinkingLevel,
25242574
}
2575+
if provider == "claude" {
2576+
execOpts.InitialImages = d.claudeInitialImages(ctx, task, taskLog)
2577+
}
25252578
// Some providers do not reliably load the per-task runtime config files we
25262579
// write into the task workdir:
25272580
// - openclaw is pinned to the task workdir via the per-task config we

server/internal/daemon/daemon_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,44 @@ func TestWatchTaskCancellation_RunningTaskNotInterrupted(t *testing.T) {
812812
}
813813
}
814814

815+
func TestClaudeInitialImagesDownloadsImageAttachments(t *testing.T) {
816+
t.Parallel()
817+
818+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
819+
switch r.URL.Path {
820+
case "/api/attachments/img-1":
821+
w.Header().Set("Content-Type", "application/json")
822+
json.NewEncoder(w).Encode(map[string]any{
823+
"id": "img-1",
824+
"download_url": "/files/img-1",
825+
"filename": "server-shot.png",
826+
"content_type": "image/png",
827+
})
828+
case "/files/img-1":
829+
w.Write([]byte("image-bytes"))
830+
default:
831+
http.NotFound(w, r)
832+
}
833+
}))
834+
t.Cleanup(srv.Close)
835+
836+
d := &Daemon{client: NewClient(srv.URL), logger: slog.Default()}
837+
images := d.claudeInitialImages(context.Background(), Task{
838+
IssueAttachments: []AttachmentMeta{
839+
{ID: "img-1", Filename: "client-shot.png", ContentType: "image/png"},
840+
{ID: "txt-1", Filename: "notes.txt", ContentType: "text/plain"},
841+
},
842+
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
843+
844+
if len(images) != 1 {
845+
t.Fatalf("initial images length = %d, want 1", len(images))
846+
}
847+
got := images[0]
848+
if got.Filename != "server-shot.png" || got.MediaType != "image/png" || string(got.Data) != "image-bytes" {
849+
t.Fatalf("unexpected initial image: %+v data=%q", got, string(got.Data))
850+
}
851+
}
852+
815853
func TestMergeUsage(t *testing.T) {
816854
t.Parallel()
817855

server/internal/daemon/types.go

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,34 +33,35 @@ type ProjectResourceData struct {
3333
// Task represents a claimed task from the server.
3434
// Agent data (name, skills) is populated by the claim endpoint.
3535
type Task struct {
36-
ID string `json:"id"`
37-
AgentID string `json:"agent_id"`
38-
RuntimeID string `json:"runtime_id"`
39-
IssueID string `json:"issue_id"`
40-
WorkspaceID string `json:"workspace_id"`
41-
Agent *AgentData `json:"agent,omitempty"`
36+
ID string `json:"id"`
37+
AgentID string `json:"agent_id"`
38+
RuntimeID string `json:"runtime_id"`
39+
IssueID string `json:"issue_id"`
40+
WorkspaceID string `json:"workspace_id"`
41+
Agent *AgentData `json:"agent,omitempty"`
4242
Repos []RepoData `json:"repos,omitempty"`
43-
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
44-
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
45-
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
46-
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
47-
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
48-
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
49-
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
50-
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
51-
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
52-
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
53-
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
54-
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
55-
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
56-
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
57-
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
58-
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
59-
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
60-
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
61-
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
62-
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
63-
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
43+
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
44+
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
45+
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
46+
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
47+
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
48+
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
49+
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
50+
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
51+
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
52+
IssueAttachments []AttachmentMeta `json:"issue_attachments,omitempty"` // attachments linked to the issue; Claude embeds image attachments directly when possible
53+
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
54+
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
55+
ChatMessageAttachments []AttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
56+
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
57+
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
58+
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
59+
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
60+
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
61+
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
62+
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
63+
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
64+
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
6465
// RequestingUserName + RequestingUserProfileDescription describe the human
6566
// the agent is working on behalf of. v1 sources them from the runtime
6667
// owner (the user who registered the daemon). Empty when the runtime has
@@ -71,12 +72,11 @@ type Task struct {
7172
RequestingUserProfileDescription string `json:"requesting_user_profile_description,omitempty"`
7273
}
7374

74-
// ChatAttachmentMeta is the structured attachment metadata the daemon
75-
// hands to the agent for chat tasks. We pass id + filename + content_type
76-
// so the chat prompt can list them explicitly and instruct the agent to
77-
// run `multica attachment download <id>` instead of guessing from a
78-
// signed CDN URL (which expires).
79-
type ChatAttachmentMeta struct {
75+
// AttachmentMeta is the structured attachment metadata the daemon receives
76+
// in claim responses. We pass id + filename + content_type so prompts can
77+
// list attachments explicitly, and Claude can embed image files directly in
78+
// the initial user message instead of going through the Read tool_result path.
79+
type AttachmentMeta struct {
8080
ID string `json:"id"`
8181
Filename string `json:"filename"`
8282
ContentType string `json:"content_type,omitempty"`

0 commit comments

Comments
 (0)