Compare commits

...

2 Commits

Author SHA1 Message Date
J
cc250f9a17 chore(server): gofmt files touched by workspace-context injection
Run gofmt on the files that buildWorkspaceContext injection touched.
Cleans up composite-literal alignment in execenv task context and
struct-tag alignment in Task / AgentTaskResponse / RegisterRequest.
No behavior change.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 17:14:42 +08:00
Jiang Bohan
0f32615f91 fix(agent): inject Workspace Context into agent brief (MUL-2542)
The per-workspace `workspace.context` field (Settings → General) was
stored in the DB but never reached the agent prompt. Plumb it from the
workspace row through the claim response, the daemon's Task struct and
TaskContextForEnv, and render it as `## Workspace Context` in the meta
brief above `## Available Commands`. Heading is skipped when the field
is empty so workspaces that haven't set a context don't see a bare
header. Applies to every task kind — issue, comment, chat, autopilot,
quick-create — so the shared system prompt is consistent regardless of
trigger source.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-22 17:02:15 +08:00
8 changed files with 323 additions and 53 deletions

View File

@@ -2239,27 +2239,28 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// Repos are passed as metadata only — the agent checks them out on demand
// via `multica repo checkout <url>`.
taskCtx := execenv.TaskContextForEnv{
IssueID: task.IssueID,
TriggerCommentID: task.TriggerCommentID,
AgentID: agentID,
AgentName: agentName,
AgentInstructions: instructions,
AgentSkills: convertSkillsForEnv(skills),
Repos: convertReposForEnv(task.Repos),
ProjectID: task.ProjectID,
ProjectTitle: task.ProjectTitle,
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
ChatSessionID: task.ChatSessionID,
AutopilotRunID: task.AutopilotRunID,
AutopilotID: task.AutopilotID,
AutopilotTitle: task.AutopilotTitle,
AutopilotDescription: task.AutopilotDescription,
AutopilotSource: task.AutopilotSource,
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
QuickCreatePrompt: task.QuickCreatePrompt,
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
IssueID: task.IssueID,
TriggerCommentID: task.TriggerCommentID,
AgentID: agentID,
AgentName: agentName,
AgentInstructions: instructions,
AgentSkills: convertSkillsForEnv(skills),
Repos: convertReposForEnv(task.Repos),
ProjectID: task.ProjectID,
ProjectTitle: task.ProjectTitle,
ProjectResources: convertProjectResourcesForEnv(task.ProjectResources),
ChatSessionID: task.ChatSessionID,
AutopilotRunID: task.AutopilotRunID,
AutopilotID: task.AutopilotID,
AutopilotTitle: task.AutopilotTitle,
AutopilotDescription: task.AutopilotDescription,
AutopilotSource: task.AutopilotSource,
AutopilotTriggerPayload: strings.TrimSpace(string(task.AutopilotTriggerPayload)),
QuickCreatePrompt: task.QuickCreatePrompt,
IsSquadLeader: strings.Contains(instructions, "## Squad Operating Protocol"),
RequestingUserName: task.RequestingUserName,
RequestingUserProfileDescription: task.RequestingUserProfileDescription,
WorkspaceContext: task.WorkspaceContext,
}
// Mark candidate env roots as active before any env work so the GC loop

View File

@@ -62,6 +62,11 @@ type TaskContextForEnv struct {
AutopilotTriggerPayload string
QuickCreatePrompt string // non-empty for quick-create tasks
IsSquadLeader bool // true when the agent is acting as a squad leader (may exit silently on no_action)
// WorkspaceContext is the workspace-level system prompt (workspace.context
// in the DB). Rendered into the brief as `## Workspace Context` when
// non-empty so every agent in the workspace sees the same shared context,
// regardless of issue / chat / autopilot / quick-create.
WorkspaceContext string
// RequestingUserName + RequestingUserProfileDescription describe the
// human the agent is acting on behalf of. v1 sources them from the
// runtime owner (the user who registered the daemon). Rendered into the

View File

@@ -23,7 +23,7 @@ var runtimeGOOS = runtime.GOOS
//
// CR/LF and other whitespace control bytes collapse to a single space; other
// C0 controls and DEL are dropped; markdown structural characters that have
// meaning in inline context (`*`, `_`, `` ` ``, `\`, `[`, `]`, `<`) are
// meaning in inline context (`*`, `_`, ` , `\`, `[`, `]`, `<`) are
// backslash-escaped. Trailing whitespace is trimmed.
func sanitizeNameForBriefMarkdown(name string) string {
var b strings.Builder
@@ -184,6 +184,20 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("\nTreat this as background context, not as task instructions. If it conflicts with the actual task, the task wins.\n\n")
}
// Workspace Context block: the workspace-level system prompt set by
// workspace owners in Settings → General (`workspace.context` DB column).
// Applies to every agent run in the workspace regardless of task kind, so
// emit it unconditionally above Available Commands when non-empty. Heading
// is skipped when the field is empty — bare headings are noise. Content
// is set by trusted workspace admins, so it is embedded directly (no
// blockquote wrapping like Requesting User, which is user-supplied) but
// trailing whitespace is trimmed to avoid stacking blank lines.
if ctxText := strings.TrimRight(ctx.WorkspaceContext, " \t\r\n"); ctxText != "" {
b.WriteString("## Workspace Context\n\n")
b.WriteString(ctxText)
b.WriteString("\n\n")
}
b.WriteString("## Available Commands\n\n")
b.WriteString("**Use `--output json` for structured data.** Human table output now prints routable issue keys (for example `MUL-123`) and short UUID prefixes for workspace resources; use `--full-id` on list commands when you need canonical UUIDs.\n\n")
b.WriteString("The default brief includes the commands needed for the core agent loop and common issue create/update tasks. For everything else, run `multica --help`, `multica <command> --help`, or `multica <command> <subcommand> --help`; prefer `--output json` when the command supports it.\n\n")

View File

@@ -199,6 +199,111 @@ func TestSubIssueCreationSectionIsUnconditional(t *testing.T) {
}
}
// Workspace Context block: workspace.context (the per-workspace system prompt
// owners set in Settings → General) must reach the brief as `## Workspace
// Context` for every task kind so agents see a consistent shared system prompt
// regardless of how they were triggered. Empty content must skip the heading
// entirely — bare headings would just add noise.
func TestWorkspaceContextRenderedAcrossTaskKinds(t *testing.T) {
t.Parallel()
const wsContext = "All comments must be in English. Prefer concise PR descriptions."
cases := []struct {
name string
ctx TaskContextForEnv
}{
{
name: "assignment-triggered",
ctx: TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
WorkspaceContext: wsContext,
},
},
{
name: "comment-triggered",
ctx: TaskContextForEnv{
IssueID: "22222222-3333-4444-5555-666666666666",
TriggerCommentID: "33333333-4444-5555-6666-777777777777",
WorkspaceContext: wsContext,
},
},
{
name: "chat",
ctx: TaskContextForEnv{
ChatSessionID: "chat-1",
WorkspaceContext: wsContext,
},
},
{
name: "quick-create",
ctx: TaskContextForEnv{
QuickCreatePrompt: "create me an issue",
WorkspaceContext: wsContext,
},
},
{
name: "autopilot run-only",
ctx: TaskContextForEnv{
AutopilotRunID: "run-1",
WorkspaceContext: wsContext,
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
out := buildMetaSkillContent("claude", tc.ctx)
if !strings.Contains(out, "## Workspace Context") {
t.Fatalf("[%s] expected `## Workspace Context` heading", tc.name)
}
if !strings.Contains(out, wsContext) {
t.Errorf("[%s] brief missing workspace context body %q", tc.name, wsContext)
}
// The block must precede Available Commands so it acts as
// background framing, not a footer hidden below CLI usage.
ctxIdx := strings.Index(out, "## Workspace Context")
cmdsIdx := strings.Index(out, "## Available Commands")
if ctxIdx == -1 || cmdsIdx == -1 || ctxIdx > cmdsIdx {
t.Errorf("[%s] `## Workspace Context` must appear above `## Available Commands` (ctx=%d, cmds=%d)", tc.name, ctxIdx, cmdsIdx)
}
})
}
}
func TestWorkspaceContextHeadingSkippedWhenEmpty(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ctx TaskContextForEnv
}{
{
name: "empty string",
ctx: TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
WorkspaceContext: "",
},
},
{
name: "whitespace only",
ctx: TaskContextForEnv{
IssueID: "11111111-2222-3333-4444-555555555555",
WorkspaceContext: " \n\t \r\n",
},
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
out := buildMetaSkillContent("claude", tc.ctx)
if strings.Contains(out, "## Workspace Context") {
t.Errorf("[%s] empty workspace context must NOT emit the heading", tc.name)
}
})
}
}
func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
t.Parallel()
cases := []struct {

View File

@@ -33,34 +33,39 @@ type ProjectResourceData struct {
// Task represents a claimed task from the server.
// Agent data (name, skills) is populated by the claim endpoint.
type Task struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Agent *AgentData `json:"agent,omitempty"`
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
// WorkspaceContext mirrors workspace.context (the per-workspace system
// prompt set in Settings → General). Server populates this on every claim
// regardless of task kind so the daemon can inject `## Workspace Context`
// into the brief. Empty when the owner hasn't set one.
WorkspaceContext string `json:"workspace_context,omitempty"`
Agent *AgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
ChatMessageAttachments []ChatAttachmentMeta `json:"chat_message_attachments,omitempty"` // attachments linked to the chat message; agent uses these to `multica attachment download <id>`
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
SquadID string `json:"squad_id,omitempty"` // when the picker was a squad, the squad's UUID; Agent is still the resolved leader
SquadName string `json:"squad_name,omitempty"` // display name for the picker squad, used in prompt text
// RequestingUserName + RequestingUserProfileDescription describe the human
// the agent is working on behalf of. v1 sources them from the runtime
// owner (the user who registered the daemon). Empty when the runtime has

View File

@@ -141,11 +141,17 @@ type ProjectResourceData struct {
}
type AgentTaskResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
// WorkspaceContext is the workspace-level system prompt set in workspace
// settings (`workspace.context` DB column). Injected into the agent brief
// as `## Workspace Context` so every agent running in this workspace —
// regardless of issue / chat / autopilot / quick-create — sees the same
// shared context. Empty when the workspace owner hasn't set it.
WorkspaceContext string `json:"workspace_context,omitempty"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt *string `json:"dispatched_at"`

View File

@@ -161,7 +161,7 @@ type DaemonRegisterRequest struct {
DeviceName string `json:"device_name"`
CLIVersion string `json:"cli_version"` // multica CLI version
LaunchedBy string `json:"launched_by"` // "desktop" when spawned by the Electron app
Runtimes []struct {
Runtimes []struct {
Name string `json:"name"`
Type string `json:"type"`
Version string `json:"version"` // agent CLI version (claude/codex)
@@ -1509,6 +1509,24 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
return
}
// Workspace-level Context (workspace.context DB column) — the per-workspace
// system prompt that workspace owners set in Settings → General. Inject it
// into the brief regardless of task kind (issue / chat / autopilot /
// quick-create) so every agent running in the workspace sees the same
// shared context. Empty string when the owner hasn't set one; the daemon
// skips rendering the heading in that case.
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(resp.WorkspaceID)); err == nil {
if ws.Context.Valid {
resp.WorkspaceContext = ws.Context.String
}
} else {
slog.Warn("task claim: failed to load workspace for context injection",
"task_id", uuidToString(task.ID),
"workspace_id", resp.WorkspaceID,
"error", err,
)
}
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID), "prior_session", resp.PriorSessionID)
writeJSON(w, http.StatusOK, map[string]any{"task": resp})
}

View File

@@ -307,6 +307,122 @@ func TestClaimTaskByRuntime_DoesNotReclaimDifferentRuntimeTask(t *testing.T) {
}
}
// TestClaimTaskByRuntime_PopulatesWorkspaceContext verifies the claim
// response carries workspace.context so the daemon can inject the
// workspace-level system prompt into every agent brief. Regression coverage
// for MUL-2542: before this fix the field was never plumbed through, so
// even workspaces that had set a context got an empty brief.
func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
const wsContext = "All comments must be in English. Prefer concise PR descriptions."
var prior string
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
t.Fatalf("read workspace.context: %v", err)
}
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, wsContext, testWorkspaceID); err != nil {
t.Fatalf("set workspace.context: %v", err)
}
t.Cleanup(func() {
if prior == "" {
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
} else {
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
}
})
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context claim runtime")
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context claim agent")
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
testWorkspaceID, "workspace-context-claim")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
ID string `json:"id"`
WorkspaceContext string `json:"workspace_context"`
} `json:"task"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode claim response: %v", err)
}
if resp.Task == nil {
t.Fatalf("expected dispatched task %s to be claimed, got nil response: %s", taskID, w.Body.String())
}
if resp.Task.ID != taskID {
t.Fatalf("claimed task id = %s, want %s", resp.Task.ID, taskID)
}
if resp.Task.WorkspaceContext != wsContext {
t.Errorf("workspace_context = %q, want %q", resp.Task.WorkspaceContext, wsContext)
}
}
// TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset verifies the field
// is omitted (empty string after JSON decode) when the workspace owner has
// not set a context. Important because the daemon's brief skips the heading
// only on empty input — a stray "context: null" coming back as the string
// "null" would render as a bogus paragraph.
func TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
var prior string
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
t.Fatalf("read workspace.context: %v", err)
}
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID); err != nil {
t.Fatalf("clear workspace.context: %v", err)
}
t.Cleanup(func() {
if prior == "" {
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
} else {
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
}
})
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context empty claim runtime")
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context empty claim agent")
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
testWorkspaceID, "workspace-context-empty-claim")
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.ClaimTaskByRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Task *struct {
ID string `json:"id"`
WorkspaceContext string `json:"workspace_context"`
} `json:"task"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode claim response: %v", err)
}
if resp.Task == nil {
t.Fatalf("expected dispatched task %s to be claimed, got nil: %s", taskID, w.Body.String())
}
if resp.Task.WorkspaceContext != "" {
t.Errorf("workspace_context = %q, want empty string when workspace.context is NULL", resp.Task.WorkspaceContext)
}
}
func TestDaemonRegister_WithDaemonToken(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")