feat(daemon): add opencode as supported agent provider (#341)
* feat(daemon): add opencode as supported agent provider Add opencode backend alongside claude and codex. The backend spawns `opencode run --format json`, parses streaming JSON events (text, tool_use, error, step_start/finish), and supports --prompt for system prompts. Includes CLI detection, AGENTS.md runtime config, native skill discovery via .config/opencode/skills/, and 21 tests covering handlers, JSON parsing, and integration-level processEvents scenarios. * chore: add .tool-versions to gitignore
This commit is contained in:
parent
09764c5f51
commit
36db325d50
9 changed files with 1203 additions and 23 deletions
312
server/pkg/agent/opencode.go
Normal file
312
server/pkg/agent/opencode.go
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// opencodeBackend implements Backend by spawning `opencode run --format json`
|
||||
// and reading streaming JSON events from stdout — the same pattern as Claude.
|
||||
type opencodeBackend struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func (b *opencodeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
||||
execPath := b.cfg.ExecutablePath
|
||||
if execPath == "" {
|
||||
execPath = "opencode"
|
||||
}
|
||||
if _, err := exec.LookPath(execPath); err != nil {
|
||||
return nil, fmt.Errorf("opencode executable not found at %q: %w", execPath, err)
|
||||
}
|
||||
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Minute
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
|
||||
args := []string{"run", "--format", "json"}
|
||||
if opts.Model != "" {
|
||||
args = append(args, "--model", opts.Model)
|
||||
}
|
||||
if opts.SystemPrompt != "" {
|
||||
args = append(args, "--prompt", opts.SystemPrompt)
|
||||
}
|
||||
if opts.MaxTurns > 0 {
|
||||
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
|
||||
}
|
||||
if opts.ResumeSessionID != "" {
|
||||
args = append(args, "--session", opts.ResumeSessionID)
|
||||
}
|
||||
args = append(args, prompt)
|
||||
|
||||
cmd := exec.CommandContext(runCtx, execPath, args...)
|
||||
if opts.Cwd != "" {
|
||||
cmd.Dir = opts.Cwd
|
||||
}
|
||||
|
||||
env := buildEnv(b.cfg.Env)
|
||||
// Auto-approve all tool use in daemon mode.
|
||||
env = append(env, `OPENCODE_PERMISSION={"*":"allow"}`)
|
||||
cmd.Env = env
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("opencode stdout pipe: %w", err)
|
||||
}
|
||||
cmd.Stderr = newLogWriter(b.cfg.Logger, "[opencode:stderr] ")
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start opencode: %w", err)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("opencode started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
resCh := make(chan Result, 1)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
defer close(msgCh)
|
||||
defer close(resCh)
|
||||
|
||||
startTime := time.Now()
|
||||
scanResult := b.processEvents(stdout, msgCh)
|
||||
|
||||
// Wait for process exit.
|
||||
exitErr := cmd.Wait()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
if runCtx.Err() == context.DeadlineExceeded {
|
||||
scanResult.status = "timeout"
|
||||
scanResult.errMsg = fmt.Sprintf("opencode timed out after %s", timeout)
|
||||
} else if runCtx.Err() == context.Canceled {
|
||||
scanResult.status = "aborted"
|
||||
scanResult.errMsg = "execution cancelled"
|
||||
} else if exitErr != nil && scanResult.status == "completed" {
|
||||
scanResult.status = "failed"
|
||||
scanResult.errMsg = fmt.Sprintf("opencode exited with error: %v", exitErr)
|
||||
}
|
||||
|
||||
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", scanResult.status, "duration", duration.Round(time.Millisecond).String())
|
||||
|
||||
resCh <- Result{
|
||||
Status: scanResult.status,
|
||||
Output: scanResult.output,
|
||||
Error: scanResult.errMsg,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
SessionID: scanResult.sessionID,
|
||||
}
|
||||
}()
|
||||
|
||||
return &Session{Messages: msgCh, Result: resCh}, nil
|
||||
}
|
||||
|
||||
// ── Event handlers ──
|
||||
|
||||
// eventResult holds the accumulated state from processing the event stream.
|
||||
type eventResult struct {
|
||||
status string
|
||||
errMsg string
|
||||
output string
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// processEvents reads JSON lines from r, dispatches events to ch, and returns
|
||||
// the accumulated result. This is the core scanner loop, extracted for testability.
|
||||
func (b *opencodeBackend) processEvents(r io.Reader, ch chan<- Message) eventResult {
|
||||
var output strings.Builder
|
||||
var sessionID string
|
||||
finalStatus := "completed"
|
||||
var finalError string
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event opencodeEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.SessionID != "" {
|
||||
sessionID = event.SessionID
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "text":
|
||||
b.handleTextEvent(event, ch, &output)
|
||||
case "tool_use":
|
||||
b.handleToolUseEvent(event, ch)
|
||||
case "error":
|
||||
b.handleErrorEvent(event, ch, &finalStatus, &finalError)
|
||||
case "step_start":
|
||||
trySend(ch, Message{Type: MessageStatus, Status: "running"})
|
||||
case "step_finish":
|
||||
// Captures final session ID from step_finish if present.
|
||||
}
|
||||
}
|
||||
|
||||
// Check for scanner errors (e.g. broken pipe, read errors).
|
||||
if scanErr := scanner.Err(); scanErr != nil {
|
||||
b.cfg.Logger.Warn("opencode stdout scanner error", "error", scanErr)
|
||||
if finalStatus == "completed" {
|
||||
finalStatus = "failed"
|
||||
finalError = fmt.Sprintf("stdout read error: %v", scanErr)
|
||||
}
|
||||
}
|
||||
|
||||
return eventResult{
|
||||
status: finalStatus,
|
||||
errMsg: finalError,
|
||||
output: output.String(),
|
||||
sessionID: sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *opencodeBackend) handleTextEvent(event opencodeEvent, ch chan<- Message, output *strings.Builder) {
|
||||
text := event.Part.Text
|
||||
if text != "" {
|
||||
output.WriteString(text)
|
||||
trySend(ch, Message{Type: MessageText, Content: text})
|
||||
}
|
||||
}
|
||||
|
||||
// handleToolUseEvent processes "tool_use" events from opencode. A single
|
||||
// tool_use event contains both the call and result in part.state when the
|
||||
// tool has completed (state.status == "completed").
|
||||
func (b *opencodeBackend) handleToolUseEvent(event opencodeEvent, ch chan<- Message) {
|
||||
// Extract input from state.input (the tool invocation parameters).
|
||||
var input map[string]any
|
||||
if event.Part.State != nil && event.Part.State.Input != nil {
|
||||
_ = json.Unmarshal(event.Part.State.Input, &input)
|
||||
}
|
||||
|
||||
// Emit the tool-use message.
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolUse,
|
||||
Tool: event.Part.Tool,
|
||||
CallID: event.Part.CallID,
|
||||
Input: input,
|
||||
})
|
||||
|
||||
// If the tool has completed, also emit a tool-result message.
|
||||
if event.Part.State != nil && event.Part.State.Status == "completed" {
|
||||
outputStr := extractToolOutput(event.Part.State.Output)
|
||||
trySend(ch, Message{
|
||||
Type: MessageToolResult,
|
||||
Tool: event.Part.Tool,
|
||||
CallID: event.Part.CallID,
|
||||
Output: outputStr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleErrorEvent processes "error" events from opencode. OpenCode can exit
|
||||
// with RC=0 even on errors (e.g. invalid model), so error events are the
|
||||
// reliable signal for failures.
|
||||
func (b *opencodeBackend) handleErrorEvent(event opencodeEvent, ch chan<- Message, finalStatus, finalError *string) {
|
||||
errMsg := ""
|
||||
if event.Error != nil {
|
||||
errMsg = event.Error.Message()
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = "unknown opencode error"
|
||||
}
|
||||
|
||||
b.cfg.Logger.Warn("opencode error event", "error", errMsg)
|
||||
trySend(ch, Message{Type: MessageError, Content: errMsg})
|
||||
|
||||
*finalStatus = "failed"
|
||||
*finalError = errMsg
|
||||
}
|
||||
|
||||
// extractToolOutput converts the tool state output (which may be a string or
|
||||
// structured object) into a string.
|
||||
func extractToolOutput(output any) string {
|
||||
if output == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := output.(string); ok {
|
||||
return s
|
||||
}
|
||||
data, _ := json.Marshal(output)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ── JSON types for `opencode run --format json` stdout events ──
|
||||
|
||||
// opencodeEvent represents a single JSON line from `opencode run --format json`.
|
||||
//
|
||||
// Event types observed in real output:
|
||||
//
|
||||
// "step_start" — agent step begins
|
||||
// "text" — text output from agent (part.text)
|
||||
// "tool_use" — tool invocation with call and result (part.tool, part.callID, part.state)
|
||||
// "error" — error from opencode (error.name, error.data.message)
|
||||
// "step_finish" — agent step completes (includes token usage)
|
||||
type opencodeEvent struct {
|
||||
Type string `json:"type"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
Part opencodeEventPart `json:"part"`
|
||||
Error *opencodeError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeEventPart represents the part field in an opencode event.
|
||||
type opencodeEventPart struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
MessageID string `json:"messageID,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
|
||||
// Text events
|
||||
Text string `json:"text,omitempty"`
|
||||
|
||||
// Tool use events
|
||||
Tool string `json:"tool,omitempty"`
|
||||
CallID string `json:"callID,omitempty"`
|
||||
State *opencodeToolState `json:"state,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeToolState represents the state of a tool invocation.
|
||||
type opencodeToolState struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Output any `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
// opencodeError represents an error event from opencode.
|
||||
type opencodeError struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Data *opencodeErrData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Message returns the human-readable error message.
|
||||
func (e *opencodeError) Message() string {
|
||||
if e.Data != nil && e.Data.Message != "" {
|
||||
return e.Data.Message
|
||||
}
|
||||
if e.Name != "" {
|
||||
return e.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type opencodeErrData struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue