mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* 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 `` 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>
439 lines
30 KiB
Go
439 lines
30 KiB
Go
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()
|
||
}
|