mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* feat(cli): add --compact to issue comment list, adopt it in every agent read command (MUL-5442) The #5999 follow-up its closing note asked for, now that the workspace measurements exist: the two bounded reads the agent workflow prescribes are dominated by reader-noise metadata, and every byte of it enters the conversation history at full price once and is replayed at cache-read price by every later call of the session. --compact (JSON output only, opt-in) drops per comment: the issue_id echoed from the request path, source_task_id, updated_at when identical to created_at, null-valued fields, and empty arrays. Content, identity, and thread-summary fields pass through untouched; a real edit timestamp or non-empty array is never dropped. Measured on MUL-5442 (production data): --roots-only --summary scan 6,117B -> 4,738B (-23%); --thread --tail 30 deep read 47,359B -> 39,349B (-17%). Adoption: all nine runnable comment-list command templates in the per-turn channel (prompt.go, reply_instructions.go) now carry --compact, and the brief's Available Commands line and workflow step 3 document it (+~40B one-time, byte-stable). Command pins updated accordingly; new unit test pins the drop/keep contract and the help-contract test pins the flag's rendered usage. Known local-env failure: TestResolveWorkspaceID_AgentContextSkipsConfig fails identically on clean origin/main in this agent runtime (agent context env interference) — unrelated. Co-authored-by: multica-agent <[email protected]> * test(cli): wire-level --compact coverage and zero-value keep pins (MUL-5442) Review nits on #6546: TestRunIssueCommentListCompactWiring drives runIssueCommentList against an httptest server both ways — default JSON keeps every field, --compact drops the reader-noise set while zero-value scalars (reply_count: 0, content_truncated: false, folded_count: 0) survive, pinning that null pruning never generalizes to zero pruning. Same zero-value pins added to the pure-function test. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Bohan-J <[email protected]> Co-authored-by: multica-agent <[email protected]>
280 lines
15 KiB
Go
280 lines
15 KiB
Go
package execenv
|
|
|
|
import "fmt"
|
|
|
|
// BuildNewCommentsHint returns the comment-reading pointer for the WARM path —
|
|
// the agent ran on this issue before, so there is a since-anchor. The server
|
|
// count is ISSUE-WIDE (every thread, not just the triggering one) and excludes
|
|
// the triggering comment itself because that body is already injected into the
|
|
// prompt. It ships only the COUNT and the cursor — never the comment bodies —
|
|
// so the server stays cheap and the agent pulls details on demand.
|
|
//
|
|
// The agent is told the full issue-wide volume but steered to read the
|
|
// triggering (parent) thread FIRST instead of blindly catching up on every
|
|
// thread. The issue-wide `--since` catch-up is kept as an explicit
|
|
// "only if you need it" fallback.
|
|
//
|
|
// Since MUL-5377 the per-turn prompt (daemon.buildCommentPrompt) is the only
|
|
// caller — the brief must not carry per-run routing state.
|
|
//
|
|
// Renders nothing on cold start (no prior run → newCommentsSince empty) or when
|
|
// there are no new comments (newCommentCount <= 0) or issueID is empty. In those
|
|
// cases the caller falls back to BuildResumedCommentsHint (when a prior session
|
|
// is active) or BuildColdCommentsHint.
|
|
func BuildNewCommentsHint(issueID, triggerCommentID, triggerThreadID, newCommentsSince string, newCommentCount int) string {
|
|
if newCommentCount <= 0 || newCommentsSince == "" || issueID == "" {
|
|
return ""
|
|
}
|
|
threadID := activeThreadID(triggerThreadID, triggerCommentID)
|
|
// When we know the triggering thread, steer the agent to read THAT thread
|
|
// first rather than blindly pulling every new comment issue-wide. The
|
|
// issue-wide --since catch-up is demoted to an only-if-needed fallback,
|
|
// phrased as a rerun of the thread command minus `--thread` instead of a
|
|
// second full command: the duplicate restated the issue UUID and anchor
|
|
// for no routing value (MUL-5721 OPT-1).
|
|
if threadID != "" {
|
|
return fmt.Sprintf(
|
|
"%d new comment(s) on this issue since your last run — don't read them all blindly. "+
|
|
"Start with the thread your triggering comment is in: "+
|
|
"`multica issue comment list %s --thread %s --since %s --compact --output json` "+
|
|
"(swap `--since` for `--tail 30` if you need the full thread, not just the delta). "+
|
|
"Only if you need context from the other threads, rerun it without `--thread` for the issue-wide catch-up.\n\n",
|
|
newCommentCount, issueID, threadID, newCommentsSince,
|
|
)
|
|
}
|
|
// Defensive: comment triggers always carry a trigger id, but if one is
|
|
// missing there is no thread to anchor on, so fall back to the plain
|
|
// issue-wide catch-up.
|
|
return fmt.Sprintf(
|
|
"%d new comment(s) on this issue since your last run. Catch up: "+
|
|
"`multica issue comment list %s --since %s --compact --output json`.\n\n",
|
|
newCommentCount, issueID, newCommentsSince,
|
|
)
|
|
}
|
|
|
|
// BuildResumedCommentsHint returns the comment-reading pointer for the WARM
|
|
// no-delta path: the daemon is resuming a prior provider session and the
|
|
// triggering comment body has already been injected into the per-turn prompt.
|
|
// newCommentCount == 0 here means no new comments arrived issue-wide since the
|
|
// last run (beyond the injected trigger and the agent's own replies). Keep the
|
|
// read bounded and conditional, but make it explicit that context-dependent
|
|
// replies should refresh the triggering conversation rather than trusting
|
|
// resumed memory alone.
|
|
func BuildResumedCommentsHint(issueID, triggerCommentID, triggerThreadID string) string {
|
|
threadID := activeThreadID(triggerThreadID, triggerCommentID)
|
|
if issueID == "" || threadID == "" {
|
|
return ""
|
|
}
|
|
// No standalone anchor-restating sentence here (MUL-5721 OPT-1): the read
|
|
// command below already carries the thread anchor, and the trigger comment
|
|
// id reaches the agent as the reply cookbook's `--parent` value.
|
|
return fmt.Sprintf(
|
|
"You're resuming the prior session, and the triggering comment is already included above. "+
|
|
"No other new comments on this issue since your last run. "+
|
|
"If your reply depends on thread context, do not rely only on resumed session memory — "+
|
|
"first pull the triggering conversation with: "+
|
|
"`multica issue comment list %s --thread %s --tail 30 --compact --output json`.\n\n",
|
|
issueID, threadID,
|
|
)
|
|
}
|
|
|
|
// BuildColdCommentsHint returns the comment-reading pointer for the COLD path —
|
|
// the agent has no prior run on this issue, so there is no since-anchor and
|
|
// BuildNewCommentsHint renders nothing. Instead of dumping the whole flat
|
|
// timeline (oldest-first, server cap 2000), point the agent at the triggering
|
|
// CONVERSATION: `--thread <trigger> --tail 30` returns that thread's root plus
|
|
// its 30 newest replies (root is always included, even at --tail 0) — the
|
|
// context the triggering comment actually needs. Cross-thread background is a
|
|
// cheap `--roots-only --summary` scan; the hint deliberately does NOT name
|
|
// `--recent`, whose saturation trap and pagination live once in the brief's
|
|
// `## Available Commands` (MUL-5372). Per-turn hints name only the reads they
|
|
// actually want the agent to run.
|
|
//
|
|
// Since MUL-5377 the per-turn prompt is the only caller (same as
|
|
// BuildNewCommentsHint). Returns "" when there is no triggering comment to
|
|
// thread from, so the caller can keep a final plain fallback.
|
|
func BuildColdCommentsHint(issueID, triggerCommentID, triggerThreadID string) string {
|
|
threadID := activeThreadID(triggerThreadID, triggerCommentID)
|
|
if issueID == "" || threadID == "" {
|
|
return ""
|
|
}
|
|
// The roots scan is phrased as a flag swap on the thread command above, not
|
|
// a second full command: the duplicate restated the issue UUID for no
|
|
// routing value (MUL-5721 OPT-1).
|
|
return fmt.Sprintf(
|
|
"Read the triggering conversation first: "+
|
|
"`multica issue comment list %s --thread %s --tail 30 --compact --output json` "+
|
|
"(that thread's root + its 30 newest replies). "+
|
|
"Need cross-thread background? Rerun with `--roots-only --summary` replacing `--thread ... --tail 30` "+
|
|
"to scan the other threads cheaply, and expand only what looks relevant.\n\n",
|
|
issueID, threadID,
|
|
)
|
|
}
|
|
|
|
func activeThreadID(triggerThreadID, triggerCommentID string) string {
|
|
if triggerThreadID != "" {
|
|
return triggerThreadID
|
|
}
|
|
return triggerCommentID
|
|
}
|
|
|
|
// BuildCommentReplyInstructions returns the canonical block telling an agent
|
|
// how to post its reply for a comment-triggered task. Both the per-turn
|
|
// prompt (daemon.buildCommentPrompt) and the CLAUDE.md workflow
|
|
// (InjectRuntimeConfig) call this so the trigger comment ID and the
|
|
// --parent value cannot drift between surfaces.
|
|
//
|
|
// The explicit "do not reuse --parent from previous turns" wording exists
|
|
// because resumed Claude sessions keep prior turns' tool calls in context
|
|
// and will otherwise copy the old --parent UUID forward.
|
|
//
|
|
// The template is platform-agnostic AND provider-agnostic — the failure it
|
|
// guards against lives at the shell layer, so it cannot be scoped to one
|
|
// provider or one OS:
|
|
//
|
|
// - Inline `--content "..."` lets the shell rewrite the body BEFORE the CLI
|
|
// receives it: a backtick-wrapped token becomes a failed command
|
|
// substitution that is silently deleted, the stored comment no longer
|
|
// matches what the model intended, and a model that notices the mismatch
|
|
// can retry forever (MUL-2904 / OKK-497). It also lets Codex emit literal
|
|
// `\n` escapes inside `--content` (MUL-1467).
|
|
// - `--content-stdin` with a HEREDOC has TWO failure modes the model cannot
|
|
// see:
|
|
// 1. On Windows, PowerShell 5.1's `$OutputEncoding` defaults to
|
|
// ASCIIEncoding when piping to native commands and drops non-ASCII as
|
|
// `?` before the bytes reach `multica.exe` (#2198 Chinese, #2236
|
|
// Chinese, #2376 Cyrillic).
|
|
// 2. On any host, when the model emits a multi-flag command (e.g.
|
|
// `multica issue create --title ... --assignee-id ... --project ...`)
|
|
// the bash heredoc/flag boundary is fragile: a `BODY \` "terminator
|
|
// with trailing token" is not recognised as the heredoc end, so flag
|
|
// lines after it are swallowed into the description; or a clean
|
|
// terminator turns the trailing `--assignee ...` line into a separate
|
|
// shell statement that fails while the create already succeeded with
|
|
// no assignee. Both paths exit 0 with silently dropped flags. Github
|
|
// issue #4182 documents two confirmed cases (OXY-78, OXY-76).
|
|
//
|
|
// The single safe path is therefore: write the body to a UTF-8 file with
|
|
// the file-write tool, post with `--content-file`, then remove the file.
|
|
// All flags live on one shell-token line; the body never touches the shell;
|
|
// no heredoc boundary exists for flags to leak across. This converges with
|
|
// the long-standing Windows path so the cross-platform template is one shape.
|
|
//
|
|
// provider is retained for caller symmetry and future per-provider tweaks; the
|
|
// guardrail itself is intentionally identical across providers and hosts.
|
|
func BuildCommentReplyInstructions(provider, issueID, triggerCommentID string, squadLeader bool) string {
|
|
if triggerCommentID == "" {
|
|
return ""
|
|
}
|
|
return buildCommentReplyInstructionsSlim(provider, issueID, triggerCommentID, squadLeader)
|
|
}
|
|
|
|
// buildCommentReplyInstructionsSlim is the compressed reply-instructions
|
|
// block used by BuildCommentReplyInstructions. It was introduced in
|
|
// MUL-3560 as the slim alternative to a legacy verbose form; the
|
|
// `runtime_brief_slim` flag has since been retired (MUL-4297) and this is
|
|
// now the only form.
|
|
//
|
|
// The slim block carries only the trigger-specific cookbook (the exact
|
|
// `--parent` UUID, the file path, the cleanup line) plus the two
|
|
// behavioural rules tests pin ("do NOT reuse --parent" and "do not rely
|
|
// on `\n` escapes"). The detailed shell-hazard rationale lives in the
|
|
// canonical `## Comment Formatting` section the same brief carries, so
|
|
// repeating it inline at every comment-triggered step 7 would be
|
|
// duplication, not signal.
|
|
func buildCommentReplyInstructionsSlim(provider, issueID, triggerCommentID string, squadLeader bool) string {
|
|
// The squad leader's `no_action` exit (recorded via `squad activity`) is
|
|
// the one path where posting no comment is correct — the imperative must
|
|
// carry its own carve-out so a later line never contradicts the
|
|
// no_action rule injected above it (MUL-5442 #6493 review).
|
|
lead := "Post your reply as a comment — always use the trigger comment ID below, "
|
|
if squadLeader {
|
|
lead = "Unless your outcome is `no_action`, post your reply as a comment — always use the trigger comment ID below, "
|
|
}
|
|
if runtimeGOOS == "windows" {
|
|
return fmt.Sprintf(
|
|
lead+
|
|
"do NOT reuse --parent values from previous turns in this session.\n\n"+
|
|
"Write the body file first — never pipe via `--content-stdin` (PowerShell drops non-ASCII; full rules: ## Comment Formatting above):\n\n"+
|
|
" multica issue comment add %s --parent %s --content-file ./reply.md\n"+
|
|
" Remove-Item ./reply.md\n\n"+
|
|
"Do NOT write literal `\\n` escapes to simulate line breaks; the file preserves real newlines.\n",
|
|
issueID, triggerCommentID,
|
|
)
|
|
}
|
|
return fmt.Sprintf(
|
|
lead+
|
|
"do NOT reuse --parent values from previous turns in this session.\n\n"+
|
|
"Write the body file first (rules: ## Comment Formatting above — MUL-2904 / #4182):\n\n"+
|
|
" multica issue comment add %s --parent %s --content-file ./reply.md\n"+
|
|
" rm ./reply.md\n\n"+
|
|
"Do NOT write literal `\\n` escapes to simulate line breaks; the file preserves real newlines.\n",
|
|
issueID, triggerCommentID,
|
|
)
|
|
}
|
|
|
|
// ThreadReplyTarget is one root-thread group a coalesced run must answer.
|
|
// ThreadID labels the conversation (its root comment id); ParentID is the exact
|
|
// `--parent` the agent must pass so its reply lands inside that thread.
|
|
type ThreadReplyTarget struct {
|
|
ThreadID string
|
|
ParentID string
|
|
}
|
|
|
|
// BuildMultiThreadCommentReplyInstructions is the reply fan-out block for a run
|
|
// whose coalesced comments span MORE THAN ONE root thread (MUL-4348). It
|
|
// deliberately overrides the general "post exactly one comment per run"
|
|
// guidance for this specific run: three unrelated questions raised in three
|
|
// separate threads must land as three in-thread answers, not one merged blob
|
|
// posted under a single thread (or as a stray root comment).
|
|
//
|
|
// The grouping is computed server-side, so same-thread follow-ups never reach
|
|
// here — they collapse to a single target upstream and take the ordinary
|
|
// single-parent path. That is why the agent is told, unconditionally, to post
|
|
// exactly one reply per listed thread and never more than one reply in the same
|
|
// thread: the "multiple @mentions in one thread" case is already consolidated
|
|
// before this instruction is emitted, so a per-thread fan-out cannot split it.
|
|
//
|
|
// The block carries only what is multi-thread-SPECIFIC: the fan-out
|
|
// announcement + override, the posting order, the per-thread `--parent`
|
|
// targets, and the one mechanical delta (a DISTINCT body file per thread).
|
|
// The posting mechanism itself — body file → `--content-file` → cleanup, the
|
|
// inline/`--content-stdin` bans, the `\n`-escape rule, and the OS-specific
|
|
// cleanup command — lives once in the brief's `## Comment Formatting`; this
|
|
// block used to restate all of it plus two example command pairs, triple-
|
|
// writing the same cookbook for ~1KB extra per multi-thread turn (MUL-5825).
|
|
// Dropping the embedded commands also removes the only OS-dependent text, so
|
|
// the block no longer branches on runtimeGOOS: `## Comment Formatting` keeps
|
|
// the OS split.
|
|
//
|
|
// Returns "" for fewer than two targets; callers keep the single-parent path.
|
|
func BuildMultiThreadCommentReplyInstructions(issueID string, targets []ThreadReplyTarget, squadLeader bool) string {
|
|
if issueID == "" || len(targets) < 2 {
|
|
return ""
|
|
}
|
|
|
|
targetLines := ""
|
|
for i, tgt := range targets {
|
|
targetLines += fmt.Sprintf("%d. thread %s → reply with `--parent %s`\n", i+1, tgt.ThreadID, tgt.ParentID)
|
|
}
|
|
|
|
// Same carve-out as the single-thread cookbook (MUL-5442 #6493 review):
|
|
// the leader's no_action exit must not be contradicted by this later
|
|
// imperative, and the scope sentence must govern the ENTIRE fan-out
|
|
// block — every obligation below ("multiple replies are required", the
|
|
// posting order, the per-thread file rule) sits under the "Otherwise" —
|
|
// not just the first verb. Ordinary agents keep the unconditional form
|
|
// byte-for-byte.
|
|
lead := "This run coalesced comments from %d DISTINCT threads. Post ONE reply per thread"
|
|
if squadLeader {
|
|
lead = "This run coalesced comments from %d DISTINCT threads. **If your outcome is `no_action`, skip this ENTIRE fan-out block — post no replies at all and exit via `multica squad activity` as your leader rules direct; everything below applies only otherwise.** Otherwise, post ONE reply per thread"
|
|
}
|
|
return fmt.Sprintf(
|
|
lead+" — %d in total. This OVERRIDES the \"post exactly one comment per run\" rule: for THIS run multiple replies are required and correct. Do NOT merge separate threads into one comment or post twice in the same thread.\n\n"+
|
|
"Reply targets, in posting order — OLDEST thread first, the newest (triggering) thread LAST. Use the exact `--parent` for each; never reuse a `--parent` from an earlier turn:\n"+
|
|
"%s\n"+
|
|
"Write and post each reply exactly as `## Comment Formatting` above directs, with ONE multi-thread delta: use a DISTINCT body file per thread (./reply-1.md, ./reply-2.md, …) so one reply's content can never leak into another's.\n",
|
|
len(targets), len(targets), targetLines,
|
|
)
|
|
}
|