Files
multica/server/internal/daemon/prompt.go
Naiyuan Qing a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* feat(chat): support images/files in agent chat replies (MUL-4287)

Agents can now attach images/files to their chat replies, matching how
comment attachments already work. The write-side gap was that the assistant
chat_message is synthesized server-side from the completion callback's text
output and never bound any attachments.

Backend:
- migration 150: nullable attachment.task_id (+ partial index), the transient
  handle that ties an agent's in-run upload to the reply it produces.
- POST /api/upload-file accepts task_id: gated to the task's own agent, in
  this workspace, on a chat task; tags the row with task_id + chat_session_id.
- CompleteTask (chat branch) binds the task's still-unclaimed attachments to
  the assistant message via BindChatAttachmentsToMessage (rejects rows already
  owned by an issue/comment/chat_message). An empty-output reply that produced
  files still creates a message so the images have an owner. FailTask binds
  nothing.

CLI:
- `multica attachment upload <path>` uploads a file for the current chat task
  (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a
  ready-to-paste markdown snippet.

Prompt:
- web/mobile chat prompt tells the agent how to attach a file to its reply.

Mobile:
- chat:done handler now always invalidates the messages list so attachments
  (absent from the event payload) refetch; mirrors web's self-heal.
- chat bubbles render standalone attachment cards via the existing
  CommentAttachmentList (dedup vs inline references), matching web.

Web/desktop needed no change — they already render message.attachments inline
and via AttachmentList, and self-heal on chat:done.

Tests: upload permission/isolation, bind-on-complete, empty-output+attachments,
FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output
contract, mobile refetch-on-done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): address review blockers on chat reply attachments (MUL-4287)

Two final-review blockers on PR #5164:

1. Mobile inline dedup only checked raw `url`, so an attachment referenced
   inline via `markdown_url` (exactly what the CLI snippet emits) rendered
   twice — once inline, once as a standalone card. Reuse the core
   `contentReferencesAttachment` helper so dedup covers every real reference
   form (stable /api/attachments/<id>/download path, url, download_url,
   markdown_url), matching web's AttachmentList. Extracted the filter into a
   pure `lib/attachment-dedup.ts` so it is unit-testable, and added a
   regression test covering `content` containing `attachment.markdown_url`
   (plus the other URL forms and same-identity sibling dedup).

2. CLI `attachment upload` emitted `![...]` image markdown for every file,
   producing a broken-image snippet for non-images. Emit image markdown only
   for image/* content types and a plain link otherwise, with a CLI contract
   test for both.

Approved scope otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287)

Merged latest main; main renumbered its migrations and now occupies 150-156,
so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and
would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the
next unique prefix (157). No content change; migrate up applies cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): render agent-produced files as attachment cards, not raw links

The chat upload command handed the agent a bare `[name](url)` markdown
snippet. Pasted mid-sentence it renders as a plain text link (not a card),
and the referenced URL hides the auto-bound standalone attachment — so a
file the agent produced could end up showing as nothing.

Return the block-level `!file[name](url)` card syntax instead (images keep
`![name](url)` inline), and markdown-escape the filename so names with `[`/`]`
don't truncate the label. The prompt and CLI help now state the file
auto-attaches below the reply and the snippet is optional, only for placement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): soften message-list scroll fade (32px → 16px)

The 32px edge fade washed out full-bleed content (HTML / image previews)
at the list edges. Halve the fade distance so it barely grazes previews
while still hinting at more content above/below.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): renumber attachment_task_id migration 157 -> 158

main landed 157_agent_task_delivered_comments while this branch was open,
colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet.
Bump this PR's migration to the next free prefix (158). Rename only; the
migration body (nullable attachment.task_id + partial index) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): pin attachment upload to the token's task; build index concurrently

Two code-review findings on the chat-attachment path (MUL-4287):

- Isolation/privacy: POST /api/upload-file only checked the form task_id
  belonged to the caller's agent, not that it matched the task-scoped token's
  authoritative X-Task-ID. A run authorized for task A could tag an attachment
  onto task B (another chat task of the same agent, possibly another user's
  session), binding it into that reply on completion. Require the form task_id
  to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression.

- Migration: split the task_id lookup index into its own migration (159) built
  with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a
  multi-command file with the ADD COLUMN in 158.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287)

Addresses the two remaining Preflight BLOCKERs on PR #5164.

Security (file.go): the task_id upload path compared the form task_id to
X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT
leaves that header empty and the middleware does NOT strip a client-forged
X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair.
So a member who learned a task ID could forge both and inject an attachment
onto another chat task's assistant reply (cross-session/privacy leak). Now the
branch requires X-Actor-Source=task_token first (mirrors chat_history.go's
load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests
now go through the real task-token headers and add a forged-JWT-403 regression.

Migration (158): task_id is a transient binding handle (written once at upload
against an already-validated task, read only during that task's own
completion; durable owner is chat_message_id). There is no app-layer path that
hard-deletes agent_task_queue rows, and orphan uploads are already reaped by
attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a
cascade dependency the app never relies on plus write overhead on the hot
attachment table. Drop the FK; task_id is now a plain UUID column. Added a
regression test that an unbound task-tagged upload is reaped on chat_session
delete. Index (159, CONCURRENTLY) unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287)

Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up
with the CLI's file-card output, so agent-produced non-image files rendered
nowhere on mobile.

- `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket
  output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never
  matched — the line stayed literal AND `standaloneAttachments` still hid the
  fallback card (the URL is in `content`), so the file showed nowhere.
- Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label
  allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is
  restricted to the same allowlist (site-relative /uploads + /api/attachments/
  <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text.
- Unescape the label to the real filename, then re-escape only the chars that
  would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed
  by the renderer — unlike web's HTML data-filename), so a raw `]` never
  truncates the link text.

No dedup change: once the inline `!file` renders, hiding the standalone card is
correct. Added focused unit tests covering the escaped-label case, parens/
backslash unescape, the site-relative URL form, and disallowed-scheme rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 09:15:36 +08:00

439 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package daemon
import (
"fmt"
"strings"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
)
// BuildPrompt constructs the task prompt for an agent CLI.
// Keep this minimal — detailed instructions live in CLAUDE.md / AGENTS.md
// injected by execenv.InjectRuntimeConfig. The provider string is threaded
// through to comment-triggered tasks' per-turn reply template; that template
// is provider-agnostic AND host-agnostic now (every OS → write a UTF-8 file,
// post with `--content-file`) because the shell-layer corruption it guards
// against is not specific to any one provider or host (MUL-2904, #4182).
func BuildPrompt(task Task, provider string) string {
if task.ChatSessionID != "" {
return buildChatPrompt(task)
}
if task.TriggerCommentID != "" {
return buildCommentPrompt(task, provider)
}
if task.AutopilotRunID != "" {
return buildAutopilotPrompt(task)
}
if task.QuickCreatePrompt != "" {
return buildQuickCreatePrompt(task)
}
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
// Assignment handoff (MUL-3375): a free-text instruction the person who
// assigned/promoted this issue left for you. Frame it as a handoff, not a
// comment to reply to — there is no comment thread to answer here.
if task.HandoffNote != "" {
b.WriteString("You were handed this issue with a handoff note. Treat it as the assigner's scoping instruction for this run; follow it before doing anything broader, and do not reply to it as if it were a comment:\n\n")
fmt.Fprintf(&b, "> %s\n\n", task.HandoffNote)
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
fmt.Fprintf(&b, "For comment history, follow the rule in your runtime workflow file (assignment-triggered tasks treat the read as mandatory). Start with `multica issue comment list %s --recent 10 --output json` to read the 10 most recently active threads, then page older threads via the stderr `Next thread cursor: ...` line and the matching `--before` / `--before-id` until you have enough history. Resolved threads come back folded — `--full` to expand. `--since <RFC3339>` is still available for incremental polling and may combine with `--recent`.\n", task.IssueID)
return b.String()
}
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
// user typed a single natural-language sentence in the create-issue modal;
// the agent's job is to translate it into one `multica issue create` CLI
// invocation, using its judgment to decide whether fetching referenced URLs
// would produce a better issue. No issue exists yet, so the agent must NOT
// call `multica issue get` or attempt to comment — there's nothing to read
// or reply to.
func buildQuickCreatePrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
b.WriteString("A user captured the following input via the quick-create modal. There is NO existing issue. Your job is to create a well-formed issue from this input with a single `multica issue create` command.\n\n")
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
b.WriteString("Field rules:\n\n")
// title
b.WriteString("- **title**: required. A concise but semantically rich summary. If the input references external resources (PRs, issues, URLs), use your judgment on whether fetching the resource would produce a meaningfully better title — e.g. \"review PR #123\" → \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n\n")
// description — the core optimization
b.WriteString("- **description**: The description is the executing agent's primary context. Aim for high fidelity — they should grasp the user's intent as if they had read the raw input themselves. Use a two-section structure:\n\n")
b.WriteString(" 1. **User request** — Faithfully restate what the user wants in their own words. Preserve specific names, identifiers, file paths, code snippets, and technical terms verbatim. Strip non-spec material before writing it (this is removal, not paraphrasing): verbal routing wrappers about creating the issue or routing it (e.g. \"create an issue\", \"分配给 X\", \"让 @X 处理\") and pure conversational fillers (e.g. \"对吧?\"). When in doubt, keep it.\n\n")
b.WriteString(" CC exception: `multica issue create` has no `--subscriber` flag, and the platform auto-subscribes members whose `[@Name](mention://member/<uuid>)` link appears in the description. When the user wrote \"cc @Y\", strip the verbal \"cc\" wrapper from the User request body and append a final `CC: <mention link(s)>` line to the description so the cc routing still fires.\n\n")
b.WriteString(" 2. **Context** — include ONLY when the input cited external resources AND you successfully fetched them AND they produced verifiable facts worth recording. Summarize facts only (e.g. \"PR #45 changes auth to JWT\"), not interpretation or unsolicited reference implementations. If you have nothing factual to add, omit the section entirely — never use it as an apology log for resources you could not fetch.\n\n")
b.WriteString(" Hard rules: never invent requirements, implementation details, or acceptance criteria the user did not express; never reduce multi-sentence input to a single vague sentence; never echo the title.\n\n")
b.WriteString(" Passing the description: a short, single-line body with no code, quotes, backticks, `$()`, or other special characters may go inline via `--description \"...\"`. Anything multi-line, or containing code snippets / file paths / quotes / backticks / `$()` / special characters, or otherwise long — which quick-create descriptions usually are — MUST be written to `./description.md` and passed with `--description-file ./description.md`; passing rich text inline lets the shell rewrite or truncate it (MUL-2904). That file MUST live inside your current working directory (e.g. `./description.md`) — never `/tmp` or any machine-shared path, where a different run may have left a stale file that would silently become this issue's description. If the file write fails for any reason, stop and fix it; never run `--description-file` against a file whose write did not succeed.\n\n")
// priority
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
// assignee
b.WriteString("- **assignee**:\n")
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace member list --output json`, `multica agent list --output json`, and `multica squad list --output json` and find the matching entity by display name. Squads are first-class assignees too — a squad name (e.g. \"Super Human\") routes work to the squad leader, who then delegates. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent or squad) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
b.WriteString(" - Treat bare @-routing as an assignee directive even when the user did not write the English word \"assign\". This includes Chinese imperatives like `让 @独立团 review 这个 PR`, `给 @X 处理`, or `交给 @X`; strip the leading `@`/`` before matching display names. Do not keep that routing wrapper or `@Name` in the description unless it is a true CC-style notification rather than ownership. If the matched entity is a squad, pass the squad's `id` as `--assignee-id`, not the leader agent's id.\n")
agentID := ""
agentName := ""
if task.Agent != nil {
agentID = task.Agent.ID
agentName = task.Agent.Name
}
switch {
case task.SquadID != "":
// The user opened quick-create with a SQUAD selected. The task
// runs on the squad's leader agent, but the squad is the expected
// owner — assigning to the leader would mask the squad's
// delegation flow. Always point the default at the squad UUID.
if task.SquadName != "" {
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to the picker SQUAD %q: pass `--assignee-id %q` (the squad's UUID). The user opened quick-create with the squad selected; you (the leader agent) are running on the squad's behalf, so the squad — not you — is the expected owner. Never leave the issue unassigned, and do not assign it to your own agent UUID.\n\n", task.SquadName, task.SquadID)
} else {
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to the picker SQUAD: pass `--assignee-id %q` (the squad's UUID). The user opened quick-create with the squad selected; you (the leader agent) are running on the squad's behalf, so the squad — not you — is the expected owner. Never leave the issue unassigned, and do not assign it to your own agent UUID.\n\n", task.SquadID)
}
case agentID != "":
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee-id %q` (your agent UUID). The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned. Use the UUID flag, not `--assignee <name>`, so the assignment is unambiguous even when other agents share part of your name.\n\n", agentID)
case agentName != "":
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n\n", agentName)
default:
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee-id <your agent UUID>` (preferred) or `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
}
// project — pinned by the modal when the user picked one, otherwise
// omitted so the platform routes to the workspace default. Always pass
// the UUID (never a name) so the issue lands in the right project even
// when several share a title.
if task.ProjectID != "" {
if task.ProjectTitle != "" {
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in project %q (the user picked it in the quick-create modal). Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID, task.ProjectTitle)
} else {
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in the project the user picked in the quick-create modal. Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID)
}
} else {
b.WriteString("- **project**: omit. The platform will route the issue to the workspace default.\n")
}
// parent — pinned by the modal when the user opened it from "Add sub
// issue" on an existing issue. Pass the UUID (never the identifier) so
// the create lands the sub-issue under the right parent even when the
// workspace prefix changes; the identifier is included in the prose
// purely as human-readable context for the agent.
if task.ParentIssueID != "" {
if task.ParentIssueIdentifier != "" {
fmt.Fprintf(&b, "- **parent**: required for this run. Pass `--parent %q` so the new issue is filed as a sub-issue of %s (the user opened quick-create from that issue's \"Add sub issue\" entry). Do not infer a different parent from the prompt text — the modal entry point is authoritative.\n", task.ParentIssueID, task.ParentIssueIdentifier)
} else {
fmt.Fprintf(&b, "- **parent**: required for this run. Pass `--parent %q` so the new issue is filed as a sub-issue of the parent the user picked in the quick-create modal. Do not infer a different parent from the prompt text — the modal entry point is authoritative.\n", task.ParentIssueID)
}
}
b.WriteString("- **status**: omit (defaults to `todo`).\n")
b.WriteString("- **attachments**: do NOT pass `--attachment`. The flag only accepts LOCAL file paths. Any image URL in the user input is already markdown — keep it inline in `--description` instead.\n\n")
// output format
b.WriteString("Output format:\n")
b.WriteString("- Run exactly one `multica issue create --output json` invocation. Do not retry for any reason — even on non-zero exit. The issue may already exist; another attempt would create a duplicate.\n")
b.WriteString("- Parse the JSON response to read the created issue's `identifier` (preferred) or `id` (fallback). Do not scrape human output and do not assume any workspace issue prefix such as `MUL-`; workspaces can use custom prefixes.\n")
b.WriteString("- After success, print exactly one line: `Created <identifier-or-id>: <title>` and exit. No commentary, no follow-up tool calls.\n")
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` — there is no issue to query or comment on.\n")
b.WriteString("- On CLI error or JSON parse error, exit with the error as the only output. The platform writes a failure notification automatically.\n")
return b.String()
}
// buildCommentPrompt constructs a prompt for comment-triggered tasks.
// The triggering comment content is embedded directly so the agent cannot
// miss it, even when stale output files exist in a reused workdir.
// The reply instructions (including the current TriggerCommentID as --parent)
// are re-emitted on every turn so resumed sessions cannot carry forward a
// previous turn's --parent UUID.
func buildCommentPrompt(task Task, provider string) string {
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
if task.TriggerCommentContent != "" {
authorLabel := "A user"
if task.TriggerAuthorType == "agent" {
name := task.TriggerAuthorName
if name == "" {
name = "another agent"
}
authorLabel = fmt.Sprintf("Another agent (%s)", name)
}
fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel)
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
// MUL-4195: comments that arrived before this run started were folded
// into it rather than dropped. The trigger above is the newest; the
// agent must ALSO address these earlier ones so no deliberate user
// instruction is silently lost. Prefer the embedded detail so the agent
// does not have to guess which thread each folded comment lives in
// (they may span multiple threads — review should-fix #3); fall back to
// a thread-agnostic issue-wide fetch hint for old servers that only send
// the ids.
if len(task.CoalescedComments) > 0 {
fmt.Fprintf(&b, "This run also covers %d earlier comment(s) posted before it started — you must read and address them too, not just the one above. They may be in different threads, so each is reproduced here with its own thread:\n\n", len(task.CoalescedComments))
for _, cc := range task.CoalescedComments {
authorLabel := "A user"
if cc.AuthorType == "agent" {
name := cc.AuthorName
if name == "" {
name = "another agent"
}
authorLabel = fmt.Sprintf("Another agent (%s)", name)
} else if cc.AuthorName != "" {
authorLabel = cc.AuthorName
}
fmt.Fprintf(&b, "- comment %s", cc.ID)
if cc.CreatedAt != "" {
fmt.Fprintf(&b, " (%s, %s)", authorLabel, cc.CreatedAt)
} else {
fmt.Fprintf(&b, " (%s)", authorLabel)
}
if cc.ThreadID != "" {
fmt.Fprintf(&b, " [thread %s]", cc.ThreadID)
}
b.WriteString(":\n")
fmt.Fprintf(&b, " > %s\n", strings.ReplaceAll(strings.TrimSpace(cc.Content), "\n", "\n > "))
}
fmt.Fprintf(&b, "\nIf you need the surrounding discussion for any of them, fetch its thread with `multica issue comment list %s --thread <thread-id> --tail 30 --output json` using the thread id shown above.\n\n", task.IssueID)
} else if len(task.CoalescedCommentIDs) > 0 {
fmt.Fprintf(&b, "This run also covers %d earlier comment(s) posted before it started — you must read and address them too, not just the one above: %s. These may be in DIFFERENT threads, so do not assume they share the triggering thread; fetch each by pulling the issue-wide discussion with `multica issue comment list %s --recent 30 --output json` (expand with `--full` if a thread is folded) and locate the ids above.\n\n",
len(task.CoalescedCommentIDs), strings.Join(task.CoalescedCommentIDs, ", "), task.IssueID)
}
if task.TriggerAuthorType == "agent" {
b.WriteString("⚠️ The triggering comment was posted by another agent. Decide whether a reply is warranted. If you produced actual work this turn (investigated, fixed something, answered a real question), post the result as a normal reply — that is NOT a noise comment, and the standard rule that final results must be delivered via comment still applies. If the triggering comment was a pure acknowledgment, thanks, or sign-off AND you produced no work this turn, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n")
}
if task.Agent != nil && strings.Contains(task.Agent.Instructions, "## Squad Operating Protocol") {
fmt.Fprintf(&b, "⚠️ **Squad leader no_action rule:** If you decide no action is needed, call `multica squad activity %s no_action --reason \"...\"` and EXIT. DO NOT post any comment — not even one that says \"no action needed\" or \"exiting silently\". The squad activity call records your decision; a comment is redundant noise.\n\n", task.IssueID)
}
}
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
// Comment-reading pointer. Warm path with new comments: issue-wide
// since-delta count, but steer the agent to read the triggering thread
// first. Warm resumed path with no new comments: the trigger is already
// injected, so don't force a duplicate thread read. Cold path: read the
// triggering thread, not the flat timeline. Final fallback (no trigger id,
// shouldn't happen here): plain read.
if hint := execenv.BuildNewCommentsHint(task.IssueID, task.TriggerCommentID, task.TriggerThreadID, task.NewCommentsSince, task.NewCommentCount); hint != "" {
b.WriteString(hint)
} else if task.PriorSessionID != "" {
b.WriteString(execenv.BuildResumedCommentsHint(task.IssueID, task.TriggerCommentID, task.TriggerThreadID))
} else if cold := execenv.BuildColdCommentsHint(task.IssueID, task.TriggerCommentID, task.TriggerThreadID); cold != "" {
b.WriteString(cold)
} else {
fmt.Fprintf(&b, "Read the discussion: `multica issue comment list %s --recent 10 --output json` (resolved threads come back folded — `--full` to expand).\n\n", task.IssueID)
}
// Reply routing. When this run coalesced comments spanning MORE THAN ONE
// root thread, answer each thread in its own thread instead of dumping one
// merged comment (MUL-4348). Same-thread follow-ups collapse to a single
// group upstream, so they keep the ordinary single-parent path below and can
// never be split into duplicate replies.
if targets := commentReplyThreads(task); len(targets) >= 2 {
b.WriteString(execenv.BuildMultiThreadCommentReplyInstructions(task.IssueID, targets))
} else {
b.WriteString(execenv.BuildCommentReplyInstructions(provider, task.IssueID, task.TriggerCommentID))
}
return b.String()
}
// commentReplyThreads groups this run's trigger + coalesced comments by their
// root thread, in first-seen order (coalesced comments oldest-first, the newest
// trigger last). A run that coalesced several @mentions from the SAME thread
// yields a single group, so same-thread follow-ups get exactly one consolidated
// reply and can never be split into duplicates; comments from different root
// threads yield one group each so the agent replies inside each thread instead
// of merging them into one blob (MUL-4348).
//
// The reply for each thread targets the NEWEST comment that triggered this run
// in that thread (coalesced comments arrive oldest-first and the trigger is the
// newest overall, so a simple last-write-wins yields the newest per thread).
// That nests the answer next to the most recent question in the thread rather
// than at the thread root, and makes the trigger's own thread (--parent =
// trigger comment) consistent with every other thread instead of a special
// case. Returns nil when there is no trigger or only a single distinct thread —
// the caller then keeps the existing single-parent reply path unchanged.
func commentReplyThreads(task Task) []execenv.ThreadReplyTarget {
if task.TriggerCommentID == "" {
return nil
}
// A comment with no explicit thread id is a root comment: it is its own
// thread, so fall back to the comment id itself as the thread key.
threadKey := func(threadID, commentID string) string {
if threadID != "" {
return threadID
}
return commentID
}
order := make([]string, 0, len(task.CoalescedComments)+1)
parentByThread := make(map[string]string, len(task.CoalescedComments)+1)
// note records first-seen order but lets the newest comment win the reply
// target: inputs are chronological (coalesced oldest-first, trigger last),
// so the last write for a thread is its newest triggering comment.
note := func(threadID, parentID string) {
if _, ok := parentByThread[threadID]; !ok {
order = append(order, threadID)
}
parentByThread[threadID] = parentID
}
// Coalesced (older) comments first: reply under the specific comment that
// mentioned the agent, not the thread root, so a mid-thread mention gets its
// answer next to the question.
for _, cc := range task.CoalescedComments {
note(threadKey(cc.ThreadID, cc.ID), cc.ID)
}
// The newest trigger last: it always wins its own thread's reply target,
// overriding any earlier coalesced comment that shared the trigger's thread.
note(threadKey(task.TriggerThreadID, task.TriggerCommentID), task.TriggerCommentID)
if len(order) <= 1 {
return nil
}
targets := make([]execenv.ThreadReplyTarget, 0, len(order))
for _, tid := range order {
targets = append(targets, execenv.ThreadReplyTarget{ThreadID: tid, ParentID: parentByThread[tid]})
}
return targets
}
// buildChatPrompt constructs a prompt for interactive chat tasks.
func buildChatPrompt(task Task) string {
// Proactive self-introduction: the agent was just created and is opening the
// conversation. There is no user message to reply to — the agent sends the
// first message so the thread reads as the agent messaging its creator, not
// the creator prompting the agent (MUL-4230).
if task.ChatIntro {
var b strings.Builder
b.WriteString("You are running as a chat assistant for a Multica workspace.\n")
b.WriteString("You were just created, and this is the very first message in a direct chat with the person who created you. They have not written anything yet — you are opening the conversation. Send a short, warm, first-person introduction: who you are, what you're good at, and how they can work with you. Do NOT phrase it as an answer to a question or repeat any prompt back; just introduce yourself as if you reached out first.\n")
return b.String()
}
var b strings.Builder
b.WriteString("You are running as a chat assistant for a Multica workspace.\n")
b.WriteString("A user is chatting with you directly. Respond to their message.\n\n")
// Channel awareness (MUL-3871). When the session is backed by an IM channel,
// the agent must KNOW it is operating inside that channel — otherwise an ask
// like "what did you just talk about" sends it to read Multica instead of the
// Slack conversation. State it explicitly, point reads at the channel (not
// Multica), and teach the two read commands, telling the agent which to start
// with based on where it was @mentioned. A web-only chat session gets no such
// block — its history is the Multica chat_session the agent already resumes.
if task.ChatChannelType != "" {
platform := channelDisplayName(task.ChatChannelType)
fmt.Fprintf(&b, "You are operating inside a %s conversation — not the Multica web app. This conversation and its history live in %s, NOT in Multica; never look in Multica issues or comments for it. The message below may be only what triggered you. Read the conversation with:\n", platform, platform)
b.WriteString("- `multica chat history --output json` — the channel overview: recent top-level messages, each thread tagged with a `thread_id` and `reply_count`. It does NOT expand thread contents.\n")
b.WriteString("- `multica chat thread [<thread_id>] --output json` — read one thread's messages; omit the id to read the thread you are in, or pass a `thread_id` from the overview to read a specific thread.\n")
if task.ChatInThread {
b.WriteString("You were @mentioned inside a thread: start with `multica chat thread` to read it; if you need the wider channel, run `multica chat history` and open a specific thread with `multica chat thread <thread_id>`.\n")
} else {
b.WriteString("You were @mentioned at the channel top level: start with `multica chat history` to see the channel, then read a specific thread's contents with `multica chat thread <thread_id>`.\n")
}
// These reads are the agent's private context-gathering; narrating them
// into a chat reply reads as noise (the user reported every reply being
// prefixed with "我先读取…"). Tell the agent to keep them out of its answer.
b.WriteString("Do these reads SILENTLY as an internal step — they are how you gather context, not part of your answer. Do NOT narrate them: your reply must not begin with what you are about to read or just read (no \"我先读取…\" / \"let me read the history / open the thread\"). Reply to the user with your answer only.\n\n")
}
if task.Agent != nil && len(task.Agent.Skills) > 0 {
refs := ExtractSlashSkills(task.ChatMessage)
if len(refs) > 0 {
agentSkills := make(map[string]string, len(task.Agent.Skills))
for _, s := range task.Agent.Skills {
agentSkills[s.ID] = s.Name
}
selected := make([]string, 0, len(refs))
seen := make(map[string]struct{}, len(refs))
for _, ref := range refs {
name, ok := agentSkills[ref.ID]
if !ok {
continue
}
if _, ok := seen[ref.ID]; ok {
continue
}
seen[ref.ID] = struct{}{}
selected = append(selected, name)
}
if len(selected) > 0 {
b.WriteString("Explicitly selected skills:\n")
for _, name := range selected {
fmt.Fprintf(&b, "- %s\n", name)
}
b.WriteString("\n")
}
}
}
fmt.Fprintf(&b, "User message:\n%s\n", task.ChatMessage)
// List attachments by id + filename so the agent can fetch them via
// the CLI. We deliberately do NOT inline the URL: chat attachments
// live behind a signed CDN with a short TTL, so by the time the agent
// has finished thinking the URL embedded in the markdown body may
// have expired. `multica attachment download <id>` re-signs at click
// time and is the only reliable path.
if len(task.ChatMessageAttachments) > 0 {
b.WriteString("\nAttachments on this message:\n")
for _, a := range task.ChatMessageAttachments {
if a.ContentType != "" {
fmt.Fprintf(&b, "- id=%s filename=%q content_type=%s\n", a.ID, a.Filename, a.ContentType)
} else {
fmt.Fprintf(&b, "- id=%s filename=%q\n", a.ID, a.Filename)
}
}
b.WriteString("Use `multica attachment download <id>` to fetch each file locally before referring to it.\n")
b.WriteString("When creating an issue that should preserve one of these attachments, pass `--attachment-id <id>` to `multica issue create` in addition to keeping the attachment markdown inline.\n")
}
// Outbound attachments: how the agent puts an image/file INTO its reply.
// Web/mobile chat only — for IM-channel chats the reply is delivered to
// that platform, not the Multica chat UI, so this binding does not apply.
if task.ChatChannelType == "" {
b.WriteString("\nTo include a file or image you produced in your reply, run `multica attachment upload <local-path>`. The file binds to your reply automatically and appears as an attachment card below it even if you paste nothing. The command also returns a `markdown` snippet you may paste on its own line to place the item where you want it (files render as a card, images inline).\n")
}
return b.String()
}
// channelDisplayName renders a chat_channel_type for prompt copy.
func channelDisplayName(channelType string) string {
switch channelType {
case "slack":
return "Slack"
default:
return channelType
}
}
// buildAutopilotPrompt constructs a prompt for run_only autopilot tasks.
func buildAutopilotPrompt(task Task) string {
var b strings.Builder
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
b.WriteString("This task was triggered by an Autopilot in run-only mode. There is no assigned Multica issue for this run.\n\n")
fmt.Fprintf(&b, "Autopilot run ID: %s\n", task.AutopilotRunID)
if task.AutopilotID != "" {
fmt.Fprintf(&b, "Autopilot ID: %s\n", task.AutopilotID)
}
if task.AutopilotTitle != "" {
fmt.Fprintf(&b, "Autopilot title: %s\n", task.AutopilotTitle)
}
if task.AutopilotSource != "" {
fmt.Fprintf(&b, "Trigger source: %s\n", task.AutopilotSource)
}
if strings.TrimSpace(string(task.AutopilotTriggerPayload)) != "" {
fmt.Fprintf(&b, "Trigger payload:\n%s\n", strings.TrimSpace(string(task.AutopilotTriggerPayload)))
}
b.WriteString("\nAutopilot instructions:\n")
if strings.TrimSpace(task.AutopilotDescription) != "" {
b.WriteString(task.AutopilotDescription)
b.WriteString("\n\n")
} else if task.AutopilotTitle != "" {
fmt.Fprintf(&b, "%s\n\n", task.AutopilotTitle)
} else {
b.WriteString("No additional autopilot instructions were provided. Inspect the autopilot configuration before proceeding.\n\n")
}
if task.AutopilotID != "" {
fmt.Fprintf(&b, "Start by running `multica autopilot get %s --output json` if you need the full autopilot configuration, then complete the instructions above.\n", task.AutopilotID)
} else {
b.WriteString("Complete the instructions above.\n")
}
b.WriteString("Do not run `multica issue get`; this run does not have an issue ID.\n")
return b.String()
}