refactor(daemon): remove context snapshot, let agent fetch data via CLI
Replace the frozen context snapshot pattern with a CLI-driven approach: agents now use `multica` CLI commands to fetch issue details, comments, and workspace context on demand, always getting the latest data. - Remove buildContextSnapshot and snapshot generation from enqueue - Claim endpoint now returns fresh agent name + skills from DB - Daemon resolves provider from local runtimeIndex, not snapshot - Prompt instructs agent to use `multica issue get` / `comment list` - Meta skill (CLAUDE.md/AGENTS.md) documents all available CLI commands - Skills still injected as filesystem files (static agent config) - Simplify daemon types: remove TaskContext/IssueContext/RuntimeContext Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6733262a63
commit
1deae2a1e9
12 changed files with 176 additions and 238 deletions
|
|
@ -56,6 +56,11 @@ func (c *Client) SetToken(token string) {
|
|||
c.token = token
|
||||
}
|
||||
|
||||
// Token returns the current auth token.
|
||||
func (c *Client) Token() string {
|
||||
return c.token
|
||||
}
|
||||
|
||||
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
|
||||
var resp struct {
|
||||
Task *Task `json:"task"`
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
|
|||
continue
|
||||
}
|
||||
if task != nil {
|
||||
d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID, "title", task.Context.Issue.Title)
|
||||
d.logger.Info("task received", "task_id", task.ID, "issue_id", task.IssueID)
|
||||
d.handleTask(ctx, *task)
|
||||
claimed = true
|
||||
pollOffset = (pollOffset + i + 1) % n
|
||||
|
|
@ -506,8 +506,11 @@ func (d *Daemon) pollLoop(ctx context.Context) error {
|
|||
}
|
||||
|
||||
func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
||||
provider := task.Context.Runtime.Provider
|
||||
d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider, "title", task.Context.Issue.Title)
|
||||
d.mu.Lock()
|
||||
rt := d.runtimeIndex[task.RuntimeID]
|
||||
d.mu.Unlock()
|
||||
provider := rt.Provider
|
||||
d.logger.Info("picked task", "task_id", task.ID, "issue_id", task.IssueID, "provider", provider)
|
||||
|
||||
if err := d.client.StartTask(ctx, task.ID); err != nil {
|
||||
d.logger.Error("start task failed", "task_id", task.ID, "error", err)
|
||||
|
|
@ -516,7 +519,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
|||
|
||||
_ = d.client.ReportProgress(ctx, task.ID, fmt.Sprintf("Launching %s", provider), 1, 2)
|
||||
|
||||
result, err := d.runTask(ctx, task)
|
||||
result, err := d.runTask(ctx, task, provider)
|
||||
if err != nil {
|
||||
d.logger.Error("task failed", "task_id", task.ID, "error", err)
|
||||
if failErr := d.client.FailTask(ctx, task.ID, err.Error()); failErr != nil {
|
||||
|
|
@ -540,26 +543,29 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) {
|
|||
}
|
||||
}
|
||||
|
||||
func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
|
||||
provider := task.Context.Runtime.Provider
|
||||
func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskResult, error) {
|
||||
entry, ok := d.cfg.Agents[provider]
|
||||
if !ok {
|
||||
return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider)
|
||||
}
|
||||
|
||||
agentName := "agent"
|
||||
var skills []SkillData
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
skills = task.Agent.Skills
|
||||
}
|
||||
|
||||
// Prepare isolated execution environment.
|
||||
taskCtx := execenv.TaskContextForEnv{
|
||||
IssueTitle: task.Context.Issue.Title,
|
||||
IssueDescription: task.Context.Issue.Description,
|
||||
WorkspaceContext: task.Context.WorkspaceContext,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
AgentSkills: convertSkillsForEnv(task.Context.Agent.Skills),
|
||||
IssueID: task.IssueID,
|
||||
AgentName: agentName,
|
||||
AgentSkills: convertSkillsForEnv(skills),
|
||||
}
|
||||
env, err := execenv.Prepare(execenv.PrepareParams{
|
||||
WorkspacesRoot: d.cfg.WorkspacesRoot,
|
||||
RepoPath: task.Context.RepoPath,
|
||||
TaskID: task.ID,
|
||||
AgentName: task.Context.Agent.Name,
|
||||
AgentName: agentName,
|
||||
Task: taskCtx,
|
||||
}, d.logger)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -18,28 +18,25 @@ func TestNormalizeServerBaseURL(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptIncludesIssueAndContext(t *testing.T) {
|
||||
func TestBuildPromptContainsIssueID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
issueID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
prompt := BuildPrompt(Task{
|
||||
Context: TaskContext{
|
||||
Issue: IssueContext{
|
||||
Title: "Fix failing test",
|
||||
Description: "Investigate and fix the test failure.",
|
||||
},
|
||||
Agent: AgentContext{
|
||||
Name: "Local Codex",
|
||||
Skills: []SkillData{
|
||||
{Name: "Concise", Content: "Be concise."},
|
||||
},
|
||||
IssueID: issueID,
|
||||
Agent: &AgentData{
|
||||
Name: "Local Codex",
|
||||
Skills: []SkillData{
|
||||
{Name: "Concise", Content: "Be concise."},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Lean prompt: issue title + description only. No inlined skill content.
|
||||
// Prompt should contain the issue ID and CLI instructions.
|
||||
for _, want := range []string{
|
||||
"Fix failing test",
|
||||
"Investigate and fix the test failure.",
|
||||
issueID,
|
||||
"multica issue get",
|
||||
"multica issue comment list",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
|
|
@ -54,25 +51,19 @@ func TestBuildPromptIncludesIssueAndContext(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptTruncatesLongDescription(t *testing.T) {
|
||||
func TestBuildPromptNoIssueDetails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
longDesc := strings.Repeat("x", 300)
|
||||
prompt := BuildPrompt(Task{
|
||||
Context: TaskContext{
|
||||
Issue: IssueContext{
|
||||
Title: "Long desc",
|
||||
Description: longDesc,
|
||||
},
|
||||
Agent: AgentContext{Name: "Test"},
|
||||
},
|
||||
IssueID: "test-id",
|
||||
Agent: &AgentData{Name: "Test"},
|
||||
})
|
||||
|
||||
if strings.Contains(prompt, longDesc) {
|
||||
t.Fatal("expected long description to be truncated in prompt")
|
||||
}
|
||||
if !strings.Contains(prompt, "...") {
|
||||
t.Fatal("expected truncation marker")
|
||||
// Prompt should not contain issue title/description (agent fetches via CLI).
|
||||
for _, absent := range []string{"**Issue:**", "**Summary:**"} {
|
||||
if strings.Contains(prompt, absent) {
|
||||
t.Fatalf("prompt should NOT contain %q — agent fetches details via CLI", absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,25 +77,16 @@ func writeSkillFiles(contextDir string, skills []SkillContextForEnv) error {
|
|||
}
|
||||
|
||||
// renderIssueContext builds the markdown content for issue_context.md.
|
||||
// Sections with empty content are omitted.
|
||||
// It contains only the issue ID and pointers to CLI commands for fetching
|
||||
// dynamic data. Sections with empty content are omitted.
|
||||
func renderIssueContext(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
|
||||
if ctx.IssueTitle != "" {
|
||||
fmt.Fprintf(&b, "# Issue: %s\n\n", ctx.IssueTitle)
|
||||
}
|
||||
b.WriteString("# Task Assignment\n\n")
|
||||
fmt.Fprintf(&b, "**Issue ID:** %s\n\n", ctx.IssueID)
|
||||
|
||||
if ctx.IssueDescription != "" {
|
||||
b.WriteString("## Description\n\n")
|
||||
b.WriteString(ctx.IssueDescription)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if ctx.WorkspaceContext != "" {
|
||||
b.WriteString("## Workspace Context\n\n")
|
||||
b.WriteString(ctx.WorkspaceContext)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Run `multica issue get " + ctx.IssueID + " --output json` for full issue details and description.\n")
|
||||
b.WriteString("Run `multica issue comment list " + ctx.IssueID + "` for discussion history.\n\n")
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Agent Skills\n\n")
|
||||
|
|
|
|||
|
|
@ -29,11 +29,9 @@ type PrepareParams struct {
|
|||
|
||||
// TaskContextForEnv is the subset of task context used for writing context files.
|
||||
type TaskContextForEnv struct {
|
||||
IssueTitle string
|
||||
IssueDescription string
|
||||
WorkspaceContext string
|
||||
AgentName string
|
||||
AgentSkills []SkillContextForEnv
|
||||
IssueID string
|
||||
AgentName string
|
||||
AgentSkills []SkillContextForEnv
|
||||
}
|
||||
|
||||
// SkillContextForEnv represents a skill to be written into the execution environment.
|
||||
|
|
|
|||
|
|
@ -100,8 +100,7 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
|||
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
AgentName: "Test Agent",
|
||||
Task: TaskContextForEnv{
|
||||
IssueTitle: "Fix the bug",
|
||||
IssueDescription: "There is a bug in the login flow.",
|
||||
IssueID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{Name: "Code Review", Content: "Be concise."},
|
||||
},
|
||||
|
|
@ -127,12 +126,12 @@ func TestPrepareDirectoryMode(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Verify context file.
|
||||
// Verify context file contains issue ID and CLI hints.
|
||||
content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read issue_context.md: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Fix the bug", "login flow", "Code Review"} {
|
||||
for _, want := range []string{"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "multica issue get", "Code Review"} {
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Fatalf("issue_context.md missing %q", want)
|
||||
}
|
||||
|
|
@ -176,8 +175,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) {
|
|||
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
AgentName: "Code Reviewer",
|
||||
Task: TaskContextForEnv{
|
||||
IssueTitle: "Add feature",
|
||||
IssueDescription: "Add a new feature.",
|
||||
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
|
|
@ -216,9 +214,7 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Test Issue",
|
||||
IssueDescription: "A detailed description.",
|
||||
WorkspaceContext: "We use Go and TypeScript.",
|
||||
IssueID: "test-issue-id-1234",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{
|
||||
Name: "Go Conventions",
|
||||
|
|
@ -241,11 +237,8 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"# Issue: Test Issue",
|
||||
"## Description",
|
||||
"A detailed description.",
|
||||
"## Workspace Context",
|
||||
"Go and TypeScript",
|
||||
"test-issue-id-1234",
|
||||
"multica issue get",
|
||||
"## Agent Skills",
|
||||
"Go Conventions",
|
||||
} {
|
||||
|
|
@ -254,6 +247,13 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// Issue details should NOT be in the context file (agent fetches via CLI).
|
||||
for _, absent := range []string{"## Description", "## Workspace Context"} {
|
||||
if strings.Contains(s, absent) {
|
||||
t.Errorf("content should NOT contain %q — agent fetches details via CLI", absent)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify skill directory and files.
|
||||
skillMd, err := os.ReadFile(filepath.Join(dir, ".agent_context", "skills", "go-conventions", "SKILL.md"))
|
||||
if err != nil {
|
||||
|
|
@ -272,12 +272,12 @@ func TestWriteContextFiles(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestWriteContextFilesOmitsEmpty(t *testing.T) {
|
||||
func TestWriteContextFilesOmitsSkillsWhenEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Minimal Issue",
|
||||
IssueID: "minimal-issue-id",
|
||||
}
|
||||
|
||||
if err := writeContextFiles(dir, ctx); err != nil {
|
||||
|
|
@ -290,13 +290,11 @@ func TestWriteContextFilesOmitsEmpty(t *testing.T) {
|
|||
}
|
||||
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "Minimal Issue") {
|
||||
t.Error("expected title to be present")
|
||||
if !strings.Contains(s, "minimal-issue-id") {
|
||||
t.Error("expected issue ID to be present")
|
||||
}
|
||||
for _, absent := range []string{"## Description", "## Workspace Context", "## Agent Skills"} {
|
||||
if strings.Contains(s, absent) {
|
||||
t.Errorf("expected %q to be omitted for empty content", absent)
|
||||
}
|
||||
if strings.Contains(s, "## Agent Skills") {
|
||||
t.Error("expected skills section to be omitted when no skills")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -326,7 +324,7 @@ func TestCleanupGitWorktree(t *testing.T) {
|
|||
RepoPath: reposRoot,
|
||||
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
||||
AgentName: "Cleanup Test",
|
||||
Task: TaskContextForEnv{IssueTitle: "Cleanup test"},
|
||||
Task: TaskContextForEnv{IssueID: "cleanup-test-id"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
|
|
@ -358,7 +356,7 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Test Issue",
|
||||
IssueID: "test-issue-id",
|
||||
AgentSkills: []SkillContextForEnv{
|
||||
{Name: "Go Conventions", Content: "Follow Go conventions.", Files: []SkillFileContextForEnv{
|
||||
{Path: "example.go", Content: "package main"},
|
||||
|
|
@ -379,8 +377,8 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
|||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"Multica Agent Runtime",
|
||||
".agent_context/issue_context.md",
|
||||
".agent_context/skills/",
|
||||
"multica issue get",
|
||||
"multica issue comment list",
|
||||
"Go Conventions",
|
||||
"PR Review",
|
||||
"go-conventions/SKILL.md",
|
||||
|
|
@ -398,7 +396,7 @@ func TestInjectRuntimeConfigCodex(t *testing.T) {
|
|||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{
|
||||
IssueTitle: "Test Issue",
|
||||
IssueID: "test-issue-id",
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
|
|
@ -424,7 +422,7 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
|||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
ctx := TaskContextForEnv{IssueTitle: "Test Issue"}
|
||||
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
|
|
@ -436,8 +434,8 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
|||
}
|
||||
|
||||
s := string(content)
|
||||
if !strings.Contains(s, "issue_context.md") {
|
||||
t.Error("should reference issue_context.md even without skills")
|
||||
if !strings.Contains(s, "multica issue get") {
|
||||
t.Error("should reference multica CLI even without skills")
|
||||
}
|
||||
if strings.Contains(s, "## Skills") {
|
||||
t.Error("should not have Skills section when there are no skills")
|
||||
|
|
@ -469,7 +467,7 @@ func TestCleanupPreservesLogs(t *testing.T) {
|
|||
RepoPath: t.TempDir(), // not a git repo
|
||||
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
|
||||
AgentName: "Preserve Test",
|
||||
Task: TaskContextForEnv{IssueTitle: "Preserve test"},
|
||||
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
|
||||
}, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare failed: %v", err)
|
||||
|
|
|
|||
|
|
@ -31,26 +31,35 @@ func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error
|
|||
}
|
||||
|
||||
// buildMetaSkillContent generates the meta skill markdown that teaches the agent
|
||||
// about the Multica runtime environment and where to find task context/skills.
|
||||
// about the Multica runtime environment and available CLI tools.
|
||||
func buildMetaSkillContent(ctx TaskContextForEnv) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("# Multica Agent Runtime\n\n")
|
||||
b.WriteString("You are running as a coding agent in the Multica platform.\n")
|
||||
b.WriteString("Your task context and skill instructions are in the `.agent_context/` directory.\n\n")
|
||||
b.WriteString("You are a coding agent in the Multica platform. Use the `multica` CLI to interact with the platform.\n\n")
|
||||
|
||||
b.WriteString("## Getting Started\n\n")
|
||||
b.WriteString("1. Read `.agent_context/issue_context.md` for the full issue description, acceptance criteria, and context.\n")
|
||||
b.WriteString("## Available Commands\n\n")
|
||||
b.WriteString("### Read\n")
|
||||
b.WriteString("- `multica issue get <id>` — Get full issue details (title, description, status, priority, assignee)\n")
|
||||
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X]` — List issues in workspace\n")
|
||||
b.WriteString("- `multica issue comment list <issue-id>` — List all comments on an issue\n")
|
||||
b.WriteString("- `multica workspace get` — Get workspace details and context\n")
|
||||
b.WriteString("- `multica agent list` — List agents in workspace\n\n")
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("2. Read your skill files in `.agent_context/skills/` for detailed instructions on how to work.\n")
|
||||
}
|
||||
b.WriteString("### Write\n")
|
||||
b.WriteString("- `multica issue comment add <issue-id> --content \"...\"` — Post a comment to an issue\n")
|
||||
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
|
||||
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString("### Workflow\n")
|
||||
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
|
||||
b.WriteString("2. Read comments for additional context or human instructions\n")
|
||||
b.WriteString("3. Complete the work in the local codebase\n")
|
||||
b.WriteString("4. Post a comment summarizing what you did\n\n")
|
||||
|
||||
if len(ctx.AgentSkills) > 0 {
|
||||
b.WriteString("## Skills\n\n")
|
||||
b.WriteString("Each skill directory contains a `SKILL.md` with instructions and optionally supporting files.\n\n")
|
||||
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
|
||||
for _, skill := range ctx.AgentSkills {
|
||||
dirName := sanitizeSkillName(skill.Name)
|
||||
fmt.Fprintf(&b, "- **%s** → `.agent_context/skills/%s/SKILL.md`", skill.Name, dirName)
|
||||
|
|
@ -63,7 +72,7 @@ func buildMetaSkillContent(ctx TaskContextForEnv) string {
|
|||
}
|
||||
|
||||
b.WriteString("## Output\n\n")
|
||||
b.WriteString("When done, return a concise Markdown comment suitable for posting back to the issue.\n")
|
||||
b.WriteString("When done, return a concise Markdown summary of your work.\n")
|
||||
b.WriteString("- Lead with the outcome.\n")
|
||||
b.WriteString("- Mention concrete files or commands if you changed anything.\n")
|
||||
b.WriteString("- If blocked, explain the blocker clearly.\n")
|
||||
|
|
|
|||
|
|
@ -6,24 +6,21 @@ import (
|
|||
)
|
||||
|
||||
// BuildPrompt constructs the task prompt for an agent CLI.
|
||||
// This is kept lean — only the issue summary and acceptance criteria.
|
||||
// Detailed skill instructions are injected via the runtime's native config
|
||||
// mechanism (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig.
|
||||
// The prompt is intentionally minimal — it provides only the issue ID and
|
||||
// instructs the agent to use the multica CLI to fetch details on demand.
|
||||
// Skill instructions are injected via the runtime's native config mechanism
|
||||
// (e.g., .claude/CLAUDE.md, AGENTS.md) by execenv.InjectRuntimeConfig.
|
||||
func BuildPrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n")
|
||||
b.WriteString("Complete the assigned issue using the local environment.\n\n")
|
||||
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
||||
|
||||
fmt.Fprintf(&b, "**Issue:** %s\n", task.Context.Issue.Title)
|
||||
fmt.Fprintf(&b, "**Agent:** %s\n\n", task.Context.Agent.Name)
|
||||
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
||||
|
||||
if task.Context.Issue.Description != "" {
|
||||
desc := task.Context.Issue.Description
|
||||
if len(desc) > 200 {
|
||||
desc = desc[:200] + "..."
|
||||
}
|
||||
fmt.Fprintf(&b, "**Summary:** %s\n\n", desc)
|
||||
}
|
||||
b.WriteString("Use the `multica` CLI to fetch the issue details and any context you need:\n\n")
|
||||
fmt.Fprintf(&b, " multica issue get %s --output json # Full issue details\n", task.IssueID)
|
||||
fmt.Fprintf(&b, " multica issue comment list %s # Comments and discussion\n\n", task.IssueID)
|
||||
|
||||
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,37 +15,23 @@ type Runtime 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"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Context TaskContext `json:"context"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
// TaskContext contains the snapshot context for a task.
|
||||
type TaskContext struct {
|
||||
Issue IssueContext `json:"issue"`
|
||||
Agent AgentContext `json:"agent"`
|
||||
Runtime RuntimeContext `json:"runtime"`
|
||||
WorkspaceContext string `json:"workspace_context,omitempty"`
|
||||
RepoPath string `json:"repo_path,omitempty"`
|
||||
}
|
||||
|
||||
// IssueContext holds issue details for task execution.
|
||||
type IssueContext struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// AgentContext holds agent details for task execution.
|
||||
type AgentContext struct {
|
||||
// AgentData holds agent details returned by the claim endpoint.
|
||||
type AgentData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills []SkillData `json:"skills"`
|
||||
}
|
||||
|
||||
// SkillData represents a structured skill in the task context.
|
||||
// SkillData represents a structured skill for task execution.
|
||||
type SkillData struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
|
|
@ -58,14 +44,6 @@ type SkillFileData struct {
|
|||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// RuntimeContext holds runtime details for task execution.
|
||||
type RuntimeContext struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
}
|
||||
|
||||
// TaskResult is the outcome of executing a task.
|
||||
type TaskResult struct {
|
||||
Status string `json:"status"`
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
|
@ -81,19 +82,27 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Context any `json:"context,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// TaskAgentData holds agent info included in claim responses so the daemon
|
||||
// can set up the execution environment (branch naming, skill files).
|
||||
type TaskAgentData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Skills []service.AgentSkillData `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
||||
|
|
@ -101,10 +110,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
|||
if t.Result != nil {
|
||||
json.Unmarshal(t.Result, &result)
|
||||
}
|
||||
var ctx any
|
||||
if t.Context != nil {
|
||||
json.Unmarshal(t.Context, &ctx)
|
||||
}
|
||||
return AgentTaskResponse{
|
||||
ID: uuidToString(t.ID),
|
||||
AgentID: uuidToString(t.AgentID),
|
||||
|
|
@ -117,7 +122,6 @@ func taskToResponse(t db.AgentTaskQueue) AgentTaskResponse {
|
|||
CompletedAt: timestampToPtr(t.CompletedAt),
|
||||
Result: result,
|
||||
Error: textToPtr(t.Error),
|
||||
Context: ctx,
|
||||
CreatedAt: timestampToString(t.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// ClaimTaskByRuntime atomically claims the next queued task for a runtime.
|
||||
// The response includes the agent's name and skills, fetched fresh from the DB.
|
||||
func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
runtimeID := chi.URLParam(r, "runtimeId")
|
||||
|
||||
|
|
@ -159,8 +160,19 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Build response with fresh agent data (name + skills).
|
||||
resp := taskToResponse(*task)
|
||||
if agent, err := h.Queries.GetAgent(r.Context(), task.AgentID); err == nil {
|
||||
skills := h.TaskService.LoadAgentSkills(r.Context(), task.AgentID)
|
||||
resp.Agent = &TaskAgentData{
|
||||
ID: uuidToString(agent.ID),
|
||||
Name: agent.Name,
|
||||
Skills: skills,
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("task claimed by runtime", "task_id", uuidToString(task.ID), "runtime_id", runtimeID, "agent_id", uuidToString(task.AgentID))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(*task)})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"task": resp})
|
||||
}
|
||||
|
||||
// ListPendingTasksByRuntime returns queued/dispatched tasks for a runtime.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ func NewTaskService(q *db.Queries, hub *realtime.Hub, bus *events.Bus) *TaskServ
|
|||
return &TaskService{Queries: q, Hub: hub, Bus: bus}
|
||||
}
|
||||
|
||||
// EnqueueTaskForIssue creates a task with a context snapshot of the issue.
|
||||
// EnqueueTaskForIssue creates a queued task for an agent-assigned issue.
|
||||
// No context snapshot is stored — the agent fetches all data it needs at
|
||||
// runtime via the multica CLI.
|
||||
func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (db.AgentTaskQueue, error) {
|
||||
if !issue.AssigneeID.Valid {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", "issue has no assignee")
|
||||
|
|
@ -43,30 +45,11 @@ func (s *TaskService) EnqueueTaskForIssue(ctx context.Context, issue db.Issue) (
|
|||
return db.AgentTaskQueue{}, fmt.Errorf("agent has no runtime")
|
||||
}
|
||||
|
||||
runtime, err := s.Queries.GetAgentRuntime(ctx, agent.RuntimeID)
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load runtime: %w", err)
|
||||
}
|
||||
|
||||
// Include workspace context in the snapshot when available.
|
||||
var workspaceContext string
|
||||
if ws, err := s.Queries.GetWorkspace(ctx, issue.WorkspaceID); err == nil && ws.Context.Valid {
|
||||
workspaceContext = ws.Context.String
|
||||
}
|
||||
|
||||
// Load agent's structured skills + files.
|
||||
agentSkills := s.loadAgentSkillsForSnapshot(ctx, agent.ID)
|
||||
|
||||
snapshot := buildContextSnapshot(issue, agent, runtime, workspaceContext, agentSkills)
|
||||
contextJSON, _ := json.Marshal(snapshot)
|
||||
|
||||
task, err := s.Queries.CreateAgentTaskWithContext(ctx, db.CreateAgentTaskWithContextParams{
|
||||
task, err := s.Queries.CreateAgentTask(ctx, db.CreateAgentTaskParams{
|
||||
AgentID: issue.AssigneeID,
|
||||
RuntimeID: agent.RuntimeID,
|
||||
IssueID: issue.ID,
|
||||
Priority: priorityToInt(issue.Priority),
|
||||
Context: contextJSON,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("task enqueue failed", "issue_id", util.UUIDToString(issue.ID), "error", err)
|
||||
|
|
@ -292,70 +275,36 @@ func (s *TaskService) updateAgentStatus(ctx context.Context, agentID pgtype.UUID
|
|||
})
|
||||
}
|
||||
|
||||
type skillSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Files []skillFileSnapshot `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
type skillFileSnapshot struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (s *TaskService) loadAgentSkillsForSnapshot(ctx context.Context, agentID pgtype.UUID) []skillSnapshot {
|
||||
// LoadAgentSkills loads an agent's skills with their files for task execution.
|
||||
func (s *TaskService) LoadAgentSkills(ctx context.Context, agentID pgtype.UUID) []AgentSkillData {
|
||||
skills, err := s.Queries.ListAgentSkills(ctx, agentID)
|
||||
if err != nil || len(skills) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]skillSnapshot, 0, len(skills))
|
||||
result := make([]AgentSkillData, 0, len(skills))
|
||||
for _, sk := range skills {
|
||||
snap := skillSnapshot{Name: sk.Name, Content: sk.Content}
|
||||
data := AgentSkillData{Name: sk.Name, Content: sk.Content}
|
||||
files, _ := s.Queries.ListSkillFiles(ctx, sk.ID)
|
||||
for _, f := range files {
|
||||
snap.Files = append(snap.Files, skillFileSnapshot{Path: f.Path, Content: f.Content})
|
||||
data.Files = append(data.Files, AgentSkillFileData{Path: f.Path, Content: f.Content})
|
||||
}
|
||||
result = append(result, snap)
|
||||
result = append(result, data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildContextSnapshot(issue db.Issue, agent db.Agent, runtime db.AgentRuntime, workspaceContext string, skills []skillSnapshot) map[string]any {
|
||||
var tools any
|
||||
if agent.Tools != nil {
|
||||
json.Unmarshal(agent.Tools, &tools)
|
||||
}
|
||||
var metadata any
|
||||
if runtime.Metadata != nil {
|
||||
json.Unmarshal(runtime.Metadata, &metadata)
|
||||
}
|
||||
// AgentSkillData represents a skill for task execution responses.
|
||||
type AgentSkillData struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Files []AgentSkillFileData `json:"files,omitempty"`
|
||||
}
|
||||
|
||||
m := map[string]any{
|
||||
"issue": map[string]any{
|
||||
"id": util.UUIDToString(issue.ID),
|
||||
"title": issue.Title,
|
||||
"description": issue.Description.String,
|
||||
},
|
||||
"agent": map[string]any{
|
||||
"id": util.UUIDToString(agent.ID),
|
||||
"name": agent.Name,
|
||||
"skills": skills,
|
||||
"tools": tools,
|
||||
},
|
||||
"runtime": map[string]any{
|
||||
"id": util.UUIDToString(runtime.ID),
|
||||
"name": runtime.Name,
|
||||
"runtime_mode": runtime.RuntimeMode,
|
||||
"provider": runtime.Provider,
|
||||
"device_info": runtime.DeviceInfo,
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
if workspaceContext != "" {
|
||||
m["workspace_context"] = workspaceContext
|
||||
}
|
||||
return m
|
||||
// AgentSkillFileData represents a supporting file within a skill.
|
||||
type AgentSkillFileData struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func priorityToInt(p string) int32 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue