mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-13 05:16:29 +02:00
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path. - execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions. - Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go. - Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps). - Update tests to slim-only expectations and docs/feature-flags.md. Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
380 lines
17 KiB
Go
380 lines
17 KiB
Go
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). 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"`
|
|
Ref string `json:"ref,omitempty"`
|
|
}
|
|
_ = json.Unmarshal(r.ResourceRef, &payload)
|
|
out := fmt.Sprintf("**GitHub repo**: %s", payload.URL)
|
|
details := make([]string, 0, 2)
|
|
if payload.Ref != "" {
|
|
details = append(details, fmt.Sprintf("checkout ref: `%s`", payload.Ref))
|
|
}
|
|
if payload.DefaultBranchHint != "" {
|
|
details = append(details, fmt.Sprintf("default branch hint: `%s`", payload.DefaultBranchHint))
|
|
}
|
|
if len(details) > 0 {
|
|
out += " (" + strings.Join(details, ", ") + ")"
|
|
}
|
|
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 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)
|
|
// For Traecli: writes {workDir}/AGENTS.md (traecli reads .trae/rules/ not AGENTS.md, so the brief is delivered inline via providerNeedsInlineSystemPrompt; the file is written for parity/visibility only)
|
|
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", "traecli":
|
|
return filepath.Join(workDir, "AGENTS.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 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` 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.
|
|
//
|
|
// The brief is assembled by buildMetaSkillContentSlim (runtime_config_sections.go),
|
|
// which applies kind-driven section gating + per-section prose compression.
|
|
// This used to be gated behind the `runtime_brief_slim` feature flag against a
|
|
// legacy verbose brief; the flag has been retired (MUL-4297) and the slim brief
|
|
// is now the only path.
|
|
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
|
return buildMetaSkillContentSlim(provider, ctx)
|
|
}
|