Skip to content

feat: multi-agent support (milestone 1) — agent abstraction + default drop-in provisioning - #46

Open
neurolabs wants to merge 21 commits into
mainfrom
opencode/support-multiple-agents
Open

feat: multi-agent support (milestone 1) — agent abstraction + default drop-in provisioning#46
neurolabs wants to merge 21 commits into
mainfrom
opencode/support-multiple-agents

Conversation

@neurolabs

Copy link
Copy Markdown
Member

Summary

Introduces the internal/agent abstraction of built-in coding-agent profiles with optional capabilities discovered by type assertion (DaemonProvider, UpgradeChecker, ConfigMerger, AttachRunner, Provisioner), plus gitignore-based default drop-in provisioning that copies the active agent's config + credential files from the host into the VM. Adds a --agent CLI flag, renames --opencode-version to --agent-version (deprecated alias kept), and rejects --worktree/--serve-only for agents lacking a daemon.

Milestone 1 registers only opencode (fully backward compatible); the seam paves the way for future agents (pi, claude).

What changed

  • internal/agent (new): registry (Register/Lookup/Names), capability interfaces, ImageSpec, opencode profile, and a gitignore-based provisioning manifest evaluator (go-git).
  • --agent flag (run/build/volume), default opencode, validated against agent.Names().
  • --agent-version replaces --opencode-version (kept as a deprecated alias).
  • Default drop-in provisioning: copies the active agent's config + credential files host→VM per per-agent include-list manifests. Security note: opencode auth.json is now copied by default; use the env-secret mechanism (env.secret) to opt out. The env-secret channel is unchanged.
  • Config merge is pattern-based: snippets must match opencode-*.json* (a bare opencode.json no longer merges by default); YAML snippet patterns supported.
  • Agent-dimensioned config dirs in configpaths.
  • Refactored image/upgrade/daemon/worktree/session/reprovision/CLI to drive through the agent interface.

Breaking change

--opencode-version renamed to --agent-version (deprecated alias retained).

Test

make check green (fmt, lint, tests).

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.96188% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.63%. Comparing base (e78b4f9) to head (2f34825).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
internal/sandbox/reprovision/config_files.go 87.73% 8 Missing and 5 partials ⚠️
cmd/opencode-sandbox/commands_system.go 82.14% 5 Missing and 5 partials ⚠️
internal/configmerge/opencodeconfig.go 88.15% 5 Missing and 4 partials ⚠️
internal/sandbox/vm/upgrade.go 75.00% 5 Missing ⚠️
internal/sandbox/vm/run_orchestrate.go 70.00% 2 Missing and 1 partial ⚠️
internal/agent/latest.go 90.00% 1 Missing and 1 partial ⚠️
internal/agent/opencode_release.go 89.47% 1 Missing and 1 partial ⚠️
internal/sandbox/image/fetch.go 80.00% 1 Missing and 1 partial ⚠️
internal/sandbox/reprovision/reprovision.go 84.61% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #46      +/-   ##
==========================================
+ Coverage   88.83%   90.63%   +1.80%     
==========================================
  Files          88       99      +11     
  Lines        5553     6014     +461     
==========================================
+ Hits         4933     5451     +518     
+ Misses        431      365      -66     
- Partials      189      198       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@neurolabs

Copy link
Copy Markdown
Member Author

Relates to #37

DaemonKillCmd() string
DaemonHealthCmd() string
DaemonHealthParse(stdout string) (bool, error)
WorktreeListCmd() string

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worktree and daemon don't depend on each other, split.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split DaemonProvider into two independent capabilities in internal/agent/capabilities.go: DaemonProvider (daemon lifecycle: start/kill/health) and WorktreeProvider (worktree list/create/parse), each with its own type-assertion helper (AsDaemonProvider / AsWorktreeProvider). --serve-only now requires DaemonProvider; --worktree requires WorktreeProvider, so an agent can implement either independently. opencode implements both, so default behavior is unchanged.

Comment thread internal/agent/opencode.go Outdated

func (opencodeProfile) ImageSpec() ImageSpec {
return ImageSpec{
VersionArg: "OPENCODE_VERSION",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be toUpper($name)_VERSION

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Derived via versionArgFor(name) = strings.ToUpper(name) + "_VERSION" in internal/agent/image.go. opencode still yields OPENCODE_VERSION.

Comment thread internal/agent/opencode.go Outdated
func (opencodeProfile) ImageSpec() ImageSpec {
return ImageSpec{
VersionArg: "OPENCODE_VERSION",
VersionLabel: "org.opencode-sandbox.opencode-version",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also derivable from name

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Derived via versionLabelFor(name) = "org.opencode-sandbox." + name + "-version". opencode still yields org.opencode-sandbox.opencode-version.

Comment thread internal/agent/opencode.go Outdated
return ImageSpec{
VersionArg: "OPENCODE_VERSION",
VersionLabel: "org.opencode-sandbox.opencode-version",
DisableUpdateEnv: "OPENCODE_DISABLE_AUTOUPDATE",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a generic map for environment changes? Maybe multiple vars must be set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImageSpec.AgentEnv (renamed from the single-string DisableUpdateEnv) is now a generic map[string]string, and DockerfileFromImageSpec emits one ENV k=v line per entry. Multiple vars can now be set; opencode renders identically (ENV OPENCODE_DISABLE_AUTOUPDATE=true).

Comment thread internal/agent/opencode.go Outdated
}

func (opencodeProfile) LatestVersion(ctx context.Context) (string, error) {
return opencode.LatestVersion(ctx)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should internal/opencode and opencode.go be moved&merged to internal/agent/opencode?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

internal/opencode was merged into the opencode profile: the release checker now lives in internal/agent/opencode_release.go (latestOpenCodeVersion, newerOpenCodeThan) in the same package, and the internal/opencode package is deleted. Kept in the same package rather than an internal/agent/opencode subpackage to avoid an import cycle, since the subpackage would need to import the parent agent package.

Comment thread internal/sandbox/image/fetch.go Outdated
// fall back to the requested (or empty) version.
func resolveAgentVersion(ctx context.Context, a agent.Agent, requested string) (string, error) {
if _, ok := agent.AsUpgradeChecker(a); ok {
return resolveOpenCodeVersion(ctx, requested)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the agent param, not hardcode opencode

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveAgentVersion(ctx, a agent.Agent, requested) now resolves the latest version via the agent's own UpgradeChecker (falling back to the requested/empty version when the agent implements none), instead of hardcoding opencode's resolver. The vm upgrade path (agentLatestVersion) was updated the same way.

// error. Agents that implement ConfigMerger use their own snippet pattern and
// VM config path; other agents fall back to the opencode snippet behavior.
func buildMergedConfig(a agent.Agent, vmHome string) (string, []byte, bool, error) {
if cm, ok := agent.AsConfigMerger(a); ok {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't hardcode opencode in the body

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded opencode auth.json removal path was replaced with an agent-driven walk: provisionDestinations derives the removal set from the agent's own ProvisionRules via agent.EvalProvisionRules. For opencode this still yields .local/share/opencode/auth.json (and the config-file family), but no opencode-specific path is hardcoded.

Comment thread internal/sandbox/vm/reconfig.go Outdated
boot vmBoot,
) (string, error) {
ui.Verbosef("expected config files: %v", cfs.Keys)
a, _ := agent.Lookup("")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the defined agent here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setUpSandbox now resolves the agent from opts.Agent via agent.Lookup(opts.Agent) (with an explicit "unknown agent" error) instead of agent.Lookup(""), so the configured agent is threaded through VM orchestration.

Comment thread internal/sandbox/vm/run_orchestrate.go Outdated
) (*Session, error) {
projectSlug := git.ProjectSlug()

a, _ := agent.Lookup("")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the ocnfigured agent here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same fix in run_orchestrate.go: PrepareSandbox resolves the agent from opts.Agent instead of agent.Lookup("").

Comment thread docs/configuration.md
opencode-sandbox provisions a single opencode config into the VM at `/home/dev/.config/opencode/opencode.json`. No embedded
provider or permission config is shipped with opencode-sandbox. Instead, opencode config is assembled from snippet files
under `~/.config/opencode-sandbox/opencode/` (user) and `.opencode-sandbox/opencode/` (project):
opencode-sandbox is agent-aware. A `--agent <name>` flag on `run`, `build`, and the `volume` subcommands selects the

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Next to flag, also config.yaml and ENV VAR

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The agent is now also selectable via the agent launcher config key and the OPENCODE_SANDBOX_AGENT env var, in addition to the --agent flag, with precedence flag > env > config > default. Documented in docs/configuration.md (config-key and env-var tables) and CHANGELOG.md.

@neurolabs neurolabs left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

config subcommand needs respec and rework.

@neurolabs

Copy link
Copy Markdown
Member Author

How can a user switch to provisioning from opencode-sandbox/ configuration instead of using the config?

Address all review comments on PR #46 for multi-agent milestone 1:
- Thread the configured agent through VM orchestration and version
  resolution instead of hardcoding agent.Lookup("") / opencode's resolver
- Split DaemonProvider into daemon and worktree capabilities so an agent
  can implement either independently
- Make ImageSpec fields (version arg/label) derivable from the agent name;
  generalize DisableUpdateEnv to a map
- Merge internal/opencode into the opencode profile
- Rename internal/opencodeconfig to generic internal/configmerge
- Allow selecting the agent via the agent config key and
  OPENCODE_SANDBOX_AGENT env var
- Replace config show with agent-aware config agent <name>, which lists
  merged snippet config and host drop-in files (merged/not merged)
- Fix hardcoded opencode auth.json removal via an agent-driven walk
- Update docs and changelog

Behavior for the default opencode agent is unchanged except the intended
breaking CLI rename (config show -> config agent) and the additive agent
config/env surface.
@neurolabs

Copy link
Copy Markdown
Member Author

All review comments addressed in commit a310c29 (multi-agent milestone 1 review cleanup).

Reviewer replies (inline)

  • worktree/daemon split (capabilities.go) — resolved inline
  • toUpper version arg / derivable label / env map (opencode.go) — resolved inline
  • internal/opencode merge — resolved inline
  • opencodeconfig split into generic configmerge — resolved inline
  • use agent param in fetch.go — resolved inline
  • don't hardcode opencode in config_files.go — resolved inline
  • use configured agent in reconfig.go / run_orchestrate.go — resolved inline
  • config.yaml + env var for --agent — resolved inline

PR-level: config subcommand respec

Reworked 'config show' into an agent-aware 'config agent [name]' command. It prints the agent's merged snippet config AND the host drop-in files, each marked 'merged' or 'not merged'. [name] defaults to the configured agent. 'config home' is unchanged.

How to switch to snippet provisioning

Creating snippet files that match the agent's snippet pattern (e.g. opencode-.json) makes the snippet merge win over the host config: when snippets exist, the drop-in copy of the config-file family is skipped and the merged snippet config is provisioned. To disable the native-config drop-in entirely (so only the snippet merge and home.yaml mappings apply), set 'provision-host-config: false' in the launcher config. This is documented in the 'Default drop-in provisioning' section of docs/configuration.md.

Register built-in pi (@earendil-works/pi-coding-agent) and claude-code
(@anthropic-ai/claude-code) agent profiles alongside opencode. Both run
interactively (no daemon), so --worktree/--serve-only are rejected at
flag-parse time via the existing capability check. Both implement an
UpgradeChecker (pi via pi.dev, claude-code via the npm registry latest
dist-tag) and a ConfigMerger writing settings*.json* snippets to
~/.pi/agent/settings.json / ~/.claude/settings.json.

- Move Node.js install before the agent install block so npm-based
  agents install with npm install -g.
- Parameterize reprovision config-family detection via
  ConfigMerger.ConfigFileNames and ConfigFiles.MergedPath so merged-config
  detection and 'config agent' output are correct for pi/claude.
- Share the semver compare and JSON latest-version fetch helpers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant