Skip to content
Closed
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
52 changes: 46 additions & 6 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
Expand All @@ -29,17 +30,22 @@ import (

"github.com/conductor-oss/conductor-cli/internal"
"github.com/conductor-oss/conductor-cli/internal/agent"
"github.com/conductor-oss/conductor-cli/internal/providers"
)

// Defaults that are policy, not server contract — named so they are not magic
// literals scattered through the code.
const (
defaultExecutionSearchSize = 50
defaultPruneOlderThanDays = 30
defaultInitModel = "openai/gpt-4o"
defaultInitMaxTurns = 25
)

// initProviderLookupTimeout bounds the provider lookup on init's error path. Naming
// the providers that actually work is the point of the error, so it is worth a short
// wait — but never an unbounded one, and init still works with no server at all.
const initProviderLookupTimeout = 10 * time.Second

var agentCmd = &cobra.Command{
Use: "agent",
Aliases: []string{"a"},
Expand Down Expand Up @@ -318,9 +324,9 @@ var agentInitCmd = &cobra.Command{
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
model := initModel
if model == "" {
model = defaultInitModel
model, err := resolveInitModel(cmd.Context(), initModel)
if err != nil {
return err
}
cfg := map[string]any{
"name": name,
Expand All @@ -335,7 +341,6 @@ var agentInitCmd = &cobra.Command{
}

var data []byte
var err error
ext := "yaml"
if initFormat == "json" {
ext = "json"
Expand All @@ -357,6 +362,41 @@ var agentInitCmd = &cobra.Command{
},
}

// resolveInitModel returns the model to write into a generated config, or an error
// naming what the user can pick from.
//
// init used to default to a fixed OpenAI model whatever was configured, so the run
// command it printed on the next line could not succeed for anyone without an OpenAI
// key — and the failure landed server-side at execution time, as a FAILED workflow
// rather than a validation error. There is no better default available: the server
// reports which providers it can dial but no model names, so any built-in choice is a
// guess this release cannot keep true (see #103).
func resolveInitModel(ctx context.Context, flagValue string) (string, error) {
if flagValue != "" {
return flagValue, nil
}

const base = "no model specified: pass --model provider/model"

lookup, cancel := context.WithTimeout(ctx, initProviderLookupTimeout)
defer cancel()

// The provider list is a courtesy on an error path. init scaffolds a local file
// and must keep working with no server reachable at all.
st, err := providers.Fetch(lookup, internal.Transport())
if err != nil {
return "", fmt.Errorf("%s (run 'conductor doctor' to see which providers are configured)", base)
}
configured := st.Configured()
if len(configured) == 0 {
if st.ManagedByHost {
return "", fmt.Errorf("%s (provider configuration is owned by the host deployment)", base)
}
return "", fmt.Errorf("%s (the server reports no configured AI providers)", base)
}
return "", fmt.Errorf("%s — providers configured on the server: %s", base, strings.Join(configured, ", "))
}

// ---- helpers (file I/O and formatting live here, in the cmd layer) ----

// loadAgentConfig reads a YAML or JSON agent config from disk and returns it as
Expand Down Expand Up @@ -481,7 +521,7 @@ func init() {
agentPruneCmd.Flags().BoolVar(&pruneArchive, "archive", false, "Archive tasks instead of hard-deleting")
agentPruneCmd.Flags().BoolVar(&pruneDryRun, "dry-run", false, "Show what would be pruned without deleting")

agentInitCmd.Flags().StringVarP(&initModel, "model", "", "", "LLM model (default: "+defaultInitModel+")")
agentInitCmd.Flags().StringVarP(&initModel, "model", "", "", "LLM model as provider/model (required)")
agentInitCmd.Flags().StringVarP(&initStrategy, "strategy", "s", "", "Multi-agent strategy (handoff, sequential, parallel, ...)")
agentInitCmd.Flags().StringVarP(&initFormat, "format", "f", "yaml", "Output format: yaml or json")

Expand Down
107 changes: 107 additions & 0 deletions cmd/agent_init_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Conductor Authors.
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

package cmd

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/conductor-oss/conductor-cli/internal"
"github.com/conductor-oss/conductor-cli/internal/transport"
)

// pointTransportAt swaps the shared transport for the duration of a test.
func pointTransportAt(t *testing.T, status int, body string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
previous := internal.Transport()
internal.SetTransport(transport.Config{BaseURL: srv.URL + "/api"})
t.Cleanup(func() {
srv.Close()
internal.SetTransport(previous)
})
}

func TestResolveInitModelReturnsAnExplicitModelVerbatim(t *testing.T) {
// Pointed at a server that would fail the lookup: an explicit model must not
// depend on the server being reachable at all.
pointTransportAt(t, http.StatusInternalServerError, `{"message":"boom"}`)

got, err := resolveInitModel(context.Background(), "anthropic/some-model")
if err != nil {
t.Fatalf("resolveInitModel: %v", err)
}
if got != "anthropic/some-model" {
t.Errorf("model = %q, want it passed through unchanged", got)
}
}

func TestResolveInitModelNamesConfiguredProviders(t *testing.T) {
pointTransportAt(t, http.StatusOK, `{"managedByHost":false,"providers":[
{"name":"openai","configured":false},
{"name":"anthropic","configured":true},
{"name":"perplexity","configured":true}]}`)

_, err := resolveInitModel(context.Background(), "")
if err == nil {
t.Fatal("expected an error when no model is given")
}
msg := err.Error()
if !strings.Contains(msg, "--model") {
t.Errorf("error should name the flag to pass: %q", msg)
}
if !strings.Contains(msg, "anthropic") || !strings.Contains(msg, "perplexity") {
t.Errorf("error should name the configured providers: %q", msg)
}
if strings.Contains(msg, "openai") {
t.Errorf("error should not offer an unconfigured provider: %q", msg)
}
}

// init scaffolds a local file. It must stay usable with no server reachable, so the
// provider list is a courtesy rather than a precondition.
func TestResolveInitModelStillFailsUsefullyWithoutAServer(t *testing.T) {
pointTransportAt(t, http.StatusNotFound, `{"status":404,"message":"Not Found"}`)

_, err := resolveInitModel(context.Background(), "")
if err == nil {
t.Fatal("expected an error when no model is given")
}
msg := err.Error()
if !strings.Contains(msg, "--model") {
t.Errorf("error should name the flag to pass: %q", msg)
}
if !strings.Contains(msg, "doctor") {
t.Errorf("error should point at a way to find the answer: %q", msg)
}
}

func TestResolveInitModelReportsHostManagedConfiguration(t *testing.T) {
pointTransportAt(t, http.StatusOK, `{"managedByHost":true,"providers":[]}`)

_, err := resolveInitModel(context.Background(), "")
if err == nil {
t.Fatal("expected an error when no model is given")
}
if !strings.Contains(err.Error(), "host") {
t.Errorf("error should explain why no providers are listed: %q", err.Error())
}
}
Loading
Loading