mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* MUL-3284: add runtime_profile schema (custom runtime PR1) Schema-only foundation for custom runtimes. Additive migration 120: - New workspace-level `runtime_profile` table: the shared, team-visible definition of a custom runtime (e.g. an in-house Codex wrapper). protocol_family is CHECK-constrained to the exact backend list in agent.New() (server/pkg/agent/agent.go). The only args column is `fixed_args` (args every agent on the runtime must inherit); there is deliberately no generic per-agent args field — those stay on agent.custom_args. - `agent_runtime.profile_id` (nullable, FK -> runtime_profile ON DELETE CASCADE): NULL = built-in runtime, non-NULL = a registered instance of a custom profile. - Partial unique index agent_runtime_workspace_daemon_profile_key on (workspace_id, daemon_id, profile_id) WHERE profile_id IS NOT NULL. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint is left INTACT so the existing registration upsert (ON CONFLICT (workspace_id, daemon_id, provider) in runtime.sql) keeps resolving its arbiter and the server stays green. Converting that key to a partial (WHERE profile_id IS NULL) index and making the upsert profile-aware is PR2's registration work, not this migration. Verified up + down against Postgres 17: full `migrate up` applies 120; schema shows the table, column, partial index and intact legacy constraint; functional checks pass (partial index blocks dup (ws,daemon,profile), allows same profile on another daemon; CHECK and display_name uniqueness reject bad input; legacy ON CONFLICT still resolves; profile delete cascades to instances); down/up round-trip is clean. Co-authored-by: multica-agent <github@multica.ai> * MUL-3284: drop DB FKs/cascade from runtime_profile migration (review fix) Per review (house rule: no new database foreign keys / cascades; relational integrity lives in the application layer): - runtime_profile.workspace_id: drop REFERENCES workspace ON DELETE CASCADE -> plain UUID NOT NULL. - runtime_profile.created_by: drop REFERENCES "user" ON DELETE SET NULL -> plain UUID. - agent_runtime.profile_id: drop REFERENCES runtime_profile ON DELETE CASCADE -> plain UUID. CHECK constraints, UNIQUE (workspace_id, display_name), the workspace index, and the partial unique index agent_runtime_workspace_daemon_profile_key are unchanged. The legacy UNIQUE (workspace_id, daemon_id, provider) constraint remains untouched. Behavioral consequence: the database no longer auto-removes a profile's agent_runtime instance rows on profile delete. That cleanup moves into PR2's profile-delete path. Up-migration comments document this; down-migration comment no longer references FKs/cascade. Re-verified on Postgres 17: migrate up applies 120; no FK constraints exist on the new columns; partial index still blocks dup (ws,daemon,profile_id); CHECK and display_name uniqueness still reject bad input; deleting a profile now leaves the runtime row orphaned (proving cascade is gone); down/up round-trip clean with the legacy constraint intact. Co-authored-by: multica-agent <github@multica.ai> * MUL-3284 PR2 (server): runtime_profile CRUD + profile-aware registration Server/DB half of the custom-runtime feature. - Migration 121: convert the legacy UNIQUE (workspace_id, daemon_id, provider) constraint on agent_runtime into a partial unique index scoped to built-in rows (WHERE profile_id IS NULL). With 120's partial index on profile_id this lets one daemon host the built-in provider AND custom profiles of the same protocol family without collision. - Queries: runtime_profile CRUD; ListEnabledRuntimeProfilesForWorkspace (daemon-facing); CountAgentsByProfile + DeleteAgentRuntimesByProfile for the app-layer cascade; profile-aware UpsertAgentRuntimeWithProfile; the built-in UpsertAgentRuntime ON CONFLICT now spells out WHERE profile_id IS NULL so it targets the right partial index. sqlc regenerated. - agent.SupportedTypes / IsSupportedType: single-source protocol_family whitelist, in lockstep with agent.New and the migration 120 CHECK. - Handlers + routes: runtime_profile CRUD (member-read, admin-write) with protocol_family whitelist validation, display_name uniqueness (409), and fixed_args validation (no generic per-agent args — iron rule); a daemon-token endpoint GET /api/daemon/workspaces/{id}/runtime-profiles; DeleteRuntimeProfile does the app-layer cascade (delete instance rows then profile, in one tx) and refuses (409) while active agents are bound. - DaemonRegister accepts an optional per-runtime profile_id: validates the profile belongs to the workspace and is enabled, registers via the profile-aware upsert, and skips legacy hostname merge for custom rows. AgentRuntimeResponse now carries profile_id. Verified on Postgres 17: migrate up through 121; built-in + custom codex coexist on one daemon; both upsert arbiters are idempotent; delete-by-profile cascade removes only the custom instance; migrate down reverses 121 then 120 and replays clean. go build ./... and go vet pass; handler test package compiles. Daemon-side wiring (fetch profiles, PATH-resolve command_name, register with profile_id, exec uses command_name) lands in a follow-up commit on this branch. Co-authored-by: multica-agent <github@multica.ai> * MUL-3284 PR2 (daemon): pull profiles, PATH-resolve, register, exec command Daemon-side half of custom runtime profiles, against the server contract on this branch. - client.go: GetRuntimeProfiles(workspaceID) -> GET /api/daemon/workspaces/{id}/runtime-profiles (mirrors GetWorkspaceRepos); RuntimeProfile / RuntimeProfilesResponse types. - types.go: Runtime gains profile_id (parsed from the register response so runtimeIndex carries it). - daemon.go: * appendProfileRuntimes — called inside registerRuntimesForWorkspace before the empty-runtimes guard. Best-effort fetch (older server 404s are logged and swallowed; never fails registration). Per enabled profile: resolve command_name via PATH (exec.LookPath, behind a `lookPath` test hook), skip+log when absent, best-effort version probe, record the resolved absolute path keyed by profile_id, and append a registration entry {name, type=protocol_family, version, status:online, profile_id}. A custom-only host (no built-in agents) still registers. * profileCommandPaths map (guarded by d.mu) + recordProfileCommandPath / customCommandPathForRuntime helpers. * runTask: looks up the claimed task's RuntimeID -> profile command path and overrides the executable path, synthesizing an AgentEntry so a custom runtime runs even when the host has no built-in agent of the same provider. provider (=protocol_family) is unchanged so agent.New still selects the right backend. - Tests: GetRuntimeProfiles request shape; profile runtime appended + path recorded (custom-only host); profile skipped when command not on PATH; profiles-fetch-404 is best-effort; customCommandPathForRuntime bookkeeping. - agent: lockstep test pinning SupportedTypes to agent.New and the migration 120 protocol_family CHECK. Iron rule honored: profile carries no generic per-agent args. fixed_args are parsed and carried but intentionally NOT wired into the launch command yet (optional/best-effort; explicit TODO(MUL-3284) in appendProfileRuntimes). Verified: go build ./... clean; go vet ./internal/daemon/... clean; go test ./internal/daemon/... pass (existing + 5 new); full go test ./internal/handler/ suite passes against a migrated Postgres 17; agent lockstep test passes. Co-authored-by: multica-agent <github@multica.ai> * MUL-3284 PR2: profile delete runs full archived-agent cascade (fix 500) Review fix. DeleteRuntimeProfile previously guarded only on ACTIVE agents, but agent.runtime_id is ON DELETE RESTRICT — a profile whose runtimes had only ARCHIVED agents passed the guard, then DeleteAgentRuntimesByProfile hit the FK and the handler 500'd. Now it mirrors the mature runtime-delete cascade (DeleteAgentRuntime): in one transaction it enumerates the profile's runtime rows, refuses (409) any with active agents or active squads led by archived agents, then for each runtime pauses autopilots pinned to its archived agents, drops archived squads led by them, and hard-deletes the archived agents before removing the runtime rows and the profile. No code path can now fall through to a raw FK error. - queries: ListAgentRuntimeIDsByProfile (sqlc regen). Reuses the existing per-runtime teardown queries (CountActiveSquadsWithArchivedLeadersByRuntime, ListArchivedAgentIDsByRuntime, PauseAutopilotsByAgentAssignees, DeleteSquadsByArchivedAgentsOnRuntime, DeleteArchivedAgentsByRuntime). - tests: TestDeleteRuntimeProfile_ArchivedAgentCascade (archived-only profile deletes cleanly: 204, runtime + archived agent + profile gone) and TestDeleteRuntimeProfile_ActiveAgentBlocks (active agent → 409, survives). Verified against Postgres 17: both new tests pass; full handler suite, daemon tests, and agent lockstep test pass; go vet clean. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
240 lines
9.2 KiB
Go
240 lines
9.2 KiB
Go
// Package agent provides a unified interface for executing prompts via
|
|
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, OpenClaw,
|
|
// Hermes, Gemini, Pi, Cursor, Kimi, Kiro, Antigravity). 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 is consumed only by providers that can pass or safely inline
|
|
// developer/system instructions. Hermes ACP intentionally ignores it and
|
|
// relies on cwd-scoped context files such as AGENTS.md instead.
|
|
SystemPrompt string
|
|
ThreadName string
|
|
MaxTurns int
|
|
Timeout time.Duration
|
|
SemanticInactivityTimeout time.Duration
|
|
ResumeSessionID string // if non-empty, resume a previous agent session
|
|
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, and opencode backends; other backends ignore the
|
|
// field rather than fail (so MUL-2339 can grow runtime support
|
|
// incrementally without breaking unrelated agents).
|
|
ThinkingLevel 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Result is the final outcome after an agent session completes.
|
|
type Result struct {
|
|
Status string // "completed", "failed", "aborted", "timeout", "cancelled"
|
|
Output string // accumulated text output
|
|
Error string // error message if failed
|
|
DurationMs int64
|
|
SessionID string
|
|
Usage map[string]TokenUsage // keyed by model name
|
|
}
|
|
|
|
// Config configures a Backend instance.
|
|
type Config struct {
|
|
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli, agy)
|
|
Env map[string]string // extra environment variables
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// New creates a Backend for the given agent type.
|
|
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro", "antigravity".
|
|
//
|
|
// SupportedTypes is the canonical whitelist of agent types New can construct.
|
|
// It MUST stay in lockstep with the switch in New below and the
|
|
// runtime_profile.protocol_family CHECK constraint (migration 120): a custom
|
|
// runtime profile may only be based on a backend Multica officially supports.
|
|
var SupportedTypes = []string{
|
|
"claude",
|
|
"codebuddy",
|
|
"codex",
|
|
"copilot",
|
|
"opencode",
|
|
"openclaw",
|
|
"hermes",
|
|
"gemini",
|
|
"pi",
|
|
"cursor",
|
|
"kimi",
|
|
"kiro",
|
|
"antigravity",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 "openclaw":
|
|
return &openclawBackend{cfg: cfg}, nil
|
|
case "hermes":
|
|
return &hermesBackend{cfg: cfg}, nil
|
|
case "gemini":
|
|
return &geminiBackend{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
|
|
default:
|
|
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity)", 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 (print mode)",
|
|
"claude": "claude (stream-json)",
|
|
"codebuddy": "codebuddy (stream-json)",
|
|
"codex": "codex app-server",
|
|
"copilot": "copilot (json)",
|
|
"cursor": "cursor-agent (stream-json)",
|
|
"gemini": "gemini (stream-json)",
|
|
"hermes": "hermes acp",
|
|
"kimi": "kimi acp",
|
|
"kiro": "kiro-cli acp",
|
|
"openclaw": "openclaw agent (json)",
|
|
"opencode": "opencode run (json)",
|
|
"pi": "pi (json mode)",
|
|
}
|
|
|
|
// 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]
|
|
}
|