Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions cmd/orchestra/agents.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"errors"
"fmt"
"strings"
"time"
Expand Down Expand Up @@ -30,23 +31,32 @@ func newAgentsCmd() *cobra.Command {
return err
}
reg := cfg.BuildRegistry()
var keyHints []string

if probe {
return probeAgents(cmd, reg, cfg, probeTimeout)
}

fmt.Printf("%-12s %-16s %s\n", "AGENT", "STATUS", "CAPABILITIES")
for _, a := range reg.All() {
status := "available"
if err := a.Health(); err != nil {
status = "not installed"
herr := a.Health()
status := agent.HealthLabel(herr)
var ee *agent.EnvError
if errors.As(herr, &ee) {
keyHints = append(keyHints, a.Name()+": "+herr.Error())
}
def := ""
if a.Name() == cfg.DefaultAgent {
def = " (default)"
}
fmt.Printf("%-12s %-16s %s%s%s\n", a.Name(), status, capList(a), apiTag(a), def)
}
for _, h := range keyHints {
fmt.Println(ui.Warn("! " + h))
}
for _, w := range cfg.LiteralSecretWarnings() {
fmt.Println(ui.Warn("! " + w))
}
fmt.Println(ui.Dim("\ntip: `orchestra agents --probe` checks agents can actually run, not just that they're installed"))
return nil
},
Expand All @@ -72,7 +82,11 @@ func probeAgents(cmd *cobra.Command, reg *agent.Registry, cfg *config.Config, ti
a := all[i]
rows[i].name = a.Name()
if err := a.Health(); err != nil {
rows[i].result = ui.Dim("not installed")
rows[i].result = ui.Dim(agent.HealthLabel(err))
var ee *agent.EnvError
if errors.As(err, &ee) {
rows[i].detail = err.Error()
}
return nil
}
p, ok := a.(agent.Prober)
Expand Down
6 changes: 5 additions & 1 deletion cmd/orchestra/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ func runSequential(ctx context.Context, in *bufio.Reader, cfg *config.Config, re
return nil
}
}
fmt.Printf("\n%s\n", ui.Success(fmt.Sprintf("✓ workflow complete — all %d steps accepted and committed", len(pl.Steps))))
kept := "accepted and committed"
if !gitutil.IsRepo(flagDir) {
kept = "accepted — changes left in the folder" // git-free: nothing is committed
}
fmt.Printf("\n%s\n", ui.Success(fmt.Sprintf("✓ workflow complete — all %d steps %s", len(pl.Steps), kept)))
return nil
}

Expand Down
6 changes: 6 additions & 0 deletions cmd/orchestra/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ agents:
# model: gpt-4o
# api_key_env: OPENAI_API_KEY
# capabilities: [plan, implement, review]
#
# Any agent can take an env: block to choose its key without writing it here
# (bring your own key; $VAR / ${VAR} are expanded at run time):
#
# env:
# GROQ_API_KEY: "${MY_GROQ_KEY}"
`

func newInitCmd() *cobra.Command {
Expand Down
31 changes: 31 additions & 0 deletions docs/EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,37 @@ and cost shows as `—`. Usage appears in the end-of-run line
and the dashboard's History, Benchmarks and Agents (per-agent totals) tabs. CLI agents
don't report usage yet, so their runs show `—`.

### Bring your own key (`env:`)

Orchestra never holds or proxies keys. CLI agents keep their own login; API agents read a key from
the environment. The optional `env:` block on any agent lets you pick *which* key, without putting it
in `orchestra.yaml`:

```yaml
agents:
- name: opencode-groq # a second opencode profile on another provider
bin: opencode
args: ["run"]
dir_flag: "--dir"
env:
GROQ_API_KEY: "${MY_GROQ_KEY}" # passed into the opencode process

- name: sonnet-work
type: api
provider: anthropic
model: claude-sonnet-4-5
env:
ANTHROPIC_API_KEY: "${WORK_ANTHROPIC_KEY}" # api agents use the api_key_env entry as their key
```

- Values reference variables as `$VAR` or `${VAR}` and are expanded each time the agent runs.
- If a referenced variable is unset, the agent shows as **missing key** (not "not installed") in
`orchestra agents`, the dashboard's Agents tab and `--probe`, with the variable's name. Values are
never printed.
- Literal values are allowed but `orchestra agents` warns when a `*KEY*`/`*TOKEN*`/`*SECRET*` entry
holds one: `orchestra.yaml` is often committed.
- If the provider rejects a key, the probe names the variable it came from.

---

## 3. Implement the `Agent` interface (for a built-in / non-CLI agent)
Expand Down
18 changes: 15 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ type CLIAgent struct {
args []string
dirFlag string // if set, inject "<dirFlag> <abs dir>" so CLIs that ignore cwd still isolate
caps []Capability
env map[string]string // extra environment ($VAR-expanded at spawn); the CLI keeps its own auth
}

// New builds a CLIAgent. dirFlag (may be empty) is the flag an agent needs to be
Expand All @@ -117,13 +118,22 @@ func New(name, bin string, args []string, dirFlag string, caps []Capability) *CL
return &CLIAgent{name: name, bin: bin, args: args, dirFlag: dirFlag, caps: caps}
}

// SetEnv sets extra environment variables for the CLI process, e.g. a
// provider key: {"GROQ_API_KEY": "${MY_GROQ_KEY}"}. Values may reference
// variables as $VAR or ${VAR}; they are expanded at every spawn.
func (a *CLIAgent) SetEnv(env map[string]string) { a.env = copyEnv(env) }

func (a *CLIAgent) Name() string { return a.name }
func (a *CLIAgent) Capabilities() []Capability { return a.caps }

// Health reports whether the agent's binary is on PATH.
// Health reports whether the agent's binary is on PATH and every variable its
// env: block references is set (an *EnvError otherwise).
func (a *CLIAgent) Health() error {
_, err := exec.LookPath(a.bin)
return err
if _, err := exec.LookPath(a.bin); err != nil {
return err
}
_, missing := expandEnv(a.env)
return firstMissing(a.name, missing)
}

// Run dispatches the task to the CLI via the runner.
Expand Down Expand Up @@ -223,10 +233,12 @@ func (a *CLIAgent) spec(task Task) runner.Spec {
args = append(args, a.dirFlag, dir) // tell cwd-ignoring CLIs where to work
}
args = append(args, task.Prompt)
env, _ := expandEnv(a.env)
return runner.Spec{
Bin: a.bin,
Args: args,
Dir: dir,
Env: env,
Timeout: task.Timeout,
Output: task.Output,
}
Expand Down
49 changes: 41 additions & 8 deletions internal/agent/apiagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import (
type APIAgent struct {
name string
model string
keyEnv string // env var holding the API key
keyEnv string // env var holding the API key
env map[string]string // optional env: block; may supply keyEnv (e.g. "${WORK_KEY}")
prov llm.Provider
caps []Capability
budget int
Expand Down Expand Up @@ -91,13 +92,37 @@ func (a *APIAgent) Model() string { return a.model }
// ProviderName returns the llm provider backing this agent.
func (a *APIAgent) ProviderName() string { return a.prov.Name() }

// Health reports whether the agent is usable without network I/O: the API key
// env var must be set (mirrors CLIAgent's cheap binary-on-PATH check).
func (a *APIAgent) Health() error {
if v := os.Getenv(a.keyEnv); strings.TrimSpace(v) == "" {
return fmt.Errorf("environment variable %s is not set (required by api agent %q)", a.keyEnv, a.name)
// SetEnv sets the agent's env: block. Only the entry named by api_key_env is
// used: it lets one agent read its key from another variable, e.g.
// {"ANTHROPIC_API_KEY": "${WORK_ANTHROPIC_KEY}"}.
func (a *APIAgent) SetEnv(env map[string]string) { a.env = copyEnv(env) }

// apiKey resolves the key: the env: block's entry for keyEnv when present,
// otherwise the process environment. A missing key is an *EnvError.
func (a *APIAgent) apiKey() (string, error) {
if raw, ok := a.env[a.keyEnv]; ok {
pairs, missing := expandEnv(map[string]string{a.keyEnv: raw})
if err := firstMissing(a.name, missing); err != nil {
return "", err
}
key := strings.TrimPrefix(pairs[0], a.keyEnv+"=")
if strings.TrimSpace(key) == "" {
return "", &EnvError{Agent: a.name, Var: a.keyEnv}
}
return key, nil
}
return nil
key := os.Getenv(a.keyEnv)
if strings.TrimSpace(key) == "" {
return "", &EnvError{Agent: a.name, Var: a.keyEnv}
}
return key, nil
}

// Health reports whether the agent is usable without network I/O: its API key
// must resolve (mirrors CLIAgent's cheap binary-on-PATH check).
func (a *APIAgent) Health() error {
_, err := a.apiKey()
return err
}

// outputContract is the strict reply format demanded of the model.
Expand Down Expand Up @@ -193,7 +218,10 @@ func (a *APIAgent) Probe(ctx context.Context, timeout time.Duration) ProbeResult
case err != nil:
detail := firstMeaningfulLine(err.Error())
var le *llm.Error
if errors.As(err, &le) && le.UserDetail() != "" {
switch {
case errors.As(err, &le) && le.Kind == llm.ErrAuth:
detail = fmt.Sprintf("%s rejected the key in %s — check it and that it can use this model", le.Provider, a.keyEnv)
case errors.As(err, &le) && le.UserDetail() != "":
detail = le.UserDetail()
}
return ProbeResult{OK: false, Detail: detail}
Expand All @@ -211,7 +239,12 @@ func (a *APIAgent) complete(ctx context.Context, system, user string, timeout ti
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
key, err := a.apiKey()
if err != nil {
return llm.Response{}, err
}
return a.prov.Complete(ctx, llm.Request{
APIKey: key,
System: system,
Messages: []llm.Message{{Role: "user", Content: user}},
Temperature: 0,
Expand Down
85 changes: 85 additions & 0 deletions internal/agent/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package agent

import (
"errors"
"fmt"
"os"
"sort"
"strings"
)

// EnvError reports a variable an agent needs from the environment that is
// unset or empty — typically a missing API key. Callers use errors.As to tell
// "missing key" apart from "not installed".
type EnvError struct {
Agent string // agent name
Var string // the environment variable that is missing
For string // the env: entry that references it; "" when Var is the API key itself
}

func (e *EnvError) Error() string {
if e.For != "" {
return fmt.Sprintf("%s is not set (referenced by env.%s of agent %q)", e.Var, e.For, e.Agent)
}
return fmt.Sprintf("%s is not set: export it, or point api_key_env / env at the variable holding your key (agent %q)", e.Var, e.Agent)
}

// expandEnv resolves an agent's env: block against the process environment.
// Values may reference variables as $VAR or ${VAR}. It returns sorted
// KEY=value pairs for the child process, plus every referenced variable that
// is unset or empty (in key order), so Health can report them without the
// values ever being printed.
func expandEnv(env map[string]string) (pairs []string, missing []EnvError) {
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := os.Expand(env[k], func(name string) string {
val := os.Getenv(name)
if strings.TrimSpace(val) == "" {
missing = append(missing, EnvError{Var: name, For: k})
}
return val
})
pairs = append(pairs, k+"="+v)
}
return pairs, missing
}

// firstMissing turns the first unresolved reference into an error for agent.
func firstMissing(agent string, missing []EnvError) error {
if len(missing) == 0 {
return nil
}
e := missing[0]
e.Agent = agent
return &e
}

// copyEnv defensively copies an env: block.
func copyEnv(env map[string]string) map[string]string {
if len(env) == 0 {
return nil
}
out := make(map[string]string, len(env))
for k, v := range env {
out[k] = v
}
return out
}

// HealthLabel is a short status for a Health result: "available",
// "missing key" (an *EnvError), or "not installed".
func HealthLabel(err error) string {
var ee *EnvError
switch {
case err == nil:
return "available"
case errors.As(err, &ee):
return "missing key"
default:
return "not installed"
}
}
Loading
Loading