Merge pull request #175 from multica-ai/forrestchang/agent-comment-identity

fix(handler): attribute agent CLI actions to agent identity
This commit is contained in:
Jiayuan Zhang 2026-03-30 02:46:38 +08:00 committed by GitHub
commit cbf8f95d05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 10 deletions

View file

@ -39,7 +39,12 @@ func newAPIClient(cmd *cobra.Command) (*cli.APIClient, error) {
return nil, fmt.Errorf("server URL not set: use --server-url flag, MULTICA_SERVER_URL env, or 'multica config set server_url <url>'")
}
return cli.NewAPIClient(serverURL, workspaceID, token), nil
client := cli.NewAPIClient(serverURL, workspaceID, token)
// When running inside a daemon task, attribute actions to the agent.
if agentID := os.Getenv("MULTICA_AGENT_ID"); agentID != "" {
client.AgentID = agentID
}
return client, nil
}
func resolveServerURL(cmd *cobra.Command) string {

View file

@ -21,6 +21,7 @@ type APIClient struct {
BaseURL string
WorkspaceID string
Token string
AgentID string // When set, requests are attributed to this agent instead of the user.
HTTPClient *http.Client
}
@ -41,6 +42,9 @@ func (c *APIClient) setHeaders(req *http.Request) {
if c.WorkspaceID != "" {
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
}
if c.AgentID != "" {
req.Header.Set("X-Agent-ID", c.AgentID)
}
}
// GetJSON performs a GET request and decodes the JSON response.

View file

@ -662,6 +662,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
"MULTICA_DAEMON_PORT": fmt.Sprintf("%d", d.cfg.HealthPort),
"MULTICA_WORKSPACE_ID": d.workspaceIDForRuntime(task.RuntimeID),
"MULTICA_AGENT_NAME": agentName,
"MULTICA_AGENT_ID": task.AgentID,
"MULTICA_TASK_ID": task.ID,
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively

View file

@ -101,10 +101,22 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
}
// Determine author identity: agent (via X-Agent-ID header) or member.
authorType := "member"
authorID := userID
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
// Validate the agent exists in this workspace.
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
if err == nil && uuidToString(agent.WorkspaceID) == uuidToString(issue.WorkspaceID) {
authorType = "agent"
authorID = agentID
}
}
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
IssueID: issue.ID,
AuthorType: "member",
AuthorID: parseUUID(userID),
AuthorType: authorType,
AuthorID: parseUUID(authorID),
Content: req.Content,
Type: req.Type,
ParentID: parentID,
@ -117,7 +129,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
resp := commentToResponse(comment)
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "member", userID, map[string]any{
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
"comment": resp,
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
@ -126,8 +138,8 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
})
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
// The agent will resume its prior session and see this comment.
if h.shouldEnqueueOnComment(r.Context(), issue) {
// Skip when the comment comes from the assigned agent itself to avoid loops.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) {
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
}

View file

@ -220,6 +220,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
// Determine creator identity: agent (via X-Agent-ID header) or member.
creatorType := "member"
actualCreatorID := creatorID
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID {
creatorType = "agent"
actualCreatorID = agentID
}
}
issue, err := qtx.CreateIssue(r.Context(), db.CreateIssueParams{
WorkspaceID: parseUUID(workspaceID),
Title: req.Title,
@ -228,8 +238,8 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
Priority: priority,
AssigneeType: assigneeType,
AssigneeID: assigneeID,
CreatorType: "member",
CreatorID: parseUUID(creatorID),
CreatorType: creatorType,
CreatorID: parseUUID(actualCreatorID),
ParentIssueID: parentIssueID,
Position: 0,
DueDate: dueDate,
@ -249,7 +259,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
resp := issueToResponse(issue, prefix)
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
h.publish(protocol.EventIssueCreated, workspaceID, "member", creatorID, map[string]any{"issue": resp})
h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp})
// Only ready issues in todo are enqueued for agents.
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
@ -371,7 +381,17 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
dueDateChanged := prevDueDate != resp.DueDate && (prevDueDate == nil) != (resp.DueDate == nil) ||
(prevDueDate != nil && resp.DueDate != nil && *prevDueDate != *resp.DueDate)
h.publish(protocol.EventIssueUpdated, workspaceID, "member", userID, map[string]any{
// Determine actor identity: agent (via X-Agent-ID header) or member.
actorType := "member"
actorID := userID
if agentID := r.Header.Get("X-Agent-ID"); agentID != "" {
if agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID)); err == nil && uuidToString(agent.WorkspaceID) == workspaceID {
actorType = "agent"
actorID = agentID
}
}
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,