Files
multica/server/pkg/agent/agent.go
LinYushen cd50c31201 feat(agent): add GitHub Copilot CLI backend (#1157)
* feat(agent): add GitHub Copilot CLI backend

Integrate Copilot CLI as a new agent backend using the stable
`-p` JSONL mode (`--output-format json`), following the same
spawn-CLI-scan-JSONL pattern established by claude.go.

Backend (server/pkg/agent/copilot.go):
- Spawn `copilot -p <prompt> --output-format json --allow-all-tools --no-ask-user`
- Parse streaming JSONL events (system/assistant/user/result/log)
- Extract session ID for resume support (`--resume <id>`)
- Accumulate per-model token usage for billing
- Filter blocked args to prevent protocol-critical flag overrides

Daemon config:
- Probe MULTICA_COPILOT_PATH / MULTICA_COPILOT_MODEL env vars
- Copilot uses AGENTS.md (native discovery) and default skills path

Frontend:
- Add Copilot logo SVG and provider switch case

Tests: 14 unit tests covering arg building, event parsing, usage
accumulation, and edge cases. All Go + TS checks pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(daemon): add restart subcommand, make daemon uses it

- `daemon start` keeps original behavior: errors if already running
- `daemon restart` stops existing daemon then starts fresh
- `make daemon` now runs `daemon restart --profile local`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(copilot): address review nits 1-5

- Nit 1: Add MinVersions["copilot"] = "1.0.0"
- Nit 2: Seed activeModel from session.start.data.selectedModel (falls
  back to opts.Model, then "copilot"). First-turn tokens now get correct
  model attribution.
- Nit 3: Handle assistant.reasoning/reasoning_delta → MessageThinking,
  reasoningText in assistant.message → MessageThinking,
  session.warning → MessageLog{warn}
- Nit 4: Extract handleCopilotEvent() method shared by production and
  tests — no more duplicated switch body that can drift
- Nit 5: Deltas write to output buffer as defense-in-depth; if process
  dies before assistant.message, output is non-empty

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:14:56 +08:00

126 lines
4.1 KiB
Go

// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, Codex, OpenCode, OpenClaw, Hermes, Pi). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
package agent
import (
"context"
"fmt"
"log/slog"
"time"
)
// Backend is the unified interface for executing prompts via coding agents.
type Backend interface {
// Execute runs a prompt and returns a Session for streaming results.
// The caller should read from Session.Messages (optional) and wait on
// Session.Result for the final outcome.
Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error)
}
// ExecOptions configures a single execution.
type ExecOptions struct {
Cwd string
Model string
SystemPrompt string
MaxTurns int
Timeout time.Duration
ResumeSessionID string // if non-empty, resume a previous agent session
CustomArgs []string // additional CLI arguments appended to the agent command
}
// Session represents a running agent execution.
type Session struct {
// Messages streams events as the agent works. The channel is closed
// when the agent finishes (before Result is sent).
Messages <-chan Message
// Result receives exactly one value — the final outcome — then closes.
Result <-chan Result
}
// MessageType identifies the kind of Message.
type MessageType string
const (
MessageText MessageType = "text"
MessageThinking MessageType = "thinking"
MessageToolUse MessageType = "tool-use"
MessageToolResult MessageType = "tool-result"
MessageStatus MessageType = "status"
MessageError MessageType = "error"
MessageLog MessageType = "log"
)
// Message is a unified event emitted by an agent during execution.
type Message struct {
Type MessageType
Content string // text content (Text, Error, Log)
Tool string // tool name (ToolUse, ToolResult)
CallID string // tool call ID (ToolUse, ToolResult)
Input map[string]any // tool input (ToolUse)
Output string // tool output (ToolResult)
Status string // agent status string (Status)
Level string // log level (Log)
}
// TokenUsage tracks token consumption for a single model.
type TokenUsage struct {
InputTokens int64
OutputTokens int64
CacheReadTokens int64
CacheWriteTokens int64
}
// Result is the final outcome after an agent session completes.
type Result struct {
Status string // "completed", "failed", "aborted", "timeout"
Output string // accumulated text output
Error string // error message if failed
DurationMs int64
SessionID string
Usage map[string]TokenUsage // keyed by model name
}
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, or pi)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
switch agentType {
case "claude":
return &claudeBackend{cfg: cfg}, nil
case "codex":
return &codexBackend{cfg: cfg}, nil
case "copilot":
return &copilotBackend{cfg: cfg}, nil
case "opencode":
return &opencodeBackend{cfg: cfg}, nil
case "openclaw":
return &openclawBackend{cfg: cfg}, nil
case "hermes":
return &hermesBackend{cfg: cfg}, nil
case "gemini":
return &geminiBackend{cfg: cfg}, nil
case "pi":
return &piBackend{cfg: cfg}, nil
case "cursor":
return &cursorBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor)", agentType)
}
}
// DetectVersion runs the agent CLI with --version and returns the output.
func DetectVersion(ctx context.Context, executablePath string) (string, error) {
return detectCLIVersion(ctx, executablePath)
}