Files
multica/server/internal/daemon/execenv/runtime_config.go
Eve 38aa864e70 MUL-3560: refactor buildMetaSkillContent into kind-driven dispatcher
This is the structural prep PR for the runtime-brief diet roadmap on
MUL-3560 — split out from the per-section compression work so the
refactor risk and the content-gating risk land separately.

Output is byte-for-byte identical to the pre-refactor monolithic
builder for every existing test fixture. All existing tests pass
unchanged. The follow-up PR (0.6) will start gating sections per the
Section × Kind matrix from Eve's design comment on MUL-3560 (drop
Mentions / Comment Formatting / Issue Metadata / Sub-issue out of
quick-create, drop Comment Formatting / Mentions / Attachments out of
autopilot and chat, etc.), with negative assertions added alongside
each removal so each kind's brief contract becomes machine-checked.

What changed:

- runtime_config_kind.go (new): taskKind enum + classifyTask helper.
  Five kinds, mutually exclusive in practice; the documented
  precedence rule keeps the tiebreak deterministic even if a future
  caller breaks the mutex by accident. hasIssueContext is the
  predicate Issue Metadata and Sub-issue Creation gate on.
- runtime_config_sections.go (new): every section of the brief is
  extracted into its own writeXxx helper, preserving byte sequences
  exactly. Helpers with internal conditions keep those conditions;
  helpers the dispatcher always wants to call are unconditional.
- runtime_config.go: buildMetaSkillContent collapses from a
  ~450-line monolith to a ~70-line dispatcher — prelude, conditional
  context sections, a 5-way kind switch over Workflow bodies, and
  trailing sections. The matrix of "which kind gets which section"
  is now readable in one screen.
- runtime_config_kind_test.go (new): TestClassifyTask covers all 5
  kinds plus tiebreak cases; TestTaskKindHasIssueContext pins the
  predicate semantics; TestBuildMetaSkillContentKindMatrix is the
  structural canary that locks today's per-kind section set so any
  later PR that drops a section from a kind must update its
  expectations in lockstep.

Verification:

- go build ./...                                                      ok
- go vet ./internal/daemon/...                                        ok
- go test ./internal/daemon/... ./internal/handler/...                ok
- go test ./internal/daemon/execenv -run TestClassifyTask -v         pass
- go test ./internal/daemon/execenv -run TestTaskKindHasIssueContext pass
- go test ./internal/daemon/execenv -run TestBuildMetaSkillContent   pass

No production behaviour change in this PR. No new logging, no new
char-bucket fields — those are in PR #4439 and compose cleanly with
this refactor.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 14:33:42 +08:00

475 lines
21 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 execenv
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
)
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
// inside the runtime config file (CLAUDE.md / AGENTS.md / GEMINI.md). The
// markers exist so writeRuntimeConfigFile can:
//
// - preserve user-authored content in the same file (the user's repo may
// already ship a CLAUDE.md / AGENTS.md when the agent is pointed at a
// local_directory project resource),
// - replace the brief idempotently on subsequent runs in the same workdir
// instead of appending duplicate copies, and
// - leave a precise excision target for a future cleanup pass.
//
// HTML comments are used so the markers are inert in every Markdown renderer
// and harmless when fed to the agent as instructions. Changing the marker
// text is a breaking change for any file that already carries the previous
// markers — bump deliberately.
const (
runtimeMarkerBegin = "<!-- BEGIN MULTICA-RUNTIME (auto-managed; do not edit) -->"
runtimeMarkerEnd = "<!-- END MULTICA-RUNTIME -->"
// runtimeManagedSeparator is the fixed separator inserted between any
// pre-existing user content and the marker block whenever Inject
// appends to a file that already exists. The separator is considered
// part of the managed region: Cleanup strips it together with the
// block, so the file rolls back to its exact pre-injection bytes
// regardless of whether the user file ended with no newline, one
// newline, or multiple trailing newlines. Without a fixed-width
// separator the cleanup path would have to renormalise the user's
// trailing bytes and would leave a subtle but real diff every run
// (see MUL-2753 review on PR #3438).
//
// Cleanup distinguishes "file we created" (no managed separator
// precedes the block — write a missing file from scratch) from "file
// that pre-existed" (managed separator precedes the block) so the
// file's existence is preserved exactly across the inject→cleanup
// cycle, including empty / whitespace-only pre-existing files.
runtimeManagedSeparator = "\n\n"
)
// runtimeGOOS is the host-platform string used by buildMetaSkillContent and
// BuildCommentReplyInstructions to emit Windows-specific guidance. Defaults
// to runtime.GOOS; tests override it to exercise the cross-platform branches
// deterministically without having to run on every target OS.
var runtimeGOOS = runtime.GOOS
// sanitizeNameForBriefMarkdown turns a possibly-multiline display name into a
// single-line, plain-text token that is safe to embed inside markdown inline
// constructs (e.g. `**%s**`) in the agent brief. The brief is loaded as
// trusted instructions, so user-controlled name fields must not be able to
// introduce headings, lists, or close the surrounding bold span.
//
// CR/LF and other whitespace control bytes collapse to a single space; other
// C0 controls and DEL are dropped; markdown structural characters that have
// meaning in inline context (`*`, `_`, “ ` “, `\`, `[`, `]`, `<`) are
// backslash-escaped. Trailing whitespace is trimmed.
func sanitizeNameForBriefMarkdown(name string) string {
var b strings.Builder
b.Grow(len(name))
prevSpace := false
for _, r := range name {
switch {
case r == '\r' || r == '\n' || r == '\t' || r == '\v' || r == '\f':
if !prevSpace && b.Len() > 0 {
b.WriteByte(' ')
prevSpace = true
}
case r < 0x20 || r == 0x7f:
continue
case r == '*' || r == '_' || r == '`' || r == '\\' || r == '[' || r == ']' || r == '<':
b.WriteByte('\\')
b.WriteRune(r)
prevSpace = false
default:
b.WriteRune(r)
prevSpace = false
}
}
return strings.TrimSpace(b.String())
}
// sanitizeEmailForBrief returns the email verbatim when it is safe to embed
// inline in the brief, or "" when it carries a character a real address never
// has (whitespace, control chars, or a markdown-break risk). Unlike
// sanitizeNameForBriefMarkdown it does NOT backslash-escape markdown specials:
// an agent may want to match the initiator's address exactly, and escaping
// `_`/`+` would corrupt it, while a valid email can't contain a newline to
// inject a heading anyway. Emails are validated at signup, so this is
// defense-in-depth, not the primary guard. See MUL-2645.
func sanitizeEmailForBrief(email string) string {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return ""
}
for _, r := range email {
if r < 0x20 || r == 0x7f || r == ' ' || r == '\\' || r == '`' || r == '*' || r == '<' || r == '>' || r == '[' || r == ']' {
return ""
}
}
return email
}
// formatProjectResource renders a single resource as a human-readable bullet.
// Unknown resource types fall back to a JSON-encoded ref so the agent can
// still read what the user attached. New resource types should add a case
// here AND in the API validator (handler/project_resource.go).
func formatProjectResource(r ProjectResourceForEnv) string {
label := r.Label
switch r.ResourceType {
case "github_repo":
var payload struct {
URL string `json:"url"`
DefaultBranchHint string `json:"default_branch_hint,omitempty"`
}
_ = json.Unmarshal(r.ResourceRef, &payload)
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
if payload.DefaultBranchHint != "" {
out += fmt.Sprintf(" (default branch: `%s`)", payload.DefaultBranchHint)
}
if label != "" {
out += " — " + label
}
return out
default:
ref := string(r.ResourceRef)
if ref == "" {
ref = "{}"
}
out := fmt.Sprintf("**%s**: `%s`", r.ResourceType, ref)
if label != "" {
out += " — " + label
}
return out
}
}
// InjectRuntimeConfig writes the meta skill content into the runtime-specific
// config file so the agent discovers its environment through its native mechanism.
//
// For Claude: writes {workDir}/CLAUDE.md (skills discovered natively from .claude/skills/)
// For Codex: writes {workDir}/AGENTS.md (skills discovered natively via CODEX_HOME)
// For Copilot: writes {workDir}/AGENTS.md (skills discovered natively from .github/skills/)
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from {workDir}/skills/ via per-task openclaw-config.json that pins agents.defaults.workspace)
// For Hermes: writes {workDir}/AGENTS.md (skills fall back to .agent_context/skills/; AGENTS.md points there)
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/skills/)
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
// For Qoder: writes {workDir}/AGENTS.md (skills discovered from .qoder/skills/, user-level ~/.qoder/skills is unaffected)
// For Antigravity: writes {workDir}/AGENTS.md (agy CLI reads AGENTS.md natively; skills discovered natively from .agents/skills/ — see https://antigravity.google/docs/gcli-migration)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
content := buildMetaSkillContent(provider, ctx)
path := runtimeConfigPath(workDir, provider)
if path == "" {
// Unknown provider — skip config injection, prompt-only mode.
return content, nil
}
return content, writeRuntimeConfigFile(path, content)
}
// runtimeConfigPath returns the absolute path to the runtime config file that
// InjectRuntimeConfig writes for the given provider, or "" when the provider
// has no file-based config target. Centralising the mapping keeps Inject /
// Cleanup in lockstep — both paths consult the same table so a new provider
// added to one side cannot drift past the other.
func runtimeConfigPath(workDir, provider string) string {
switch provider {
case "claude", "codebuddy":
return filepath.Join(workDir, "CLAUDE.md")
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder":
return filepath.Join(workDir, "AGENTS.md")
case "gemini":
return filepath.Join(workDir, "GEMINI.md")
default:
return ""
}
}
// writeRuntimeConfigFile writes the Multica runtime brief to path without
// clobbering any user-authored content already present. Behaviour by file
// state:
//
// - file missing → create the file containing only the marker block, no
// leading separator. Cleanup detects the absence of the separator and
// restores the missing-file state by removing the file outright.
// - file present (any content, including empty), no marker block →
// append `<runtimeManagedSeparator>` + the marker block. The
// separator's bytes are part of the managed region so Cleanup can
// restore the user's pre-injection bytes exactly (no trailing-newline
// normalisation, no surprises for files that ended without a newline
// or with extra trailing newlines).
// - file present, marker block already there → replace the body between
// the markers in place so repeated runs in the same workdir don't grow
// the file unboundedly. The pre-block content (including any managed
// separator established by the first inject) is preserved verbatim.
//
// The previous implementation called os.WriteFile unconditionally, which
// silently truncated a repository's CLAUDE.md / AGENTS.md / GEMINI.md the
// first time the agent was pointed at the user's own directory via the
// local_directory project resource flow. See MUL-2753.
func writeRuntimeConfigFile(path, brief string) error {
block := runtimeMarkerBegin + "\n" + strings.TrimRight(brief, "\n") + "\n" + runtimeMarkerEnd + "\n"
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return os.WriteFile(path, []byte(block), 0o644)
}
if err != nil {
return fmt.Errorf("read existing runtime config %s: %w", path, err)
}
existingStr := string(existing)
if start, end, ok := locateMarkerBlock(existingStr); ok {
// Replace the existing block in place. locateMarkerBlock already
// consumes the trailing newline that closed the previous block, so
// successive runs don't accumulate blank lines around the block.
// The managed separator (if any) lives in existingStr[:start] and
// is preserved untouched.
newContent := existingStr[:start] + block + existingStr[end:]
return os.WriteFile(path, []byte(newContent), 0o644)
}
// No marker block present. Append the fixed managed separator followed
// by the block. The separator is unconditional — including for files
// that already end in two or more newlines — so the byte boundary
// between user content and the managed region is deterministic, which
// is what lets Cleanup roll back to the user's exact original bytes.
return os.WriteFile(path, []byte(existingStr+runtimeManagedSeparator+block), 0o644)
}
// locateMarkerBlock finds the [start, end) byte range of the Multica marker
// block inside content. The returned `end` is one past the block's trailing
// newline (if any) so callers can splice the block out without leaving an
// orphan blank line behind.
//
// The end marker is searched for strictly after the begin marker. This
// matters for two malformed cases that the previous naive `strings.Index`
// pair would mishandle:
//
// - User content carries a stray `<!-- END MULTICA-RUNTIME -->` (e.g. a
// documentation snippet showing what the wire format looks like) before
// any begin marker. The naive parser would find that end and reject the
// block (`endIdx > startIdx` false), then append a fresh block — and
// since the stray end stays in place, every subsequent run would append
// yet another block, growing the file unboundedly.
// - A previous run crashed between writing begin and end and left the file
// with a half-block. The naive parser would not find an end, fall
// through to the append branch, and stack a new block after the
// half-block. Treating "begin found, no end after" as "the block ends
// at EOF" makes the next write replace the half-block in place.
func locateMarkerBlock(content string) (start, end int, found bool) {
start = strings.Index(content, runtimeMarkerBegin)
if start < 0 {
return 0, 0, false
}
afterBegin := start + len(runtimeMarkerBegin)
endRel := strings.Index(content[afterBegin:], runtimeMarkerEnd)
if endRel < 0 {
// Malformed — no end marker after begin. Treat the rest of the file
// as the block so the next write replaces it cleanly instead of
// stacking another block beneath the half-block.
return start, len(content), true
}
end = afterBegin + endRel + len(runtimeMarkerEnd)
if end < len(content) && content[end] == '\n' {
end++
}
return start, end, true
}
// CleanupRuntimeConfig excises the Multica marker block from the runtime
// config file for the given provider and restores the file to its exact
// pre-injection state, byte for byte. The cleanup is the second half of
// the contract `writeRuntimeConfigFile` establishes: together they must
// round-trip a user's local repository config across an arbitrary number
// of Multica runs without ever touching a single non-managed byte.
//
// Behaviour, mirroring the three Inject states:
//
// - file has no marker block → no-op (nothing was ever injected here);
// - block is at the start of the file with no preceding managed
// separator → the file was created by Inject from a missing-file
// state. Remove the file outright so the post-cleanup directory
// listing is byte-identical to the pre-Inject one.
// - block is preceded by the fixed managed separator → strip the
// separator together with the block; whatever remains (which may be
// an empty pre-existing file, a whitespace-only file, or arbitrary
// user content) is the user's original file, written back verbatim
// with NO trailing-newline normalisation and NO TrimSpace-based file
// removal heuristic. Both of those were sources of subtle diff in
// PR #3438 review feedback.
//
// Required for the local_directory flow (WorkDir is the user's own repo):
// without this pass, a manual `claude` / `codex` / `gemini` run started by
// the user inside the same directory after a Multica task would pick up
// the stale brief and act on the previous task's issue id, trigger
// comment id, and reply rules. Cloud workspace runs never trigger this
// pollution because their workdir is daemon scratch that the GC loop
// deletes wholesale; the daemon skips this Cleanup on those workdirs.
//
// Missing files, unknown providers, and files without a marker block are
// no-ops — Cleanup is safe to call defensively.
func CleanupRuntimeConfig(workDir, provider string) error {
path := runtimeConfigPath(workDir, provider)
if path == "" {
return nil
}
existing, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read runtime config %s: %w", path, err)
}
existingStr := string(existing)
start, end, ok := locateMarkerBlock(existingStr)
if !ok {
return nil
}
pre := existingStr[:start]
post := existingStr[end:]
// Detect — and strip — the fixed managed separator that Inject puts
// immediately before the block whenever it appended to a file that
// pre-existed. The absence of the separator is the marker that says
// "Inject created this file from scratch", which is the only case
// where Cleanup is allowed to delete the file.
hadManagedSeparator := strings.HasSuffix(pre, runtimeManagedSeparator)
if hadManagedSeparator {
pre = pre[:len(pre)-len(runtimeManagedSeparator)]
}
remainder := pre + post
if !hadManagedSeparator && remainder == "" {
// Inject created the file (no managed separator → block was the
// only content). Restore the missing-file state.
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("remove runtime config %s: %w", path, err)
}
return nil
}
// File pre-existed (possibly empty, possibly whitespace-only,
// possibly with user content) — write the remainder back exactly,
// without any normalisation. An empty `remainder` here means the
// user's original file was empty; we still write it (zero-byte file)
// so the file's existence is preserved.
return os.WriteFile(path, []byte(remainder), 0o644)
}
// buildMetaSkillContent generates the meta skill markdown that teaches the
// agent about the Multica runtime environment and available CLI tools.
//
// As of MUL-3560 PR 0.5 the builder is a thin kind-aware dispatcher: each
// section of the brief lives in its own `writeXxx` helper (see
// runtime_config_sections.go), and the assembly order is driven by the
// taskKind classification (see runtime_config_kind.go). This PR is a
// pure refactor — output is byte-for-byte identical to the pre-refactor
// monolithic builder for every existing test fixture. The follow-up PR
// (0.6) will start gating sections per the Section × Kind matrix from
// Eve's MUL-3560 design comment (e.g. drop Mentions / Comment Formatting /
// Issue Metadata out of quick-create), with negative assertions added
// alongside each removal so each kind's brief contract becomes
// machine-checked.
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
var b strings.Builder
kind := classifyTask(ctx)
// === Always-on prelude ===
//
// Every kind starts with the same identity-and-actor-framing block:
// header, background-task-safety, agent identity, requesting user,
// task initiator, workspace context. Each helper internally suppresses
// itself when its preconditions are not met (e.g. RequestingUser does
// nothing on an empty profile description), so the prelude is uniform
// across kinds even though the rendered output varies.
writeHeader(&b)
writeBackgroundTaskSafetyInstructions(&b)
writeAgentIdentity(&b, ctx)
writeRequestingUser(&b, ctx)
writeTaskInitiator(&b, ctx)
writeWorkspaceContext(&b, ctx)
// === Available Commands + Comment Formatting ===
//
// In the pre-refactor builder both sections are unconditional. PR 0.5
// preserves that — the follow-up PR will replace Available Commands
// with a per-kind subset (quick-create gets only `issue create`,
// autopilot gets a read-only subset, etc.) and drop Comment Formatting
// for kinds that never post comments.
writeAvailableCommands(&b)
writeCommentFormatting(&b)
// === Conditional context sections ===
writeRepositories(&b, ctx)
writeProjectContext(&b, ctx)
// Issue Metadata: only kinds that operate on a real Multica issue
// (comment-triggered / assignment-triggered) can read or pin metadata,
// so we gate by hasIssueContext. Chat / quick-create / autopilot
// would otherwise just produce a guaranteed-failed `metadata list`
// call on every entry.
if kind.hasIssueContext() {
writeIssueMetadata(&b)
}
// Instruction Precedence: only assignment-triggered runs see the
// "agent identity wins over the assignment workflow" guardrail, since
// they are the only kind whose workflow auto-flips status / drives
// the full issue lifecycle. Other kinds' workflows are read/reply
// only.
if kind == kindAssignmentTriggered {
writeInstructionPrecedence(&b)
}
// === Workflow ===
//
// The Workflow heading is uniform across kinds; the body switches
// on the classified taskKind. Each case calls a single helper, so
// the matrix of "which workflow does each kind get" is auditable as
// one switch.
writeWorkflowHeader(&b)
switch kind {
case kindChat:
writeWorkflowChat(&b)
case kindQuickCreate:
writeWorkflowQuickCreate(&b)
case kindAutopilotRunOnly:
writeWorkflowAutopilot(&b, ctx)
case kindCommentTriggered:
writeWorkflowComment(&b, provider, ctx)
case kindAssignmentTriggered:
writeWorkflowAssignment(&b, ctx)
}
// === Trailing sections ===
//
// Sub-issue Creation is meaningful only for kinds with a parent /
// child relationship — i.e. those that operate on a real issue.
if kind.hasIssueContext() && ctx.IssueID != "" {
writeSubIssueCreation(&b)
}
writeSkills(&b, provider, ctx)
writeMentions(&b)
writeAttachments(&b)
writeAlwaysUseCLI(&b)
writeOutput(&b, kind, ctx)
return b.String()
}
func writeBackgroundTaskSafetyInstructions(b *strings.Builder) {
b.WriteString("## Background Task Safety\n\n")
b.WriteString("Multica marks this task terminal when your top-level agent process/turn exits. Any background work you started but did not collect before exiting can be orphaned: its result may be lost, and the user may see a completed/failed task even though the delegated work was never synthesized.\n\n")
b.WriteString("- Do NOT end your turn while background tasks, async subagents, background shell commands, or detached tool calls are still running.\n")
b.WriteString("- If a tool or runtime offers a background mode, use it only when you can explicitly wait for completion and collect the result before your final response.\n")
b.WriteString("- If a tool response says to wait for a future notification/reminder instead of collecting now, do not rely on that in Multica-managed runs. Block on the appropriate wait/output/collect operation before exiting.\n")
b.WriteString("- If you cannot observe or collect a background task's result, do not spawn it in the background; run the work synchronously instead.\n")
b.WriteString("- Before posting your final result or exiting silently, account for every background task you started and incorporate its output or failure into your response.\n\n")
}