mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* fix(agent): drive OpenCode over ACP so long tools and subagents are not force-stopped `opencode run --format json` reports a tool only when it finishes, with the call and its result in one event. The daemon never saw a tool in flight, so a long silent tool or a delegated subagent was judged by the 10-minute OpenCode idle window instead of the tool budget and force-stopped (GH #9614). Built-in OpenCode 1.15+ and 2.x runtimes now run `opencode acp`, which on both majors announces a tool when it starts executing. MessageToolUse is emitted from that in_progress frame, so the daemon applies AgentToolWatchdog for the whole tool. One code path covers both majors: - model via session/set_config_option (2.x has no session/set_model); effort via the advertised `effort` option, splitting 2.x `model#variant` - resume via session/load; a missing session is reported as ResumeRejected - cancel via session/cancel, which aborts the running tool on both majors - MCP: 1.x keeps OPENCODE_CONFIG_CONTENT; 2.x gets servers in session/new, which lifts the 2.x MCP refusal on this transport - 2.x subagent text (`_meta["opencode/child-session"]`) stays out of the parent's output; its tool calls are kept - usage summed from the session export over this turn's assistant messages, since the prompt result's usage is not per-turn on either major The run transport stays as the fallback for custom profiles, older or unknown versions, agents with custom_args, and MULTICA_OPENCODE_TRANSPORT=run. Also fix the run transport on 2.x: tool_use parts carry the call id as `id` (1.x: `callID`), so every 2.x tool message had an empty CallID. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound OpenCode ACP teardown when the runtime stops reading A runtime that stops reading stdin mid-prompt left the prompt write blocked holding the client's write lock. The cancel path then waited on that lock to send session/cancel, and the cancel grace and the process-group kill only ran after the write, so the process could outlive the task indefinitely. - session/cancel is sent only once the prompt is fully delivered, from its own goroutine, so the grace starts immediately and a stalled pipe cannot hold it - a turn OpenCode never answered (or a cancelled/timed-out setup) is torn down forcibly: stdin closed first to release a blocked write, then SIGTERM to the group and SIGKILL after the grace; an answered turn still exits on EOF Regression tests cover a stalled prompt write under a 1s timeout and a runtime that ignores session/cancel. Co-authored-by: multica-agent <[email protected]> * fix(agent): bound OpenCode ACP teardown when a setup write stalls Setup requests (initialize, session/new, session/load, set_config_option) are synchronous stdin writes that check their context only after the write returns. A runtime that stops reading after initialize, with a large MCP payload in session/new, left the run goroutine blocked in that write, so the deferred teardown never ran and the process outlived the timeout. A watcher now force-stops the process as soon as the run ends before the prompt is fully delivered; closing stdin releases the blocked write. Once the prompt is delivered the existing session/cancel path owns shutdown. Regression test: 256 KiB MCP env, runtime stalls after initialize, 1s timeout; returns in ~1s (60s before the fix). Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Multica Eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
838 lines
29 KiB
Go
838 lines
29 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// OpenCode over ACP (`opencode acp`).
|
|
//
|
|
// `opencode run --format json` writes a tool_use event only once the tool has
|
|
// finished, with the call and its result in one line. The daemon therefore never
|
|
// sees a tool in flight on that transport, and a long silent tool — a build, a
|
|
// test suite, a delegated subagent — is judged by the short OpenCode idle window
|
|
// (DefaultOpenCodeIdleWatchdog, 10 min) instead of the tool budget, so healthy
|
|
// runs get force-stopped (GH #9614). Both majors stream tool starts on ACP
|
|
// instead: 1.x and 2.x send `tool_call` when the model emits the call and an
|
|
// `in_progress` `tool_call_update` carrying the real input the moment the tool
|
|
// starts executing. That second frame is where MessageToolUse is emitted (see
|
|
// hermesClient.toolInputArrivesInProgress).
|
|
//
|
|
// What differs between the majors on this transport, all verified against
|
|
// opencode-ai 1.19.4 and @opencode/cli 2.2.0 (handshake also on 1.15.0 and
|
|
// 2.0.0):
|
|
//
|
|
// - Model selection. Both advertise a `model` entry in configOptions and take
|
|
// session/set_config_option; 2.x has no session/set_model at all (-32601).
|
|
// Reasoning effort is the advertised `effort` option on both, so the 2.x
|
|
// `provider/model#variant` spelling is only split, never sent.
|
|
// - MCP. 1.x still honours OPENCODE_CONFIG_CONTENT, which carries every shape
|
|
// the OpenCode translator accepts, so it keeps that channel. 2.x ignores the
|
|
// env var; ACP session/new's `mcpServers` is the channel there, which keeps
|
|
// credentials in the process instead of in a file in the agent's workdir
|
|
// (the reason the run transport refuses MCP on 2.x).
|
|
// - Subagents. 2.x forwards a child session's updates into the parent with
|
|
// `_meta["opencode/child-session"]`. Its tool calls are kept (they are real
|
|
// work in flight); its text is dropped so the parent's output stays the
|
|
// parent's answer, matching the run transport.
|
|
// - Usage. The prompt result's usage is not a per-turn figure on either
|
|
// major: 1.x reports the last assistant message only, 2.x the session
|
|
// total. Usage is therefore summed from the session export, over the
|
|
// assistant messages this turn created (opencodeACPTurnUsage).
|
|
//
|
|
// Cancellation is `session/cancel`, which on both majors aborts the running
|
|
// tool and answers the prompt with stopReason "cancelled". On 2.x it is the
|
|
// only reliable stop: the ACP process hosts its server as a separate process
|
|
// group, so a group signal alone does not reach the tools.
|
|
//
|
|
// The run transport is kept as a fallback for everything this file cannot vouch
|
|
// for: custom runtime profiles, versions older than the ones checked, agents
|
|
// with custom_args (they are `opencode run` flags), and anyone who sets
|
|
// MULTICA_OPENCODE_TRANSPORT=run.
|
|
|
|
// opencodeTransportEnv selects the OpenCode transport. "run" forces the
|
|
// `opencode run --format json` path; anything else lets the backend choose.
|
|
// It is an operator setting read from the daemon's environment (the daemon
|
|
// strips MULTICA_* keys from agent custom_env); Config.Env is consulted first
|
|
// so tests can pin a transport per backend.
|
|
const opencodeTransportEnv = "MULTICA_OPENCODE_TRANSPORT"
|
|
|
|
// Oldest releases whose ACP surface (configOptions model + effort selectors,
|
|
// loadSession, session/cancel) matches what this file drives. 1.15.0 is the
|
|
// first 1.x release that advertises the effort option.
|
|
var (
|
|
opencodeACPMinV1 = semver{Major: 1, Minor: 15, Patch: 0}
|
|
opencodeACPMinV2 = semver{Major: 2, Minor: 0, Patch: 0}
|
|
)
|
|
|
|
// Timing knobs. Vars so tests can shorten them.
|
|
var (
|
|
// opencodeACPCancelGrace bounds the wait for the prompt to answer after
|
|
// session/cancel before the process is torn down regardless.
|
|
opencodeACPCancelGrace = 5 * time.Second
|
|
// opencodeACPExitGrace is how long the process gets to exit on its own
|
|
// after stdin closes before it is signalled.
|
|
opencodeACPExitGrace = 3 * time.Second
|
|
// opencodeACPDrainGrace bounds the trailing-notification drain after the
|
|
// prompt response, like the other ACP backends' reader drain.
|
|
opencodeACPDrainGrace = 2 * time.Second
|
|
// opencodeACPUsageTimeout bounds the post-run session export.
|
|
opencodeACPUsageTimeout = 15 * time.Second
|
|
)
|
|
|
|
// opencodeUsesACP reports whether this run should use the ACP transport, and if
|
|
// not, why (for the log line).
|
|
func opencodeUsesACP(cfg Config, opts ExecOptions) (bool, string) {
|
|
if strings.EqualFold(strings.TrimSpace(opencodeTransportSetting(cfg)), "run") {
|
|
return false, opencodeTransportEnv + "=run"
|
|
}
|
|
// A custom profile wraps a binary this package did not choose, so its
|
|
// version string does not establish which protocol it speaks — the same
|
|
// rule opencodeUsesV2Contract applies.
|
|
if !cfg.BuiltinRuntime || len(cfg.LaunchPrefix) != 0 {
|
|
return false, "custom runtime profile"
|
|
}
|
|
version, err := parseSemver(strings.TrimSpace(cfg.CLIVersion))
|
|
if err != nil {
|
|
return false, "unknown OpenCode version"
|
|
}
|
|
switch {
|
|
case version.Major == 1 && version.lessThan(opencodeACPMinV1):
|
|
return false, "OpenCode older than " + fmt.Sprintf("%d.%d.%d", opencodeACPMinV1.Major, opencodeACPMinV1.Minor, opencodeACPMinV1.Patch)
|
|
case version.Major < 1:
|
|
return false, "OpenCode older than 1.x"
|
|
case version.Major >= 2 && version.lessThan(opencodeACPMinV2):
|
|
return false, "unrecognised OpenCode version"
|
|
}
|
|
// custom_args are `opencode run` flags (--agent, --attach, …); `opencode
|
|
// acp` would reject or ignore them, so honour them on the transport they
|
|
// were written for.
|
|
if len(opts.CustomArgs) != 0 {
|
|
return false, "agent has custom_args for `opencode run`"
|
|
}
|
|
return true, ""
|
|
}
|
|
|
|
func opencodeTransportSetting(cfg Config) string {
|
|
if v, ok := cfg.Env[opencodeTransportEnv]; ok {
|
|
return v
|
|
}
|
|
return os.Getenv(opencodeTransportEnv)
|
|
}
|
|
|
|
// opencodeACPToolName keeps OpenCode's own tool ids (bash, read, task, …) so
|
|
// the transcript reads the same as on the run transport. 2.x sends the id as
|
|
// `name`; 1.x's start frame sends it as `title`.
|
|
func opencodeACPToolName(name, title, kind string) string {
|
|
if name = strings.TrimSpace(name); name != "" {
|
|
return name
|
|
}
|
|
if title = strings.TrimSpace(title); title != "" {
|
|
return title
|
|
}
|
|
return kind
|
|
}
|
|
|
|
// opencodeACPChildUpdate reports whether a session/update came from a 2.x
|
|
// subagent's child session rather than the session Multica prompted.
|
|
func opencodeACPChildUpdate(update json.RawMessage) bool {
|
|
var u struct {
|
|
Meta map[string]json.RawMessage `json:"_meta"`
|
|
}
|
|
if json.Unmarshal(update, &u) != nil {
|
|
return false
|
|
}
|
|
_, ok := u.Meta["opencode/child-session"]
|
|
return ok
|
|
}
|
|
|
|
// opencodeACPSplitModel splits a 2.x `provider/model#variant` selector. The
|
|
// pinned variant wins over the agent-level thinking level, as on the run
|
|
// transport (opencodeModelArg).
|
|
func opencodeACPSplitModel(model, thinkingLevel string) (string, string) {
|
|
model = strings.TrimSpace(model)
|
|
if base, variant, ok := strings.Cut(model, "#"); ok {
|
|
if variant = strings.TrimSpace(variant); variant != "" {
|
|
return strings.TrimSpace(base), variant
|
|
}
|
|
return strings.TrimSpace(base), thinkingLevel
|
|
}
|
|
return model, thinkingLevel
|
|
}
|
|
|
|
// opencodeACPSelectModel puts the session on model and returns the session's
|
|
// configOptions as they stand afterwards, which is what the effort option has
|
|
// to be read from: the effort vocabulary belongs to the current model.
|
|
func opencodeACPSelectModel(ctx context.Context, request acpRequestFn, sessionID, model string, sessionResult json.RawMessage) (json.RawMessage, error) {
|
|
if model == "" {
|
|
return sessionResult, nil
|
|
}
|
|
if _, ok := findACPConfigOption(sessionResult, "model"); !ok {
|
|
// Not seen on any version this transport is enabled for; kept so an
|
|
// unexpected build degrades to the older ACP model call.
|
|
if _, err := request(ctx, "session/set_model", map[string]any{"sessionId": sessionID, "modelId": model}); err != nil {
|
|
return nil, fmt.Errorf("select model %q: %w", model, err)
|
|
}
|
|
return nil, nil
|
|
}
|
|
result, err := request(ctx, "session/set_config_option", map[string]any{
|
|
"sessionId": sessionID,
|
|
"configId": "model",
|
|
"value": model,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("select model %q: %w", model, err)
|
|
}
|
|
if current, ok := acpConfigOptionCurrentValue(result, "model"); ok && current != model {
|
|
return nil, fmt.Errorf("select model %q: OpenCode kept the session on %q", model, current)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// opencodeACPMcpServers translates mcp_config for a 2.x ACP session. It goes
|
|
// through the OpenCode translator first so the accepted input shapes (including
|
|
// OpenCode-native `mcp` entries) and the validation are the same as on 1.x.
|
|
//
|
|
// Two OpenCode features have no ACP representation and fail the run rather
|
|
// than silently change what the agent gets: remote OAuth, and a bare
|
|
// {"enabled": …} override of a server the user configured elsewhere. A
|
|
// per-server timeout is dropped with a warning.
|
|
func opencodeACPMcpServers(raw json.RawMessage, cfg Config) ([]any, error) {
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return []any{}, nil
|
|
}
|
|
servers, err := translateMCPConfigForOpenCode(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(servers))
|
|
for name := range servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]any, 0, len(names))
|
|
for _, name := range names {
|
|
entry, _ := servers[name].(map[string]any)
|
|
if enabled, ok := entry["enabled"].(bool); ok && !enabled {
|
|
if _, typed := entry["type"]; typed {
|
|
continue // a disabled server is simply not sent
|
|
}
|
|
}
|
|
if _, ok := entry["timeout"]; ok && cfg.Logger != nil {
|
|
cfg.Logger.Warn("opencode: MCP server timeout cannot be passed over ACP; using OpenCode's default", "server", name)
|
|
}
|
|
switch entry["type"] {
|
|
case "local":
|
|
command := stringsFromAny(entry["command"])
|
|
if len(command) == 0 {
|
|
return nil, fmt.Errorf("opencode mcp_config: server %q: local server missing `command`", name)
|
|
}
|
|
out = append(out, map[string]any{
|
|
"name": name,
|
|
"command": command[0],
|
|
"args": append([]string{}, command[1:]...),
|
|
"env": acpNameValues(entry["environment"]),
|
|
})
|
|
case "remote":
|
|
if oauth, ok := entry["oauth"]; ok && oauth != false {
|
|
return nil, fmt.Errorf("opencode mcp_config: server %q uses OAuth, which OpenCode 2.x cannot receive over ACP; use headers, an OpenCode 1.x runtime, or %s=run", name, opencodeTransportEnv)
|
|
}
|
|
url, _ := entry["url"].(string)
|
|
out = append(out, map[string]any{
|
|
"type": "http",
|
|
"name": name,
|
|
"url": url,
|
|
"headers": acpNameValues(entry["headers"]),
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("opencode mcp_config: server %q is an {\"enabled\": …} override, which OpenCode 2.x cannot receive over ACP", name)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func stringsFromAny(v any) []string {
|
|
switch items := v.(type) {
|
|
case []string:
|
|
return items
|
|
case []any:
|
|
out := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
s, ok := item.(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// acpNameValues turns a JSON object of strings into ACP's sorted
|
|
// [{name, value}] list.
|
|
func acpNameValues(v any) []map[string]any {
|
|
m, _ := v.(map[string]any)
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
out := make([]map[string]any, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, map[string]any{"name": k, "value": fmt.Sprint(m[k])})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (b *opencodeBackend) executeACP(ctx context.Context, execPath, prompt string, opts ExecOptions) (*Session, error) {
|
|
usesV2 := opencodeUsesV2Contract(b.cfg)
|
|
cwd := opts.Cwd
|
|
if cwd == "" {
|
|
cwd = "."
|
|
}
|
|
cwd, err := filepath.Abs(cwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opencode working directory: %w", err)
|
|
}
|
|
|
|
// 1.x: MCP rides OPENCODE_CONFIG_CONTENT exactly as on the run transport.
|
|
// 2.x: MCP goes into session/new, and the env carries none.
|
|
envOpts := opts
|
|
mcpServers := []any{}
|
|
if usesV2 {
|
|
envOpts = ExecOptions{Cwd: opts.Cwd}
|
|
if mcpServers, err = opencodeACPMcpServers(opts.McpConfig, b.cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
env, err := openCodeEnvironment(b.cfg, envOpts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if opts.MaxTurns > 0 {
|
|
b.cfg.Logger.Warn("opencode does not support --max-turns; ignoring", "maxTurns", opts.MaxTurns)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
runCtx, cancel := runContext(ctx, timeout)
|
|
|
|
// The process runs on its own context so cancelling the task can go
|
|
// through session/cancel first; procCancel is the last-resort group kill.
|
|
procCtx, procCancel := context.WithCancel(context.Background())
|
|
args := []string{"acp"}
|
|
cmd := b.cfg.commandAt(execPath).exec(procCtx, args...)
|
|
hideAgentWindow(cmd)
|
|
cmd.WaitDelay = 5 * time.Second
|
|
cmd.Dir = cwd
|
|
cmd.Env = env
|
|
b.cfg.logAgentCommand(cmd, newAgentCommandLogArgs(args, trustAgentCommandPositional(0, "acp")))
|
|
|
|
fail := func(err error) (*Session, error) {
|
|
procCancel()
|
|
cancel()
|
|
return nil, err
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return fail(fmt.Errorf("opencode stdout pipe: %w", err))
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fail(fmt.Errorf("opencode stdin pipe: %w", err))
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return fail(fmt.Errorf("opencode stderr pipe: %w", err))
|
|
}
|
|
if err := startOwnedProcessTree(cmd, b.cfg.Logger); err != nil {
|
|
return fail(fmt.Errorf("start opencode: %w", err))
|
|
}
|
|
b.cfg.Logger.Info("opencode acp started", "pid", cmd.Process.Pid, "cwd", cwd, "model", opts.Model)
|
|
|
|
providerErr := newACPProviderErrorSniffer("opencode")
|
|
stderrDone := make(chan struct{})
|
|
go func() {
|
|
defer close(stderrDone)
|
|
_, _ = io.Copy(io.MultiWriter(newLogWriter(b.cfg.Logger, "[opencode:stderr] "), providerErr), stderr)
|
|
}()
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
var streaming atomic.Bool
|
|
var sawText, sawTool atomic.Bool
|
|
var outputMu sync.Mutex
|
|
var output strings.Builder
|
|
activity := make(chan struct{}, 1)
|
|
promptDone := make(chan hermesPromptResult, 1)
|
|
|
|
c := &hermesClient{
|
|
cfg: b.cfg,
|
|
stdin: stdin,
|
|
pending: make(map[int]*pendingRPC),
|
|
pendingTools: make(map[string]*pendingToolCall),
|
|
toolInputArrivesInProgress: true,
|
|
toolOutputFromContent: true,
|
|
toolName: opencodeACPToolName,
|
|
// session/load replays the whole conversation as session/update;
|
|
// only the turn this run prompts is this run's transcript.
|
|
acceptNotification: func(string) bool { return streaming.Load() },
|
|
acceptUpdate: func(updateType string, update json.RawMessage) bool {
|
|
if updateType == "tool_call" || updateType == "tool_call_update" {
|
|
return true
|
|
}
|
|
return !opencodeACPChildUpdate(update)
|
|
},
|
|
onActivity: func() {
|
|
select {
|
|
case activity <- struct{}{}:
|
|
default:
|
|
}
|
|
},
|
|
onMessage: func(msg Message) {
|
|
if !streaming.Load() {
|
|
return
|
|
}
|
|
switch msg.Type {
|
|
case MessageText:
|
|
sawText.Store(true)
|
|
outputMu.Lock()
|
|
output.WriteString(msg.Content)
|
|
outputMu.Unlock()
|
|
case MessageToolUse:
|
|
sawTool.Store(true)
|
|
}
|
|
trySend(msgCh, msg)
|
|
},
|
|
onPromptDone: func(result hermesPromptResult) {
|
|
select {
|
|
case promptDone <- result:
|
|
default:
|
|
}
|
|
},
|
|
}
|
|
|
|
readerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(readerDone)
|
|
scanner := newAgentStreamScanner(stdout)
|
|
for scanner.Scan() {
|
|
if line := strings.TrimSpace(scanner.Text()); line != "" {
|
|
c.handleLine(line)
|
|
}
|
|
}
|
|
c.closeAllPending(errors.New("opencode process exited"))
|
|
}()
|
|
|
|
// stop tears the process down exactly once. A graceful stop closes stdin
|
|
// and lets OpenCode exit on EOF before signalling. A forced stop is for a
|
|
// run that was cancelled or timed out without OpenCode answering: closing
|
|
// stdin first releases a prompt write blocked on a full pipe (it holds the
|
|
// client's write lock), and the group is signalled at once, so an
|
|
// unresponsive runtime is gone within opencodeTerminateGrace instead of
|
|
// being waited on.
|
|
// promptSent flips once the whole prompt is in the pipe. Until then
|
|
// OpenCode has not received the turn, so there is nothing for
|
|
// session/cancel to stop.
|
|
var promptSent atomic.Bool
|
|
|
|
var stopOnce sync.Once
|
|
stop := func(force bool) {
|
|
stopOnce.Do(func() {
|
|
_ = stdin.Close()
|
|
if !force {
|
|
select {
|
|
case <-readerDone:
|
|
case <-time.After(opencodeACPExitGrace):
|
|
force = true
|
|
}
|
|
}
|
|
if force {
|
|
signalProcessGroup(cmd, syscall.SIGTERM)
|
|
select {
|
|
case <-readerDone:
|
|
case <-time.After(opencodeTerminateGrace()):
|
|
}
|
|
}
|
|
procCancel() // group SIGKILL; a no-op for a group that already exited
|
|
_ = cmd.Wait()
|
|
<-readerDone
|
|
<-stderrDone
|
|
releaseProcessGroup(cmd)
|
|
})
|
|
}
|
|
|
|
go func() {
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
defer cancel()
|
|
// Setup paths return early; a cancelled or timed-out setup is torn
|
|
// down without the graceful wait.
|
|
defer func() { stop(runCtx.Err() != nil) }()
|
|
|
|
// Until the prompt is fully delivered there is no turn for
|
|
// session/cancel to stop, and every request so far is a synchronous
|
|
// write that checks its context only after the write returns. A
|
|
// runtime that stops reading (a large session/new MCP payload, a
|
|
// large prompt) would hold this goroutine in that write, so the
|
|
// deferred stop above would never run. The watcher tears the process
|
|
// down as soon as the run ends in that window; closing stdin is what
|
|
// releases the blocked write. Once the prompt is delivered, the
|
|
// cancel path below owns the shutdown.
|
|
watcherDone := make(chan struct{})
|
|
defer close(watcherDone)
|
|
go func() {
|
|
select {
|
|
case <-runCtx.Done():
|
|
if !promptSent.Load() {
|
|
stop(true)
|
|
}
|
|
case <-watcherDone:
|
|
}
|
|
}()
|
|
|
|
startTime := time.Now()
|
|
result := func(status, errText, sessionID string, rejected bool) {
|
|
resCh <- Result{Status: status, Error: errText, SessionID: sessionID, ResumeRejected: rejected, DurationMs: time.Since(startTime).Milliseconds()}
|
|
}
|
|
|
|
initResult, err := c.request(runCtx, "initialize", map[string]any{
|
|
"protocolVersion": 1,
|
|
"clientInfo": map[string]any{"name": "multica-agent-sdk", "version": "0.2.0"},
|
|
"clientCapabilities": map[string]any{
|
|
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
|
|
"terminal": false,
|
|
},
|
|
})
|
|
if err != nil {
|
|
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "initialize", err, timeout, b.cfg.Logger)
|
|
result(status, errText, "", false)
|
|
return
|
|
}
|
|
mcpServers = filterACPMcpServersByCapability(mcpServers, extractACPMcpCapabilities(initResult), "opencode", b.cfg)
|
|
|
|
var sessionID string
|
|
var sessionResult json.RawMessage
|
|
if opts.ResumeSessionID != "" {
|
|
loaded, err := c.request(runCtx, "session/load", map[string]any{
|
|
"cwd": cwd,
|
|
"sessionId": opts.ResumeSessionID,
|
|
"mcpServers": mcpServers,
|
|
})
|
|
if err != nil {
|
|
status, errText, rejected := classifyACPResumeFailure(runCtx, "opencode", "session/load", err, timeout, b.cfg.Logger)
|
|
result(status, errText, "", rejected)
|
|
return
|
|
}
|
|
var changed bool
|
|
sessionID, changed = resolveResumedSessionID(opts.ResumeSessionID, loaded)
|
|
if changed {
|
|
b.cfg.Logger.Warn("opencode returned a different session id on resume; continuing with the new id",
|
|
"requested", opts.ResumeSessionID, "actual", sessionID)
|
|
}
|
|
sessionResult = loaded
|
|
} else {
|
|
created, err := c.request(runCtx, "session/new", map[string]any{
|
|
"cwd": cwd,
|
|
"mcpServers": mcpServers,
|
|
})
|
|
if err != nil {
|
|
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "session/new", err, timeout, b.cfg.Logger)
|
|
result(status, errText, "", false)
|
|
return
|
|
}
|
|
if sessionID = extractACPSessionID(created); sessionID == "" {
|
|
result("failed", "opencode session/new returned no session ID", "", false)
|
|
return
|
|
}
|
|
sessionResult = created
|
|
}
|
|
c.sessionID = sessionID
|
|
|
|
model, level := opencodeACPSplitModel(opts.Model, opts.ThinkingLevel)
|
|
options, err := opencodeACPSelectModel(runCtx, c.request, sessionID, model, sessionResult)
|
|
if err != nil {
|
|
status, errText, _ := classifyACPResumeFailure(runCtx, "opencode", "model selection", err, timeout, b.cfg.Logger)
|
|
keep := sessionID
|
|
if setupFailureWithholdsSessionID(opts) {
|
|
keep = ""
|
|
}
|
|
result(status, errText, keep, false)
|
|
return
|
|
}
|
|
if options != nil {
|
|
applyACPEffortOption(runCtx, c.request, "opencode", b.cfg.Logger, sessionID, options, level, true)
|
|
} else if level != "" {
|
|
b.cfg.Logger.Warn("opencode session advertises no configuration after model selection; ignoring thinking level", "thinkingLevel", level)
|
|
}
|
|
|
|
b.cfg.Logger.Info("opencode session ready", "session_id", sessionID, "resumed", opts.ResumeSessionID != "")
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running", SessionID: sessionID})
|
|
|
|
// OpenCode reads the runtime brief from the AGENTS.md the daemon writes
|
|
// into the workdir, so SystemPrompt is not prepended — same as the run
|
|
// transport (MUL-5392).
|
|
streaming.Store(true)
|
|
turnStart := time.Now()
|
|
type promptOutcome struct{ err error }
|
|
promptCh := make(chan promptOutcome, 1)
|
|
go func() {
|
|
_, err := c.requestAndNotifySent(context.Background(), "session/prompt", map[string]any{
|
|
"sessionId": sessionID,
|
|
"prompt": []map[string]any{{"type": "text", "text": prompt}},
|
|
}, func() { promptSent.Store(true) })
|
|
promptCh <- promptOutcome{err: err}
|
|
}()
|
|
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
var promptErr error
|
|
answered := false
|
|
select {
|
|
case out := <-promptCh:
|
|
promptErr, answered = out.err, true
|
|
case <-runCtx.Done():
|
|
if !promptSent.Load() {
|
|
b.cfg.Logger.Warn("opencode: run ended before the prompt was delivered; stopping the process")
|
|
break
|
|
}
|
|
// Ask OpenCode to stop the turn; it aborts the running tool and
|
|
// answers the prompt. The write runs on its own goroutine so a
|
|
// stalled pipe cannot hold the teardown, and the grace starts now.
|
|
go func() {
|
|
if err := opencodeACPNotify(c, "session/cancel", map[string]any{"sessionId": sessionID}); err != nil {
|
|
b.cfg.Logger.Warn("opencode: session/cancel could not be sent", "error", err)
|
|
}
|
|
}()
|
|
select {
|
|
case out := <-promptCh:
|
|
promptErr, answered = out.err, true
|
|
case <-time.After(opencodeACPCancelGrace):
|
|
b.cfg.Logger.Warn("opencode did not answer session/cancel in time; stopping the process", "grace", opencodeACPCancelGrace.String())
|
|
}
|
|
}
|
|
|
|
var pr hermesPromptResult
|
|
havePrompt := false
|
|
if answered && promptErr == nil {
|
|
select {
|
|
case pr = <-promptDone:
|
|
havePrompt = true
|
|
default:
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case runCtx.Err() == context.DeadlineExceeded:
|
|
finalStatus, finalError = "timeout", fmt.Sprintf("opencode timed out after %s", timeout)
|
|
case runCtx.Err() == context.Canceled:
|
|
finalStatus, finalError = "aborted", "execution cancelled"
|
|
case promptErr != nil:
|
|
finalStatus, finalError = "failed", fmt.Sprintf("opencode session/prompt failed: %v", promptErr)
|
|
case havePrompt:
|
|
switch pr.stopReason {
|
|
case "", "end_turn":
|
|
case "cancelled":
|
|
finalStatus, finalError = "aborted", "opencode cancelled the turn"
|
|
case "max_tokens":
|
|
finalStatus, finalError = "failed", "opencode stopped: the model hit its output length limit"
|
|
case "refusal":
|
|
finalStatus, finalError = "failed", "opencode stopped: the provider refused the request"
|
|
default:
|
|
finalStatus, finalError = "failed", "opencode stopped: "+pr.stopReason
|
|
}
|
|
}
|
|
if answered && runCtx.Err() == nil {
|
|
waitForACPNotificationQuiescence(runCtx, activity, readerDone, acpNotificationQuietTime, opencodeACPDrainGrace)
|
|
}
|
|
streaming.Store(false)
|
|
// A turn OpenCode never answered is forced down; an answered one,
|
|
// including an acknowledged cancel, exits gracefully.
|
|
stop(!answered)
|
|
providerErr.Finalize()
|
|
|
|
outputMu.Lock()
|
|
finalOutput := output.String()
|
|
outputMu.Unlock()
|
|
finalStatus, finalError = promoteACPResultOnProviderError(finalStatus, finalError, finalOutput, providerErr)
|
|
|
|
usage, usageErr := opencodeACPTurnUsage(b.cfg, execPath, env, cwd, sessionID, turnStart, usesV2)
|
|
if usageErr != nil {
|
|
usage = TokenUsage{}
|
|
// Fall back to what the prompt reported only where that figure is
|
|
// not an overcount: 1.x reports one message (an undercount), and a
|
|
// fresh 2.x session's total is this turn.
|
|
if havePrompt && (!usesV2 || opts.ResumeSessionID == "") {
|
|
usage = pr.usage.TokenUsage
|
|
usage.CostUSDTicks = 0
|
|
}
|
|
b.cfg.Logger.Warn("opencode: could not read this turn's token usage from the session export", "error", usageErr, "fallback", acpTokenUsagePresent(usage))
|
|
}
|
|
|
|
// The run transport's empty-step guard (#6522): a turn that closes
|
|
// cleanly with no text, no tool call and no usage at all never reached
|
|
// the provider, and must not be reported as a success.
|
|
if finalStatus == "completed" && usageErr == nil && !sawText.Load() && !sawTool.Load() && !acpTokenUsagePresent(usage) {
|
|
finalStatus = "failed"
|
|
finalError = "opencode turn ended with no text, no tool call and no reported usage — the provider produced nothing"
|
|
}
|
|
|
|
var usageMap map[string]TokenUsage
|
|
if acpTokenUsagePresent(usage) {
|
|
name := opts.Model
|
|
if name == "" {
|
|
name = "unknown"
|
|
}
|
|
usageMap = map[string]TokenUsage{name: usage}
|
|
}
|
|
duration := time.Since(startTime)
|
|
b.cfg.Logger.Info("opencode finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: finalOutput,
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: sessionID,
|
|
Usage: usageMap,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
// opencodeACPNotify writes a JSON-RPC notification (no id, no response).
|
|
func opencodeACPNotify(c *hermesClient, method string, params any) error {
|
|
data, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.writeLine(append(data, '\n'))
|
|
}
|
|
|
|
// opencodeACPTurnUsage sums the token usage of the assistant messages this turn
|
|
// created, read from the session export after the process has exited.
|
|
//
|
|
// Each OpenCode step is its own assistant message with its own provider call,
|
|
// so the turn's usage is their sum — the same figure the run transport adds up
|
|
// from step_finish events. The turn's messages are the ones created at or after
|
|
// turnStart; the export's timestamps come from the same host clock.
|
|
func opencodeACPTurnUsage(cfg Config, execPath string, env []string, cwd, sessionID string, turnStart time.Time, usesV2 bool) (TokenUsage, error) {
|
|
if sessionID == "" || strings.HasPrefix(sessionID, "-") {
|
|
return TokenUsage{}, errors.New("no session id")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), opencodeACPUsageTimeout)
|
|
defer cancel()
|
|
args := []string{"export", sessionID}
|
|
if usesV2 {
|
|
// --standalone keeps the export off the resident background service:
|
|
// this run never started one, and should not leave one behind.
|
|
args = []string{"session", "export", sessionID, "--standalone"}
|
|
}
|
|
run := func(args []string) ([]byte, error) {
|
|
cmd := cfg.commandAt(execPath).exec(ctx, args...)
|
|
hideAgentWindow(cmd)
|
|
cmd.Dir, cmd.Env = cwd, env
|
|
cmd.Stderr = &tailBuffer{max: 4096}
|
|
limited := &limitedBuffer{max: openCodeClockExportMaxBytes}
|
|
cmd.Stdout = limited
|
|
err := runOwned(cmd, cfg.Logger)
|
|
if limited.overflow {
|
|
return nil, errors.New("session export exceeds the size limit")
|
|
}
|
|
return limited.buf.Bytes(), err
|
|
}
|
|
// --sanitize redacts message text, which this never needs. Older CLIs
|
|
// lack the flag; retry without it.
|
|
data, err := run(append(args, "--sanitize"))
|
|
if err != nil {
|
|
data, err = run(args)
|
|
}
|
|
if err != nil {
|
|
return TokenUsage{}, fmt.Errorf("session export: %w", err)
|
|
}
|
|
return opencodeExportTurnUsage(data, turnStart.UnixMilli(), opencodeSeparatesReasoning(cfg))
|
|
}
|
|
|
|
// opencodeExportTurnUsage reads both export shapes: 1.x nests each message's
|
|
// metadata under `info` with a `role`; 2.x flattens it and names the kind
|
|
// `type`.
|
|
func opencodeExportTurnUsage(data []byte, sinceMs int64, separateReasoning bool) (TokenUsage, error) {
|
|
type msgTime struct {
|
|
Created int64 `json:"created"`
|
|
}
|
|
type meta struct {
|
|
Role string `json:"role"`
|
|
Type string `json:"type"`
|
|
Time msgTime `json:"time"`
|
|
Tokens *opencodeTokens `json:"tokens"`
|
|
}
|
|
var export struct {
|
|
Messages []struct {
|
|
meta
|
|
Info *meta `json:"info"`
|
|
} `json:"messages"`
|
|
}
|
|
start := bytes.IndexByte(data, '{')
|
|
if start < 0 {
|
|
return TokenUsage{}, errors.New("session export is not JSON")
|
|
}
|
|
if err := json.Unmarshal(data[start:], &export); err != nil {
|
|
return TokenUsage{}, fmt.Errorf("parse session export: %w", err)
|
|
}
|
|
var usage TokenUsage
|
|
for _, m := range export.Messages {
|
|
info := m.meta
|
|
if m.Info != nil {
|
|
info = *m.Info
|
|
}
|
|
if info.Role != "assistant" && info.Type != "assistant" {
|
|
continue
|
|
}
|
|
if info.Time.Created < sinceMs || info.Tokens == nil {
|
|
continue
|
|
}
|
|
t := info.Tokens
|
|
usage.InputTokens += t.Input
|
|
usage.OutputTokens += t.Output
|
|
if separateReasoning {
|
|
usage.OutputTokens += t.Reasoning
|
|
}
|
|
if t.Cache != nil {
|
|
usage.CacheReadTokens += t.Cache.Read
|
|
usage.CacheWriteTokens += t.Cache.Write
|
|
}
|
|
}
|
|
return usage, nil
|
|
}
|
|
|
|
// limitedBuffer keeps at most max bytes and records whether more arrived.
|
|
type limitedBuffer struct {
|
|
buf bytes.Buffer
|
|
max int
|
|
overflow bool
|
|
}
|
|
|
|
func (l *limitedBuffer) Write(p []byte) (int, error) {
|
|
if room := l.max - l.buf.Len(); room < len(p) {
|
|
l.overflow = true
|
|
if room > 0 {
|
|
l.buf.Write(p[:room])
|
|
}
|
|
return len(p), nil
|
|
}
|
|
return l.buf.Write(p)
|
|
}
|