mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* refactor(agent): drop unreachable inline system-prompt branches (MUL-5392) The daemon only populates ExecOptions.SystemPrompt for openclaw, kimi and traecli (providerNeedsInlineSystemPrompt); every other backend receives the runtime brief as a per-task context file in the workdir. The inline branches in claude, codex, opencode and pi were therefore dead, and read as if they were the live delivery path. Probed each backend over its real launch path with a canary in the context file and no inline delivery — claude 2.1.220 (CLAUDE.md), codex 0.144.6 via the app-server, opencode 1.17.7, pi 0.67.2, hermes 0.18.2 via ACP — and all of them picked the brief up from disk, so the branches were removable with no behaviour change. An empty workdir returned no canary, confirming the probe could fail. opencode's branch was worse than dead: `opencode run` has no --prompt flag, so enabling inline delivery there would have made every opencode task exit 1 with a usage dump. The DevEco backend, forked from opencode, already documents this constraint; opencode itself never got the fix. Regression tests pin all three arg builders against re-adding the flag, and providerNeedsInlineSystemPrompt now documents what was verified and what is still unprobed (grok, qoder, codebuddy). Hermes and kiro are untouched: their exclusion is deliberate and already tested. Co-authored-by: multica-agent <github@multica.ai> * test(agent): pin codex developerInstructions contract, drop stale pi flag doc Review follow-up on MUL-5392. buildPiArgs' doc comment still advertised --append-system-prompt after the branch that emitted it was removed — exactly the stale-signal this PR set out to delete. The two codex sites fixed to a literal nil had no regression test, so restoring nilIfEmpty(opts.SystemPrompt) would still have gone green. Both thread/start and thread/resume now run with a canary SystemPrompt and assert developerInstructions comes through as an explicit null. Mutation-checked: reverting either site fails its test. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <agent@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
377 lines
16 KiB
Go
377 lines
16 KiB
Go
// Package agent provides a unified interface for executing prompts via
|
|
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, DevEco Code,
|
|
// OpenClaw, Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder, Trae, Grok,
|
|
// Qwen Code). It
|
|
// mirrors the happy-cli AgentBackend pattern, translated to idiomatic Go.
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// Backend is the unified interface for executing prompts via coding agents.
|
|
type Backend interface {
|
|
// Execute runs a prompt and returns a Session for streaming results.
|
|
// The caller should read from Session.Messages (optional) and wait on
|
|
// Session.Result for the final outcome.
|
|
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
|
|
}
|
|
|
|
// ExecOptions configures a single execution.
|
|
type ExecOptions struct {
|
|
Cwd string
|
|
Model string
|
|
// SystemPrompt carries the Multica runtime brief for the few providers
|
|
// that cannot pick it up from disk. The daemon leaves it empty for every
|
|
// other provider (see daemon.providerNeedsInlineSystemPrompt), because the
|
|
// brief is already delivered as a per-task context file in the workdir —
|
|
// CLAUDE.md, AGENTS.md, CODEBUDDY.md or QWEN.md depending on the runtime.
|
|
//
|
|
// A backend must therefore NOT assume this is populated, and adding a new
|
|
// backend that only reads SystemPrompt will silently receive nothing.
|
|
SystemPrompt string
|
|
ThreadName string
|
|
MaxTurns int
|
|
Timeout time.Duration
|
|
SemanticInactivityTimeout time.Duration
|
|
// IdleWatchdogTimeout optionally narrows the daemon's generic no-message
|
|
// watchdog for this execution. Zero keeps the daemon-wide window, and a
|
|
// value above that window cannot extend the global safety bound. The
|
|
// daemon-wide zero still disables the watchdog entirely, and an in-flight
|
|
// tool continues to use the separate tool watchdog budget.
|
|
IdleWatchdogTimeout time.Duration
|
|
// HandshakeTimeout bounds startup RPCs for providers with a long-lived
|
|
// protocol transport. It is currently consumed by Codex app-server;
|
|
// zero uses the provider default rather than disabling the bound.
|
|
HandshakeTimeout time.Duration
|
|
ResumeSessionID string // if non-empty, resume a previous agent session
|
|
// ResumeExpected records that this task intended to continue a prior
|
|
// conversation, independent of ResumeSessionID (which a fallback retry may
|
|
// clear). When it is true but the backend ends up on a fresh thread — the
|
|
// live resume RPC was rejected, or a transport failure forced a fresh retry —
|
|
// the backend surfaces a continuity notice to the user instead of silently
|
|
// restarting. Currently honoured by the codex backend (MUL-4424).
|
|
ResumeExpected bool
|
|
ExtraArgs []string // daemon-wide default CLI arguments appended before CustomArgs; currently read by claude and codex backends only
|
|
CustomArgs []string // per-agent CLI arguments appended after ExtraArgs
|
|
McpConfig json.RawMessage // if non-nil, MCP server config to pass via --mcp-config
|
|
// ThinkingLevel is the runtime-native reasoning/effort value (e.g.
|
|
// Claude's "low|medium|high|xhigh|max", Codex's "none|minimal|low|
|
|
// medium|high|xhigh", OpenCode's model variant names). Empty means
|
|
// "use the runtime/model default" —
|
|
// every backend that consumes this skips its --effort / reasoning_effort
|
|
// injection so the upstream CLI's own default applies. Currently honoured
|
|
// by the claude, codex, opencode, codebuddy, and grok (ACP
|
|
// `--effort` on `grok agent`) backends; other backends ignore
|
|
// the field rather than fail (so MUL-2339 can grow runtime support
|
|
// incrementally without breaking unrelated agents).
|
|
ThinkingLevel string
|
|
// ServiceTier is a runtime-native Codex execution tier (for example
|
|
// "priority", displayed as Fast). Empty means inherit local Codex config.
|
|
// Other providers ignore this field.
|
|
ServiceTier string
|
|
// OpenclawMode chooses between local (embedded) and gateway routing for
|
|
// the openclaw backend. "" or "local" keeps the historical behaviour —
|
|
// the daemon spawns `openclaw agent --local …` and the agent loop runs
|
|
// in-process on the daemon host. "gateway" instructs the daemon to drop
|
|
// the --local flag and let openclaw route the turn through a Gateway (the
|
|
// user's globally-configured one, or an endpoint pinned in the per-task
|
|
// config wrapper that the daemon writes from execenv.OpenclawGatewayPin —
|
|
// see server/internal/daemon/execenv/openclaw_config.go). Other backends
|
|
// ignore this field, mirroring ThinkingLevel's renderer-side fall-through
|
|
// pattern. See issue #3260.
|
|
OpenclawMode string
|
|
// ClaudeSettingsPath is a daemon-owned, task-local settings file passed
|
|
// through Claude Code's --settings flag. It currently carries restrictive
|
|
// runtime-skill overrides only; other providers ignore it.
|
|
ClaudeSettingsPath string
|
|
}
|
|
|
|
// runContext derives the execution context for an agent subprocess from the
|
|
// configured per-run timeout. A positive timeout imposes a hard wall-clock
|
|
// deadline; a zero (or negative) timeout imposes NO deadline, leaving liveness
|
|
// entirely to the daemon's inactivity watchdog so a session that keeps emitting
|
|
// events is never killed merely for running long (MUL-3064). The caller owns
|
|
// the returned CancelFunc and must call it to release resources.
|
|
func runContext(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
|
if timeout > 0 {
|
|
return context.WithTimeout(ctx, timeout)
|
|
}
|
|
return context.WithCancel(ctx)
|
|
}
|
|
|
|
// Session represents a running agent execution.
|
|
type Session struct {
|
|
// Messages streams events as the agent works. The channel is closed
|
|
// when the agent finishes (before Result is sent).
|
|
Messages <-chan Message
|
|
// Result receives exactly one value — the final outcome — then closes.
|
|
Result <-chan Result
|
|
}
|
|
|
|
// MessageType identifies the kind of Message.
|
|
type MessageType string
|
|
|
|
const (
|
|
MessageText MessageType = "text"
|
|
MessageThinking MessageType = "thinking"
|
|
MessageToolUse MessageType = "tool-use"
|
|
MessageToolResult MessageType = "tool-result"
|
|
MessageStatus MessageType = "status"
|
|
MessageError MessageType = "error"
|
|
MessageLog MessageType = "log"
|
|
)
|
|
|
|
// Message is a unified event emitted by an agent during execution.
|
|
type Message struct {
|
|
Type MessageType
|
|
Content string // text content (Text, Error, Log)
|
|
Tool string // tool name (ToolUse, ToolResult)
|
|
CallID string // tool call ID (ToolUse, ToolResult)
|
|
Input map[string]any // tool input (ToolUse)
|
|
Output string // tool output (ToolResult)
|
|
Status string // agent status string (Status)
|
|
Level string // log level (Log)
|
|
SessionID string // backend session id (Status), for early resume-pointer pinning
|
|
}
|
|
|
|
// TokenUsage tracks token consumption for a single model.
|
|
type TokenUsage struct {
|
|
InputTokens int64
|
|
OutputTokens int64
|
|
CacheReadTokens int64
|
|
CacheWriteTokens int64
|
|
// CostUSDTicks is the provider's own statement of what this usage cost,
|
|
// in ticks of 1e-10 USD. Zero means "not reported" — only a few agents
|
|
// return it (xAI Grok Build does, via `_meta.usage.costUsdTicks`).
|
|
//
|
|
// It matters because a token-times-rate estimate cannot reproduce
|
|
// request-level pricing rules. xAI bills a request at 2x once its prompt
|
|
// reaches 200K tokens, and a usage record aggregates every model call in
|
|
// a turn — so the stored token counts cannot say which tier any single
|
|
// request hit. The provider's own figure already has that priced in.
|
|
CostUSDTicks int64
|
|
}
|
|
|
|
// CostUSDTicksPerUSD is the scale of the provider-reported cost unit: xAI
|
|
// reports whole ticks of 1e-10 USD, which keeps sub-cent turn costs exact in
|
|
// int64 all the way to the database instead of drifting through float64.
|
|
const CostUSDTicksPerUSD = 10_000_000_000
|
|
|
|
// Result is the final outcome after an agent session completes.
|
|
type Result struct {
|
|
Status string // "completed", "failed", "aborted", "timeout", "cancelled"
|
|
Output string // final user-facing output selected by the backend
|
|
Error string // error message if failed
|
|
DurationMs int64
|
|
SessionID string
|
|
Usage map[string]TokenUsage // keyed by model name
|
|
// ResumeRejected is positive evidence that this run's requested resume
|
|
// was itself refused — the transcript is gone, the session belongs to
|
|
// another provider account, OR the session still exists but its history
|
|
// can no longer be replayed to the provider (e.g. GH #5975: a stored
|
|
// image now exceeds the provider's max dimensions, so every resumed
|
|
// session/prompt is rejected before the turn runs). What unites these is
|
|
// that the resume CANNOT continue and only starting over can cure it, so
|
|
// it is what the daemon's fresh-session fallback looks for first. Note the
|
|
// last case keeps a non-empty SessionID (the id is real, only its history
|
|
// is unusable) — the daemon gates on this boolean, not an empty id.
|
|
//
|
|
// false is NOT evidence of the opposite. For a backend listed in
|
|
// ResumeRejectionUndetectable it means "could not tell"; for every other
|
|
// backend it means "checked, and this was not a rejection". The daemon
|
|
// needs the provider name to tell those apart — see
|
|
// shouldRetryWithFreshSession in internal/daemon.
|
|
//
|
|
// Backends must NOT set it for failures a new session cannot cure:
|
|
// network drops, rate limits, quota, provider 5xx, or auth errors. Those
|
|
// keep the session pointer so the platform's own retry can resume the
|
|
// truncated conversation (see retryableReasons in internal/service/task.go).
|
|
ResumeRejected bool
|
|
// codexInitializeRetrySafe is provider-internal evidence that an
|
|
// initialize timeout happened before semantic activity and after the
|
|
// process tree was reaped. It is intentionally not part of the public
|
|
// result contract.
|
|
codexInitializeRetrySafe bool
|
|
// codexStartupRefreshRetrySafe is provider-internal evidence that the
|
|
// first turn produced no semantic progress because Codex could not load
|
|
// its model catalog, and that the process tree was reaped afterwards.
|
|
// Like codexInitializeRetrySafe it is not part of the public contract.
|
|
codexStartupRefreshRetrySafe bool
|
|
}
|
|
|
|
// Config configures a Backend instance.
|
|
type Config struct {
|
|
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli, traecli, grok, qwen)
|
|
CLIVersion string // detected version paired with ExecutablePath; observation only, never used to choose behavior
|
|
Env map[string]string // extra environment variables
|
|
Logger *slog.Logger
|
|
TaskID string
|
|
RuntimeID string
|
|
DaemonVersion string
|
|
CodexVersion string
|
|
}
|
|
|
|
// New creates a Backend for the given agent type.
|
|
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "deveco", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder", "traecli", "grok", "qwen".
|
|
//
|
|
// SupportedTypes is the canonical whitelist of agent types eligible to back a
|
|
// custom runtime profile. It MUST stay in lockstep with the
|
|
// runtime_profile.protocol_family CHECK constraint (migration 120, widened by
|
|
// migration 134 to add qoder, migration 136 to add traecli, migration 175 to
|
|
// add deveco, migration 179 to add grok, and migration 202 to add qwen): a
|
|
// custom runtime profile may only
|
|
// be based on a backend Multica officially supports.
|
|
// qoder is exposed here so Qoder CN (`qoderclicn`) users can point the Qoder
|
|
// backend at a non-default binary instead of misrouting through Kiro/ACP with
|
|
// incompatible arguments (#4883). traecli (Trae) has a New backend, launch
|
|
// header and provider branding but was previously missing from this whitelist,
|
|
// so the family picker rejected it (#4945). grok is the xAI Grok Build CLI
|
|
// ACP backend (`grok agent --always-approve stdio`). qwen is Qwen Code's
|
|
// native `qwen -p <prompt> --output-format stream-json` backend.
|
|
var SupportedTypes = []string{
|
|
"claude",
|
|
"codebuddy",
|
|
"codex",
|
|
"copilot",
|
|
"opencode",
|
|
"deveco",
|
|
"openclaw",
|
|
"hermes",
|
|
"pi",
|
|
"cursor",
|
|
"kimi",
|
|
"kiro",
|
|
"antigravity",
|
|
"qoder",
|
|
"traecli",
|
|
"grok",
|
|
"qwen",
|
|
}
|
|
|
|
// IsSupportedType reports whether agentType is in the SupportedTypes whitelist.
|
|
// Used to validate a custom runtime profile's protocol_family before it is
|
|
// persisted or registered.
|
|
func IsSupportedType(agentType string) bool {
|
|
for _, t := range SupportedTypes {
|
|
if t == agentType {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resumeRejectionUndetectable lists the backends that cannot produce
|
|
// Result.ResumeRejected at all. They scrape SessionID out of stream output and
|
|
// have no rejection detection: no phrase match, no structured error code, no
|
|
// internal restart. copilot's own comment documents the hole (a session.error
|
|
// arriving before session.start leaves SessionID empty), and antigravity's
|
|
// conversation-id reader returns "" whenever the CLI exits before dispatching.
|
|
//
|
|
// Membership is deliberately opt-in. A backend absent from this map is treated
|
|
// as capable, so a new backend fails closed — it reports no rejection and gets
|
|
// no fallback — rather than silently inheriting a guess-based retry. Remove an
|
|
// entry as soon as its backend learns to report rejections.
|
|
var resumeRejectionUndetectable = map[string]bool{
|
|
"antigravity": true,
|
|
"copilot": true,
|
|
"cursor": true,
|
|
"deveco": true,
|
|
"opencode": true,
|
|
}
|
|
|
|
// ResumeRejectionUndetectable reports whether agentType is a backend that
|
|
// cannot tell a refused resume from any other startup failure. Callers use it
|
|
// to read a false Result.ResumeRejected correctly: "could not tell" for these,
|
|
// "checked, not a rejection" for everything else.
|
|
func ResumeRejectionUndetectable(agentType string) bool {
|
|
return resumeRejectionUndetectable[agentType]
|
|
}
|
|
|
|
func New(agentType string, cfg Config) (Backend, error) {
|
|
if cfg.Logger == nil {
|
|
cfg.Logger = slog.Default()
|
|
}
|
|
|
|
switch agentType {
|
|
case "claude":
|
|
return &claudeBackend{cfg: cfg}, nil
|
|
case "codebuddy":
|
|
return &codebuddyBackend{cfg: cfg}, nil
|
|
case "codex":
|
|
return &codexBackend{cfg: cfg}, nil
|
|
case "copilot":
|
|
return &copilotBackend{cfg: cfg}, nil
|
|
case "opencode":
|
|
return &opencodeBackend{cfg: cfg}, nil
|
|
case "deveco":
|
|
return &devecoBackend{cfg: cfg}, nil
|
|
case "openclaw":
|
|
return &openclawBackend{cfg: cfg}, nil
|
|
case "hermes":
|
|
return &hermesBackend{cfg: cfg}, nil
|
|
case "pi":
|
|
return &piBackend{cfg: cfg}, nil
|
|
case "cursor":
|
|
return &cursorBackend{cfg: cfg}, nil
|
|
case "kimi":
|
|
return &kimiBackend{cfg: cfg}, nil
|
|
case "kiro":
|
|
return &kiroBackend{cfg: cfg}, nil
|
|
case "antigravity":
|
|
return &antigravityBackend{cfg: cfg}, nil
|
|
case "qoder":
|
|
return &qoderBackend{cfg: cfg}, nil
|
|
case "traecli":
|
|
return &traecliBackend{cfg: cfg}, nil
|
|
case "grok":
|
|
return &grokBackend{cfg: cfg}, nil
|
|
case "qwen":
|
|
return &qwenBackend{cfg: cfg}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, deveco, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder, traecli, grok, qwen)", agentType)
|
|
}
|
|
}
|
|
|
|
// DetectVersion runs the agent CLI with --version and returns the output.
|
|
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
|
|
return detectCLIVersion(ctx, executablePath)
|
|
}
|
|
|
|
// launchHeaders maps each supported agent type to the user-visible skeleton
|
|
// that the daemon spawns before any custom_args are appended. This is
|
|
// intentionally minimal — only the command + subcommand (or a short mode
|
|
// label when there is no subcommand). Internal flags, transport values, and
|
|
// environment variables are deliberately omitted so the string is a hint
|
|
// about *what* users are extending, not a dump of the full command line.
|
|
var launchHeaders = map[string]string{
|
|
"antigravity": "agy -p (non-interactive)",
|
|
"claude": "claude (stream-json)",
|
|
"codebuddy": "codebuddy (stream-json)",
|
|
"codex": "codex app-server",
|
|
"copilot": "copilot (json)",
|
|
"cursor": "cursor-agent (stream-json)",
|
|
"deveco": "deveco run (json)",
|
|
"hermes": "hermes acp",
|
|
"kimi": "kimi acp",
|
|
"kiro": "kiro-cli acp",
|
|
"openclaw": "openclaw agent (json)",
|
|
"opencode": "opencode run (json)",
|
|
"pi": "pi (json mode)",
|
|
"qoder": "qodercli --acp",
|
|
"traecli": "traecli acp serve",
|
|
"grok": "grok agent stdio",
|
|
"qwen": "qwen -p (stream-json)",
|
|
}
|
|
|
|
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
|
|
// empty string if the type is unknown. Callers render this as a preview so
|
|
// users understand which command their custom_args get appended to.
|
|
func LaunchHeader(agentType string) string {
|
|
return launchHeaders[agentType]
|
|
}
|