Files
multica/server/internal/handler/agent_runtime_skills.go
Jiayuan Zhang 5d9295ac65 feat(agents): add per-agent runtime skill controls (#5686)
* feat(agents): add per-agent runtime skill controls

Co-authored-by: multica-agent <github@multica.ai>

* fix(agents): renumber runtime-skill migration and broadcast agent:status on toggle

Address the MUL-5101 review blockers on PR #5686:

- Rebase onto main and renumber the runtime-skill-disable migration
  202 -> 203. main added 202_runtime_profile_add_qwen, so the pair
  collided on prefix 202 and migrations_lint_test would reject the
  duplicate. 203 is the next free prefix.
- Publish an "agent:status" event after persisting a
  disabled_runtime_skills override, mirroring the workspace-skill toggle
  in writeUpdatedAgentSkills. The realtime layer keys off this event to
  invalidate workspaceKeys.agents, so other open web/desktop/mobile
  clients now drop their stale toggle state instead of only the
  initiating tab refreshing. Reload junction-table skills before the
  broadcast so it doesn't signal cleared skills (#3459).
- Add a handler regression test proving the broadcast fires on both
  disable and enable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:38:38 +08:00

211 lines
7.1 KiB
Go

package handler
import (
"encoding/json"
"log/slog"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/logger"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
const maxRuntimeSkillKeyLength = 512
// DisabledRuntimeSkill identifies one runtime-local skill that an agent must
// not inherit. RuntimeID scopes the choice to a single machine/runtime so a
// same-named skill on another runtime is unaffected.
type DisabledRuntimeSkill struct {
RuntimeID string `json:"runtime_id"`
Provider string `json:"provider"`
Root string `json:"root"`
Key string `json:"key"`
Name string `json:"name,omitempty"`
Plugin string `json:"plugin,omitempty"`
}
func decodeDisabledRuntimeSkills(raw []byte) []DisabledRuntimeSkill {
if len(raw) == 0 {
return []DisabledRuntimeSkill{}
}
var skills []DisabledRuntimeSkill
if err := json.Unmarshal(raw, &skills); err != nil || skills == nil {
return []DisabledRuntimeSkill{}
}
return skills
}
func disabledRuntimeSkillsFor(raw []byte, runtimeID, provider string) []DisabledRuntimeSkill {
all := decodeDisabledRuntimeSkills(raw)
result := make([]DisabledRuntimeSkill, 0, len(all))
for _, skill := range all {
if skill.RuntimeID == runtimeID && skill.Provider == provider {
result = append(result, skill)
}
}
return result
}
func normalizeRuntimeSkillIdentity(root, key, plugin string) (string, string, string, bool) {
root = strings.TrimSpace(root)
key = strings.TrimSpace(key)
plugin = strings.TrimSpace(plugin)
if len(key) == 0 || len(key) > maxRuntimeSkillKeyLength {
return "", "", "", false
}
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(key)))
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", "", "", false
}
switch root {
case "provider", "universal":
plugin = ""
case "plugin":
if plugin == "" {
return "", "", "", false
}
default:
return "", "", "", false
}
return root, cleaned, plugin, true
}
func sameDisabledRuntimeSkill(a, b DisabledRuntimeSkill) bool {
return a.RuntimeID == b.RuntimeID && a.Provider == b.Provider &&
a.Root == b.Root && a.Key == b.Key && a.Plugin == b.Plugin
}
// SetAgentRuntimeSkillEnabled persists a per-agent override for a skill that
// is discovered from the agent's currently assigned local runtime.
func (h *Handler) SetAgentRuntimeSkillEnabled(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, agentID)
if !ok {
return
}
if !h.canManageAgent(w, r, agent) {
return
}
var req struct {
RuntimeID string `json:"runtime_id"`
Root string `json:"root"`
Key string `json:"key"`
Name string `json:"name"`
Plugin string `json:"plugin"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "runtime_id, root, key, and enabled are required")
return
}
runtimeID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
if !ok {
return
}
if !agent.RuntimeID.Valid || agent.RuntimeID != runtimeID {
writeError(w, http.StatusConflict, "agent is no longer assigned to this runtime")
return
}
rt, err := h.Queries.GetAgentRuntime(r.Context(), runtimeID)
if err != nil || rt.WorkspaceID != agent.WorkspaceID {
writeError(w, http.StatusNotFound, "runtime not found")
return
}
if rt.RuntimeMode != "local" || (rt.Provider != "codex" && rt.Provider != "claude") {
writeError(w, http.StatusBadRequest, "runtime skill controls are only supported for codex and claude")
return
}
root, key, plugin, valid := normalizeRuntimeSkillIdentity(req.Root, req.Key, req.Plugin)
if !valid || (root == "plugin" && rt.Provider != "claude") {
writeError(w, http.StatusBadRequest, "invalid runtime skill identity")
return
}
name := strings.TrimSpace(req.Name)
if len(name) > maxRuntimeSkillKeyLength {
writeError(w, http.StatusBadRequest, "invalid runtime skill name")
return
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to begin transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
locked, err := qtx.GetAgentForUpdate(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load agent")
return
}
if !locked.RuntimeID.Valid || locked.RuntimeID != runtimeID {
writeError(w, http.StatusConflict, "agent is no longer assigned to this runtime")
return
}
target := DisabledRuntimeSkill{
RuntimeID: req.RuntimeID,
Provider: rt.Provider,
Root: root,
Key: key,
Name: name,
Plugin: plugin,
}
current := decodeDisabledRuntimeSkills(locked.DisabledRuntimeSkills)
next := make([]DisabledRuntimeSkill, 0, len(current)+1)
for _, skill := range current {
if sameDisabledRuntimeSkill(skill, target) {
continue
}
next = append(next, skill)
}
if !*req.Enabled {
next = append(next, target)
}
payload, err := json.Marshal(next)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encode runtime skill settings")
return
}
if _, err := qtx.UpdateAgentDisabledRuntimeSkills(r.Context(), db.UpdateAgentDisabledRuntimeSkillsParams{
ID: locked.ID,
DisabledRuntimeSkills: payload,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update runtime skill")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit")
return
}
// Broadcast the updated agent so every other open web/desktop/mobile client
// invalidates its cached agent and picks up the new disabled_runtime_skills
// state. The workspace-skill toggle does the same through
// writeUpdatedAgentSkills (skill.go); the realtime layer keys off the
// "agent:status" event to invalidate workspaceKeys.agents. Without this only
// the initiating tab refreshes and other clients keep showing stale toggles.
locked.DisabledRuntimeSkills = payload
resp := agentToResponse(locked)
if err := h.enrichAgentResponseWithTargets(r.Context(), &resp, locked.ID); err != nil {
slog.Warn("runtime skill toggle: load invocation targets for broadcast failed",
append(logger.RequestAttrs(r), "error", err, "agent_id", agentID)...)
}
// agentToResponse initialises Skills as []; reload the junction-table
// bindings so the broadcast mirrors reality instead of signalling other
// clients that this agent's skills were cleared (#3459).
if err := h.attachAgentSkills(r.Context(), &resp, locked.ID); err != nil {
slog.Warn("runtime skill toggle: load agent skills for broadcast failed",
append(logger.RequestAttrs(r), "error", err, "agent_id", agentID)...)
}
actorType, actorID := h.resolveActor(r, requestUserID(r), uuidToString(locked.WorkspaceID))
h.publish(protocol.EventAgentStatus, uuidToString(locked.WorkspaceID), actorType, actorID,
map[string]any{"agent": broadcastAgentResponse(resp)})
w.WriteHeader(http.StatusNoContent)
}