Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions server/internal/agentpolicy/policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package agentpolicy

import "encoding/json"

const (
ModeOperatorControlled = "operator_controlled"

CommandIssueCreate = "issue.create"
CommandIssueUpdateStatus = "issue.update.status"
CommandIssueStatus = "issue.status"
CommandIssueUpdateAssignee = "issue.update.assignee"
CommandIssueAssign = "issue.assign"
)

// Policy is the Multica command policy embedded in agent.runtime_config.
type Policy struct {
Mode string `json:"mode"`
DenyCommands []string `json:"deny_commands"`
DenyAgentMentions *bool `json:"deny_agent_mentions"`
AllowPlainComment *bool `json:"allow_comment_without_agent_mentions"`
}

type runtimeConfig struct {
MulticaPolicy *Policy `json:"multica_policy"`
}

// FromRuntimeConfig extracts the Multica command policy from agent.runtime_config.
// Invalid or empty JSON is treated as no policy so legacy agents keep working.
func FromRuntimeConfig(raw []byte) Policy {
if len(raw) == 0 {
return Policy{}
}
var cfg runtimeConfig
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.MulticaPolicy == nil {
return Policy{}
}
return *cfg.MulticaPolicy
}

func (p Policy) IsOperatorControlled() bool {
return p.Mode == ModeOperatorControlled
}

func (p Policy) DeniesCommand(command string) bool {
for _, denied := range p.DenyCommands {
if denied == command {
return true
}
}
if !p.IsOperatorControlled() {
return false
}
switch command {
case CommandIssueCreate,
CommandIssueUpdateStatus,
CommandIssueStatus,
CommandIssueUpdateAssignee,
CommandIssueAssign:
return true
default:
return false
}
}

func (p Policy) DeniesAnyCommand(commands ...string) bool {
for _, command := range commands {
if p.DeniesCommand(command) {
return true
}
}
return false
}

func (p Policy) DeniesAgentMentionsByDefault() bool {
if p.IsOperatorControlled() {
return true
}
if p.DenyAgentMentions != nil {
return *p.DenyAgentMentions
}
return false
}
71 changes: 71 additions & 0 deletions server/internal/agentpolicy/policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package agentpolicy

import "testing"

func TestOperatorControlledPolicyDeniesBaselineCommands(t *testing.T) {
policy := FromRuntimeConfig([]byte(`{
"multica_policy": {
"mode": "operator_controlled"
}
}`))

for _, command := range []string{
CommandIssueCreate,
CommandIssueUpdateStatus,
CommandIssueStatus,
CommandIssueUpdateAssignee,
CommandIssueAssign,
} {
if !policy.DeniesCommand(command) {
t.Fatalf("expected operator-controlled policy to deny %s", command)
}
}

if !policy.DeniesAgentMentionsByDefault() {
t.Fatalf("expected operator-controlled policy to deny agent mentions by default")
}
}

func TestDenyCommandsWorkWithoutOperatorControlledMode(t *testing.T) {
policy := FromRuntimeConfig([]byte(`{
"multica_policy": {
"deny_commands": ["issue.create"],
"deny_agent_mentions": true
}
}`))

if !policy.DeniesCommand(CommandIssueCreate) {
t.Fatalf("expected explicit deny_commands to deny issue.create")
}
if policy.DeniesCommand(CommandIssueStatus) {
t.Fatalf("did not expect status to be denied without operator_controlled mode or explicit deny")
}
if !policy.DeniesAgentMentionsByDefault() {
t.Fatalf("expected explicit deny_agent_mentions to be honored")
}
}

func TestEmptyOrInvalidRuntimeConfigHasNoPolicy(t *testing.T) {
for _, raw := range [][]byte{nil, []byte(`{}`), []byte(`not json`)} {
policy := FromRuntimeConfig(raw)
if policy.DeniesCommand(CommandIssueCreate) {
t.Fatalf("expected no command denial for raw=%q", string(raw))
}
if policy.DeniesAgentMentionsByDefault() {
t.Fatalf("expected no agent mention denial for raw=%q", string(raw))
}
}
}

func TestOperatorControlledAlwaysDeniesAgentMentions(t *testing.T) {
policy := FromRuntimeConfig([]byte(`{
"multica_policy": {
"mode": "operator_controlled",
"deny_agent_mentions": false
}
}`))

if !policy.DeniesAgentMentionsByDefault() {
t.Fatalf("expected operator-controlled policy to deny agent mentions even when deny_agent_mentions is false")
}
}
3 changes: 3 additions & 0 deletions server/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -1224,11 +1224,13 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
var agentID string
var skills []SkillData
var instructions string
var runtimeConfig json.RawMessage
if task.Agent != nil {
agentID = task.Agent.ID
agentName = task.Agent.Name
skills = task.Agent.Skills
instructions = task.Agent.Instructions
runtimeConfig = task.Agent.RuntimeConfig
}

// Prepare isolated execution environment.
Expand All @@ -1240,6 +1242,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
AgentID: agentID,
AgentName: agentName,
AgentInstructions: instructions,
AgentRuntimeConfig: runtimeConfig,
AgentSkills: convertSkillsForEnv(skills),
Repos: convertReposForEnv(task.Repos),
ProjectID: task.ProjectID,
Expand Down
1 change: 1 addition & 0 deletions server/internal/daemon/execenv/execenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type TaskContextForEnv struct {
AgentID string // unique ID of the dispatched agent
AgentName string
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
AgentRuntimeConfig json.RawMessage
AgentSkills []SkillContextForEnv
Repos []RepoContextForEnv // workspace repos available for checkout
ProjectID string // issue's project, when present
Expand Down
75 changes: 75 additions & 0 deletions server/internal/daemon/execenv/operator_controlled_policy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package execenv

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestInjectRuntimeConfigOperatorControlledAssignmentOmitsStatusCommands(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "policy-issue-id",
AgentRuntimeConfig: []byte(`{
"multica_policy": {
"mode": "operator_controlled",
"deny_agent_mentions": true
}
}`),
}

if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
if !strings.Contains(s, "operator-controlled assignment") {
t.Fatalf("expected operator-controlled assignment instructions, got:\n%s", s)
}
for _, forbidden := range []string{
"multica issue status policy-issue-id in_progress",
"multica issue status policy-issue-id in_review",
"multica issue status policy-issue-id blocked",
} {
if strings.Contains(s, forbidden) {
t.Fatalf("operator-controlled runtime config should not contain %q", forbidden)
}
}
if !strings.Contains(s, "Do not include any `mention://agent/...` links") {
t.Fatalf("expected agent mention guardrail in operator-controlled instructions")
}
}

func TestInjectRuntimeConfigOperatorControlledCommentTriggerOmitsLifecycleInstructions(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "policy-issue-id",
TriggerCommentID: "trigger-comment-id",
AgentRuntimeConfig: []byte(`{
"multica_policy": {
"mode": "operator_controlled"
}
}`),
}

if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
if err != nil {
t.Fatalf("read CLAUDE.md: %v", err)
}
s := string(content)
if strings.Contains(s, "unless the comment explicitly asks for it") {
t.Fatalf("operator-controlled comment trigger should not allow lifecycle changes on request")
}
if !strings.Contains(s, "Do NOT change issue status, change assignee, create issues, or mention another agent") {
t.Fatalf("expected operator-controlled lifecycle and handoff guardrail in comment-triggered instructions")
}
}
22 changes: 20 additions & 2 deletions server/internal/daemon/execenv/runtime_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"

"github.com/multica-ai/multica/server/internal/agentpolicy"
)

// formatProjectResource renders a single resource as a human-readable bullet.
Expand Down Expand Up @@ -76,6 +78,7 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error
// about the Multica runtime environment and available CLI tools.
func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
var b strings.Builder
agentPolicy := agentpolicy.FromRuntimeConfig(ctx.AgentRuntimeConfig)

b.WriteString("# Multica Agent Runtime\n\n")
b.WriteString("You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.\n\n")
Expand Down Expand Up @@ -249,9 +252,24 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
fmt.Fprintf(&b, "3. Find the triggering comment (ID: `%s`) and understand what is being asked — do NOT confuse it with previous comments\n", ctx.TriggerCommentID)
b.WriteString("4. **Decide whether a reply is warranted.** If you produced actual work this turn (investigated, fixed, answered a real question), post the result via step 6 — that is a normal reply, not a noise comment. If the triggering comment was a pure acknowledgment / thanks / sign-off from another agent AND you produced no work this turn, do NOT post a reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is a valid and preferred way to end agent-to-agent conversations.\n")
b.WriteString("5. If a reply IS warranted: do any requested work first, then **decide whether to include any `@mention` link.** The default is NO mention. Only mention when you are escalating to a human owner who is not yet involved, delegating a concrete new sub-task to another agent for the first time, or the user explicitly asked you to loop someone in. Never @mention the agent you are replying to as a thank-you or sign-off.\n")
b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered to the user. ")
b.WriteString("6. **If you reply, post it as a comment — this step is mandatory when you reply.** Text in your terminal or run logs is NOT delivered. ")
b.WriteString(BuildCommentReplyInstructions(ctx.IssueID, ctx.TriggerCommentID))
b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
if agentPolicy.IsOperatorControlled() {
b.WriteString("7. Do NOT change issue status, change assignee, create issues, or mention another agent. A human operator owns lifecycle and handoff changes.\n\n")
} else {
b.WriteString("7. Do NOT change the issue status unless the comment explicitly asks for it\n\n")
}
} else if agentPolicy.IsOperatorControlled() {
// Operator-controlled assignment: the server enforces write policy for
// status/assignee/issue-create/agent-mention commands, so do not instruct
// the agent to run commands that will be rejected.
b.WriteString("**This is an operator-controlled assignment.** A human operator owns issue lifecycle changes.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue comment list %s --output json` to read the full comment history — this is mandatory, not optional. Earlier comments often carry context the issue body lacks.\n", ctx.IssueID)
b.WriteString(" - If the output is very large or truncated, use pagination: `--limit 30` to get the latest 30 comments, or `--since <timestamp>` to fetch only recent ones\n")
b.WriteString("3. Complete the requested work without changing issue status, changing assignee, creating issues, or mentioning another agent.\n")
fmt.Fprintf(&b, "4. **Post your final results as a plain comment — this step is mandatory**: `multica issue comment add %s --content \"...\"`. Do not include any `mention://agent/...` links.\n", ctx.IssueID)
b.WriteString("5. If blocked, post a plain comment explaining the blocker and wait for the human operator to change status or assignment.\n\n")
} else {
// Assignment-triggered: defer to agent Skills for workflow specifics.
b.WriteString("You are responsible for managing the issue status throughout your work.\n\n")
Expand Down
17 changes: 9 additions & 8 deletions server/internal/daemon/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,15 @@ type Task struct {

// AgentData holds agent details returned by the claim endpoint.
type AgentData struct {
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []SkillData `json:"skills"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
CustomArgs []string `json:"custom_args,omitempty"`
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []SkillData `json:"skills"`
RuntimeConfig json.RawMessage `json:"runtime_config,omitempty"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
CustomArgs []string `json:"custom_args,omitempty"`
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
}

// SkillData represents a structured skill for task execution.
Expand Down
17 changes: 9 additions & 8 deletions server/internal/handler/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,15 @@ type AgentTaskResponse struct {
// TaskAgentData holds agent info included in claim responses so the daemon
// can set up the execution environment (branch naming, skill files, instructions).
type TaskAgentData struct {
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []service.AgentSkillData `json:"skills,omitempty"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
CustomArgs []string `json:"custom_args,omitempty"`
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
Instructions string `json:"instructions"`
Skills []service.AgentSkillData `json:"skills,omitempty"`
RuntimeConfig json.RawMessage `json:"runtime_config,omitempty"`
CustomEnv map[string]string `json:"custom_env,omitempty"`
CustomArgs []string `json:"custom_args,omitempty"`
McpConfig json.RawMessage `json:"mcp_config,omitempty"`
Model string `json:"model,omitempty"`
}

func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
Expand Down
Loading
Loading