agentwrapper

package module
v0.0.0-...-345d077 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 12, 2026 License: Apache-2.0 Imports: 19 Imported by: 5

README

agentwrapper - Agent Wrapper & CLI Abstraction Package

agentwrapper provides a unified Go interface (CLIClient and SandboxSpec) to integrate, manage, and execute external CLI AI coding agents (such as agy, claude, and opencode) within Asgard.


1. Overview & Architecture

Is all logic contained in agentwrapper?

No, not exclusively, but agentwrapper is the core provider of CLI agent abstractions.

Here is the breakdown of responsibilities across Asgard:

  1. Core Package (backend/agentwrapper/):

    • Defines standard interfaces (CLIClient, SandboxSpec, ReportFunc) in types/types.go.
    • Implements agent sub-packages (backend/agentwrapper/agy, backend/agentwrapper/opencode, etc.).
    • Registers available CLI agents (defaultClients in supported.go).
    • Provides global functions for querying models (GetSupportedCLIsAndModels), checking quota (GetQuota, CheckQuota), and retrieving sandbox specifications (GetSandboxSpec).
    • Validates user setup/authentication files (validate.go).
  2. Outside agentwrapper (Where else logic resides):

    • lib/bwrap/: Renders the AW_AGENTS.md contract (agent identity metadata + prompt body) and bind-mounts it at the canonical /session/AW_AGENTS.md path; consumes types.SandboxSpec via agentwrapper.GetSandboxSpec(cliName) for skills mounts, directory binds, authentication directories, and extra args.
    • cmd/asgard/main.go: Invokes validation functions (e.g., agentwrapper.ValidateAgySetup(), agentwrapper.ValidateClaudeSetup(), agentwrapper.ValidateOpencodeSetup()) at server startup when no runner section is configured (with runners, the runners own the CLI credentials and the backend skips this validation).
    • backend/agentwrapper/cmd/aw/: Asgard's CLI tool (aw). Each sub-command (e.g., backend/agentwrapper/cmd/aw/commands/agy.go, claude.go, opencode.go) exposes a CLI command that invokes the agent's Prompt and Usage methods. Inside the sandbox, aw loads the AW_AGENTS.md contract (agentwrapper/common) and translates it into CLI-native agent definitions (opencode agents/<id>.md, simplest custom prompt, agy GEMINI.md, claude --append-system-prompt-file).
    • lib/agents/run/: Handles runtime agent execution within Bubblewrap sandboxes and performs quota checks using agentwrapper.CheckQuota(cli, model).
    • lib/api/: Serves /api/quota by asking the runners (runnerfs.QueryQuota); the runner's QueryQuota service calls agentwrapper.GetQuota(ctx) in its own sandbox. The backend never queries the vendor CLIs itself (503 when no runner is configured).

2. Interface Definitions (types/types.go)

To support a new CLI agent, the agent implementation must satisfy the CLIClient interface, and optionally the SandboxSpec interface if running under Bubblewrap isolation.

CLIClient Interface
type CLIClient interface {
    Usage(ctx context.Context, opts UsageOptions) ([]ModelUsage, error)
    Models(ctx context.Context, opts UsageOptions) ([]string, error)
    Prompt(ctx context.Context, prompt string, opts PromptOptions) (*PromptResult, error)
}
type SandboxSpec interface {
    SystemPromptHeader() string
    SystemPromptPeerHeader() string
    SkillsMountPath(home string) string
    MountDirectories(home string) []string
    AuthDirectory(home string) string
    AuthFiles(home string) []string
    ExtraEnv(home string) map[string]string
    ExtraArgs() []string
}

Agent identity is NOT delivered through SandboxSpec. Instead, the host renders the AW_AGENTS.md contract (agent id/name/team metadata in YAML frontmatter plus the prompt body) and bind-mounts it at the canonical /session/AW_AGENTS.md path ($AW_AGENTS_PATH). The aw subcommand running inside the sandbox loads the contract via agentwrapper/common and translates it into CLI-native form:

  • opencode: writes ~/.config/opencode/agents/<agent-id>.md (frontmatter with mode: primary and permission rules derived from the contract) and runs opencode run --agent <agent-id>, so the contract identity replaces opencode's built-in build persona instead of arriving as weak ambient instructions.

  • simplest: composes the in-process system prompt from the CLI protocol headers plus the contract body.

  • claude: passes the composed prompt via --append-system-prompt-file (a temp file) and derives --disallowedTools from the contract (AskUserQuestion always, Task/Agent for team agents). Runs claude --print --verbose --output-format stream-json --dangerously-skip-permissions (the sandbox must not run as root); a new session gets a generated --session-id, a given one uses --resume, and a /low|medium|high|xhigh|max model suffix maps to --effort. Quota comes from parsing the text output of /usage.

    The sandbox sets CLAUDE_CONFIG_DIR=~/.claude (via SandboxSpec.ExtraEnv, also baked into the Docker image) so claude's .claude.json lives inside the bound ~/.claude directory: claude rewrites that file with a rename, which would detach a single-file bind mount. Log in with the same variable set (docker exec -it -e CLAUDE_CONFIG_DIR=<home>/.claude <container> claude). DISABLE_AUTOUPDATER=1 is set too; updates come from rebuilding the image.

    Known limitations of the claude adapter:

    • Subscription accounts only: quota comes from /usage, so API-key, Bedrock and Vertex accounts fail the quota check. Only the fable, opus, sonnet and haiku aliases are supported.
    • Subagent messages (lines with parent_tool_use_id) are reported as if they came from the main agent, and they also overwrite the reported input_tokens, so the figure can flap while subagents run (and end up as a subagent's context if one finishes after the parent's last message).
  • agy: installs the composed prompt into ~/.gemini/GEMINI.md for the duration of the run and restores the previous content afterwards.

To support a new CLI, the adapter only needs a translation step inside aw; no sandbox mount code changes are required. aw derives the agent identity directly from the AW_AGENTS.md contract (/session/AW_AGENTS.md) without requiring an --agent flag from the host. For CLIs like opencode, aw constructs and passes --agent <agent-id> directly to opencode run.


3. Step-by-Step Guide: How to Add a New CLI Agent

Follow these steps to add support for a new CLI agent (e.g., myagent).

Step 1: Create a Sub-package under backend/agentwrapper/

Create a new directory backend/agentwrapper/myagent/ and implement the CLIClient (and optionally SandboxSpec) interface.

Example (backend/agentwrapper/myagent/client.go):

package myagent

import (
    "context"
    "github.com/AgentDrasil/asgard/agentwrapper/types"
)

type Client struct{}

func NewClient() *Client {
    return &Client{}
}

func (c *Client) Usage(ctx context.Context, opts types.UsageOptions) ([]types.ModelUsage, error) {
    // Implement quota / usage checking logic
    return []types.ModelUsage{}, nil
}

func (c *Client) Models(ctx context.Context, opts types.UsageOptions) ([]string, error) {
    // Return list of supported model names
    return []string{"myagent-v1"}, nil
}

func (c *Client) Prompt(ctx context.Context, prompt string, opts types.PromptOptions) (*types.PromptResult, error) {
    // Implement prompt execution logic (e.g., executing the CLI binary)
    return &types.PromptResult{}, nil
}

// SandboxSpec implementation
func (c *Client) SystemPromptHeader() string {
    return "## Important Instructions\n..."
}

func (c *Client) SystemPromptPeerHeader() string {
    return "## Peer Collaboration Instructions\n..."
}

func (c *Client) SkillsMountPath(home string) string {
    return home + "/.myagent/skills"
}

func (c *Client) MountDirectories(home string) []string {
    return []string{home + "/.myagent"}
}

func (c *Client) AuthDirectory(home string) string {
    return home + "/.myagent"
}

func (c *Client) ExtraArgs() []string {
    return nil
}
Step 2: Register the New Client in backend/agentwrapper/supported.go

Register your new client instance in the defaultClients map:

import (
    "github.com/AgentDrasil/asgard/agentwrapper/myagent"
)

var defaultClients = map[string]types.CLIClient{
    "agy":      &agy.Client{},
    "claude":   &claude.Client{},
    "opencode": &opencode.Client{},
    "myagent":  &myagent.Client{}, // <-- Register here
}
Step 3: Add Setup Validation (Optional)

If your agent requires specific local credentials/configs, add a validation function in backend/agentwrapper/validate.go:

func ValidateMyAgentSetup() error {
    home, err := homeDirFn()
    if err != nil {
        return fmt.Errorf("failed to get user home directory: %w", err)
    }
    authPath := filepath.Join(home, ".myagent", "credentials.json")
    if _, err := os.Stat(authPath); err != nil {
        return fmt.Errorf("myagent setup validation failed: missing auth file %s", authPath)
    }
    return nil
}

Then call this validation in cmd/asgard/main.go during startup:

if err := agentwrapper.ValidateMyAgentSetup(); err != nil {
    log.Fatal().Err(err).Msg("MyAgent setup validation failed")
}
Step 4: Add CLI Command to aw Tool

Create backend/agentwrapper/cmd/aw/commands/myagent.go to expose the Cobra command for aw myagent:

package commands

import (
    "github.com/spf13/cobra"
    "github.com/AgentDrasil/asgard/agentwrapper/myagent"
)

var myagentCmd = &cobra.Command{
    Use:   "myagent",
    Short: "Run a myagent session",
    RunE: func(cmd *cobra.Command, args []string) error {
        // Implement CLI flags parsing and call myagent.Prompt / myagent.Usage
        return nil
    },
}

func init() {
    // Register flags...
}

Register myagentCmd in backend/agentwrapper/cmd/aw/commands/root.go:

rootCmd.AddCommand(myagentCmd)
Mid-run message injection (inject socket)

aw claude and aw agy listen on a unix socket so the host can push a user message into the running session. The per-run host socket directory is bind-mounted at /fakebash in the agent sandbox, so aw listens on /fakebash/inject.sock (override with ASGARD_INJECT_SOCK; outside the sandbox, with no override and no /fakebash, the feature is off) and the host dials <sockDir>/inject.sock. opencode and simplest do not listen, so connecting fails and the caller queues the message instead.

Protocol (package agentwrapper/inject): the client writes one JSON line {"prompt":"..."} (max 16 MiB) and the server replies with one line after the injection returned, then closes: {"ok":true} or {"ok":false,"reason":"not_injectable"|"bad_request"|"internal","error":"..."}. inject.Send returns nil on success and an error wrapping types.ErrNotInjectable when the message was not delivered and should be queued (run over, not yet started, or no listener, which also wraps inject.ErrUnavailable).


4. Testing

Run all unit tests across agentwrapper to ensure compatibility:

go test ./backend/agentwrapper/...

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CheckQuota

func CheckQuota(cli string, model string) float64

CheckQuota returns the headline remaining quota fraction of a target.

func GetCLIQuota

func GetCLIQuota(ctx context.Context, cli string) (map[string][]types.ModelUsage, error)

GetCLIQuota returns the quota of a single CLI keyed by its name, without the sandbox query runner. It backs `aw usage --cli`.

func GetModelContextWindow

func GetModelContextWindow(cli, model string) int

GetModelContextWindow resolves the context window for a model given the CLI type. It iterates through ModelCandidates(cli, model) and returns the first known limit. For simplest, the user's simplest config is consulted (same source as `aw models`) so custom models pick up their configured contextWindow without hardcoding. If none are known, it returns types.DefaultContextWindow (1M).

func GetQuota

func GetQuota(ctx context.Context) (map[string][]types.ModelUsage, error)

func GetRegisteredCLIs

func GetRegisteredCLIs() []string

GetRegisteredCLIs returns all registered CLI names.

func GetSandboxSpec

func GetSandboxSpec(cli string) types.SandboxSpec

GetSandboxSpec returns the SandboxSpec for the given CLI if implemented, or nil.

func GetSupportedCLIsAndModels

func GetSupportedCLIsAndModels() map[string][]string

func HasModelVariant

func HasModelVariant(cli, model string) bool

HasModelVariant reports whether the model string carries a recognized variant (thinking level) suffix for the given CLI.

func IsKnownModel

func IsKnownModel(cli, model string) bool

IsKnownModel reports whether the model is cataloged for the given CLI, accounting for variant suffixes (e.g. "zai-coding-plan/glm-5.3/low" or "gemini-3.7-flash-low"). For simplest, models defined in the user's simplest config (provider/id or bare id) are also considered known, because the simplest runtime uses model.ContextWindow from that config instead of the hardcoded table.

func LookupContextWindow

func LookupContextWindow(model string) (limit int, known bool)

LookupContextWindow delegates directly to types.LookupContextWindow.

func MatchesModel

func MatchesModel(cli, model, known string) bool

MatchesModel reports whether the requested model matches a known model name, accounting for opencode/agy/simplest variant suffixes (e.g. "provider/model/low" or "gemini-3.7-flash-low" matches the known model "provider/model" or "gemini-3.7-flash").

func ModelCandidates

func ModelCandidates(cli, model string) []string

ModelCandidates returns the model names that the requested model string should be matched against, most specific first. For opencode, agy, and simplest, a model with a variant suffix also matches its base model name.

func SetAccountUsageProvider

func SetAccountUsageProvider(p AccountUsageProvider)

SetAccountUsageProvider makes CheckTarget consider every account of a CLI instead of only the first one. Passing nil restores the plain query.

func SetClients

func SetClients(c map[string]types.CLIClient)

SetClients overrides the CLI clients. Passing nil restores the defaults.

func SetQueryRunner

func SetQueryRunner(r QueryRunner)

SetQueryRunner makes models and usage queries go through r instead of invoking the vendor CLIs in-process. Vendor CLIs (agy, claude) implement these queries with non-interactive prompts, which can fire their hooks, so the host sets a runner that confines them to the sandbox. Passing nil restores in-process queries.

func ValidateAgySetup

func ValidateAgySetup() error

ValidateAgySetup verifies that agy is correctly set up on the runner. It passes if at least one account under accounts.Root() has an antigravity-oauth-token, or else (legacy) if ~/.gemini/antigravity-cli/antigravity-oauth-token exists.

func ValidateClaudeSetup

func ValidateClaudeSetup() error

ValidateClaudeSetup verifies that claude is correctly set up on the runner. It passes if at least one account under accounts.Root() has a .credentials.json, or else (legacy) if ~/.claude/.credentials.json exists.

func ValidateOpencodeSetup

func ValidateOpencodeSetup() error

ValidateOpencodeSetup verifies that opencode is correctly set up on the user's system. It checks if opencode.aw.yaml exists and has auth/env keys (or opencode.LoadEnv() has any key), or if ~/.local/share/opencode/opencode.db exists.

func ValidateSimplestSetup

func ValidateSimplestSetup() error

ValidateSimplestSetup verifies that simplest is correctly set up on the user's system. It checks $SIMPLEST_KEY_PATH, $SIMPLEST_CONFIG_PATH, ~/.config/simplest/key.yaml, ~/.simplest/key.yaml, etc. If a config file is present, it validates that it can be parsed and loaded. If no config file is present, it checks for fallback environment variables (GEMINI_API_KEY / OPENAI_API_KEY).

Types

type AccountUsage

type AccountUsage struct {
	Account string
	Usages  []types.ModelUsage
}

AccountUsage is the quota one account reported for its CLI.

type AccountUsageProvider

type AccountUsageProvider func(ctx context.Context, cli string) (accounts []AccountUsage, ok bool)

AccountUsageProvider returns the quota of every account of a CLI that answered within ctx. ok is false when the CLI has no multi-account setup to consider (fewer than two accounts, or an account override), in which case the plain query is used.

type FakeClient

type FakeClient struct {
	UsageFunc  func(ctx context.Context, opts types.UsageOptions) ([]types.ModelUsage, error)
	ModelsFunc func(ctx context.Context, opts types.UsageOptions) ([]string, error)
	PromptFunc func(ctx context.Context, prompt string, opts types.PromptOptions) (*types.PromptResult, error)

	SystemPromptHeaderFunc     func() string
	SystemPromptPeerHeaderFunc func() string
	SkillsMountPathFunc        func(home string) string
	MountDirectoriesFunc       func(home string) []string
	AuthDirectoryFunc          func(home string) string
	AuthFilesFunc              func(home string) []string
	ExtraArgsFunc              func() []string
}

FakeClient implements types.CLIClient and types.SandboxSpec for testing purposes.

func NewFakeClient

func NewFakeClient() *FakeClient

NewFakeClient returns a new instance of FakeClient.

func (*FakeClient) AuthDirectory

func (c *FakeClient) AuthDirectory(home string) string

func (*FakeClient) AuthFiles

func (c *FakeClient) AuthFiles(home string) []string

func (*FakeClient) ExtraArgs

func (c *FakeClient) ExtraArgs() []string

func (*FakeClient) ExtraEnv

func (c *FakeClient) ExtraEnv(home string) map[string]string

func (*FakeClient) Models

func (c *FakeClient) Models(ctx context.Context, opts types.UsageOptions) ([]string, error)

Models implements types.CLIClient.Models.

func (*FakeClient) MountDirectories

func (c *FakeClient) MountDirectories(home string) []string

func (*FakeClient) Prompt

func (c *FakeClient) Prompt(ctx context.Context, prompt string, opts types.PromptOptions) (*types.PromptResult, error)

Prompt implements types.CLIClient.Prompt.

func (*FakeClient) SkillsMountPath

func (c *FakeClient) SkillsMountPath(home string) string

func (*FakeClient) SystemPromptHeader

func (c *FakeClient) SystemPromptHeader() string

func (*FakeClient) SystemPromptPeerHeader

func (c *FakeClient) SystemPromptPeerHeader() string

func (*FakeClient) Usage

func (c *FakeClient) Usage(ctx context.Context, opts types.UsageOptions) ([]types.ModelUsage, error)

Usage implements types.CLIClient.Usage.

type QueryRunner

type QueryRunner func(ctx context.Context, args ...string) ([]byte, error)

QueryRunner runs `aw <args...>` somewhere safe (e.g. inside the agent sandbox) and returns its stdout.

type QuotaCheck

type QuotaCheck struct {
	// Remaining is the headline fraction of quota left; 0 when the query
	// failed or the model has no entry.
	Remaining float64

	// Usable is the quota policy verdict of the account the query ran on,
	// nil when no policy applied (callers then apply their own threshold).
	Usable *bool

	// Reason explains an unusable verdict.
	Reason string
}

QuotaCheck is the quota state of one (cli, model) target.

func CheckTarget

func CheckTarget(cli string, model string) QuotaCheck

CheckTarget queries the quota of a (cli, model) target.

Directories

Path Synopsis
Package agy provides programmatic interaction helpers for the agy CLI tool.
Package agy provides programmatic interaction helpers for the agy CLI tool.
cmd
aw command
Package common implements the AW_AGENTS.md contract shared between the Asgard host and the aw dispatcher running inside the sandbox.
Package common implements the AW_AGENTS.md contract shared between the Asgard host and the aw dispatcher running inside the sandbox.
Package inject carries a user message from the host into a running `aw claude` / `aw agy` process inside the agent sandbox.
Package inject carries a user message from the host into a running `aw claude` / `aw agy` process inside the agent sandbox.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL