Skip to content
Open
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
23 changes: 23 additions & 0 deletions internal/agent/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ func NewOpenAIProfile(model string) ProviderProfile {
}
}

func NewCodexAppServerProfile(model string) ProviderProfile {
return &baseProfile{
id: "codex-app-server",
model: strings.TrimSpace(model),
parallel: true,
contextWindow: 1_047_576,
basePrompt: openAIProfileBasePrompt,
docFiles: []string{"AGENTS.md", ".codex/instructions.md"},
toolDefs: []llm.ToolDefinition{
defReadFile(),
defApplyPatch(),
defWriteFile(),
defShell(),
defGrep(),
defGlob(),
defSpawnAgent(),
defSendInput(),
defWait(),
defCloseAgent(),
},
}
}

func NewAnthropicProfile(model string) ProviderProfile {
return &baseProfile{
id: "anthropic",
Expand Down
8 changes: 5 additions & 3 deletions internal/agent/profile_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import (
var (
profileFactoriesMu sync.RWMutex
profileFactories = map[string]func(string) ProviderProfile{
"openai": NewOpenAIProfile,
"anthropic": NewAnthropicProfile,
"google": NewGeminiProfile,
"openai": NewOpenAIProfile,
"anthropic": NewAnthropicProfile,
"google": NewGeminiProfile,
"codex-app-server": NewCodexAppServerProfile,
"codex": NewCodexAppServerProfile,
}
)

Expand Down
44 changes: 44 additions & 0 deletions internal/agent/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ func TestProviderProfiles_ToolsetsAndDocSelection(t *testing.T) {
assertHasTool(t, gemini, "read_many_files")
assertHasTool(t, gemini, "list_dir")
assertMissingTool(t, gemini, "apply_patch")

codex := NewCodexAppServerProfile("gpt-5-codex")
if codex.ID() != "codex-app-server" {
t.Fatalf("codex id: %q", codex.ID())
}
if !codex.SupportsParallelToolCalls() {
t.Fatalf("codex profile should support parallel tool calls")
}
if codex.ContextWindowSize() != 1_047_576 {
t.Fatalf("codex context window: got %d want %d", codex.ContextWindowSize(), 1_047_576)
}
assertHasTool(t, codex, "apply_patch")
assertMissingTool(t, codex, "edit_file")
}

func TestProviderProfiles_ToolLists_MatchSpec(t *testing.T) {
Expand Down Expand Up @@ -92,6 +105,21 @@ func TestProviderProfiles_ToolLists_MatchSpec(t *testing.T) {
"close_agent",
})
})
t.Run("codex-app-server", func(t *testing.T) {
p := NewCodexAppServerProfile("gpt-5-codex")
assertToolListExact(t, p, []string{
"read_file",
"apply_patch",
"write_file",
"shell",
"grep",
"glob",
"spawn_agent",
"send_input",
"wait",
"close_agent",
})
})
}

func TestProviderProfiles_BuildSystemPrompt_IncludesProviderSpecificBaseInstructions(t *testing.T) {
Expand Down Expand Up @@ -181,4 +209,20 @@ func TestNewProfileForFamily_DefaultFamiliesAndRegistration(t *testing.T) {
if _, err := NewProfileForFamily("missing-family", "m3"); err == nil {
t.Fatalf("expected unsupported family error")
}

codex, err := NewProfileForFamily("codex-app-server", "gpt-5-codex")
if err != nil {
t.Fatalf("NewProfileForFamily(codex-app-server): %v", err)
}
if codex.ID() != "codex-app-server" {
t.Fatalf("codex profile id=%q want codex-app-server", codex.ID())
}

codexAlias, err := NewProfileForFamily("codex", "gpt-5-codex")
if err != nil {
t.Fatalf("NewProfileForFamily(codex): %v", err)
}
if codexAlias.ID() != "codex-app-server" {
t.Fatalf("codex alias profile id=%q want codex-app-server", codexAlias.ID())
}
}
13 changes: 12 additions & 1 deletion internal/attractor/engine/api_client_from_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/danshapiro/kilroy/internal/llm"
"github.com/danshapiro/kilroy/internal/llm/providers/anthropic"
"github.com/danshapiro/kilroy/internal/llm/providers/codexappserver"
"github.com/danshapiro/kilroy/internal/llm/providers/google"
"github.com/danshapiro/kilroy/internal/llm/providers/openai"
"github.com/danshapiro/kilroy/internal/llm/providers/openaicompat"
Expand All @@ -21,7 +22,15 @@ func newAPIClientFromProviderRuntimes(runtimes map[string]ProviderRuntime) (*llm
if rt.Backend != BackendAPI {
continue
}
apiKey := strings.TrimSpace(os.Getenv(rt.API.DefaultAPIKeyEnv))
apiKeyEnv := strings.TrimSpace(rt.API.DefaultAPIKeyEnv)
if rt.API.Protocol == providerspec.ProtocolCodexAppServer && apiKeyEnv == "" {
c.Register(codexappserver.NewAdapter(codexappserver.AdapterOptions{Provider: key}))
continue
}
if apiKeyEnv == "" {
continue
}
apiKey := strings.TrimSpace(os.Getenv(apiKeyEnv))
if apiKey == "" {
continue
}
Expand All @@ -41,6 +50,8 @@ func newAPIClientFromProviderRuntimes(runtimes map[string]ProviderRuntime) (*llm
OptionsKey: rt.API.ProviderOptionsKey,
ExtraHeaders: rt.APIHeaders(),
}))
case providerspec.ProtocolCodexAppServer:
c.Register(codexappserver.NewAdapter(codexappserver.AdapterOptions{Provider: key}))
default:
return nil, fmt.Errorf("unsupported api protocol %q for provider %s", rt.API.Protocol, key)
}
Expand Down
71 changes: 71 additions & 0 deletions internal/attractor/engine/api_client_from_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,77 @@ func TestNewAPIClientFromProviderRuntimes_RegistersMinimaxViaOpenAICompat(t *tes
}
}

func TestNewAPIClientFromProviderRuntimes_RegistersCodexAppServerProtocol(t *testing.T) {
runtimes := map[string]ProviderRuntime{
"codex-app-server": {
Key: "codex-app-server",
Backend: BackendAPI,
API: providerspec.APISpec{
Protocol: providerspec.ProtocolCodexAppServer,
DefaultAPIKeyEnv: "",
},
},
}
c, err := newAPIClientFromProviderRuntimes(runtimes)
if err != nil {
t.Fatalf("newAPIClientFromProviderRuntimes: %v", err)
}
if len(c.ProviderNames()) != 1 || c.ProviderNames()[0] != "codex-app-server" {
t.Fatalf("expected codex-app-server adapter, got %v", c.ProviderNames())
}
}

func TestNewAPIClientFromProviderRuntimes_CodexAppServerHonorsExplicitAPIKeyEnv(t *testing.T) {
runtimes := map[string]ProviderRuntime{
"codex-app-server": {
Key: "codex-app-server",
Backend: BackendAPI,
API: providerspec.APISpec{
Protocol: providerspec.ProtocolCodexAppServer,
DefaultAPIKeyEnv: "CODEX_APP_SERVER_TOKEN",
},
},
}

t.Setenv("CODEX_APP_SERVER_TOKEN", "")
c, err := newAPIClientFromProviderRuntimes(runtimes)
if err != nil {
t.Fatalf("newAPIClientFromProviderRuntimes: %v", err)
}
if len(c.ProviderNames()) != 0 {
t.Fatalf("expected no adapters when explicit codex api key env is unset, got %v", c.ProviderNames())
}

t.Setenv("CODEX_APP_SERVER_TOKEN", "present")
c, err = newAPIClientFromProviderRuntimes(runtimes)
if err != nil {
t.Fatalf("newAPIClientFromProviderRuntimes: %v", err)
}
if len(c.ProviderNames()) != 1 || c.ProviderNames()[0] != "codex-app-server" {
t.Fatalf("expected codex-app-server adapter when explicit env is set, got %v", c.ProviderNames())
}
}

func TestNewAPIClientFromProviderRuntimes_CodexAppServerPreservesCustomProviderKey(t *testing.T) {
runtimes := map[string]ProviderRuntime{
"my-codex-provider": {
Key: "my-codex-provider",
Backend: BackendAPI,
API: providerspec.APISpec{
Protocol: providerspec.ProtocolCodexAppServer,
DefaultAPIKeyEnv: "",
},
},
}
c, err := newAPIClientFromProviderRuntimes(runtimes)
if err != nil {
t.Fatalf("newAPIClientFromProviderRuntimes: %v", err)
}
if len(c.ProviderNames()) != 1 || c.ProviderNames()[0] != "my-codex-provider" {
t.Fatalf("expected custom codex provider key to be preserved, got %v", c.ProviderNames())
}
}

func TestResolveBuiltInBaseURLOverride_MinimaxUsesEnvOverride(t *testing.T) {
t.Setenv("MINIMAX_BASE_URL", "http://127.0.0.1:8888")
got := resolveBuiltInBaseURLOverride("minimax", "https://api.minimax.io")
Expand Down
4 changes: 1 addition & 3 deletions internal/attractor/engine/cli_only_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import "strings"

// cliOnlyModelIDs lists models that MUST route through CLI backend regardless
// of provider backend configuration. These models have no API endpoint.
var cliOnlyModelIDs = map[string]bool{
"gpt-5.3-codex-spark": true,
}
var cliOnlyModelIDs = map[string]bool{}

// isCLIOnlyModel returns true if the given model ID (with or without provider
// prefix) must be routed exclusively through the CLI backend.
Expand Down
25 changes: 21 additions & 4 deletions internal/attractor/engine/cli_only_models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ func TestIsCLIOnlyModel(t *testing.T) {
model string
want bool
}{
{"gpt-5.3-codex-spark", true},
{"GPT-5.3-CODEX-SPARK", true}, // case-insensitive
{"openai/gpt-5.3-codex-spark", true}, // with provider prefix
{"gpt-5.3-codex", false}, // regular codex
{"gpt-5.3-codex-spark", false},
{"GPT-5.3-CODEX-SPARK", false}, // case-insensitive
{"openai/gpt-5.3-codex-spark", false}, // with provider prefix
{"gpt-5.3-codex", false}, // regular codex
{"gpt-5.2-codex", false},
{"claude-opus-4-6", false},
{"", false},
Expand All @@ -21,3 +21,20 @@ func TestIsCLIOnlyModel(t *testing.T) {
}
}
}

func TestIsCLIOnlyModel_UsesConfiguredRegistry(t *testing.T) {
orig := cliOnlyModelIDs
cliOnlyModelIDs = map[string]bool{
"test-cli-only-model": true,
}
t.Cleanup(func() {
cliOnlyModelIDs = orig
})

if got := isCLIOnlyModel("test-cli-only-model"); !got {
t.Fatalf("isCLIOnlyModel(test-cli-only-model) = %v, want true", got)
}
if got := isCLIOnlyModel("openai/test-cli-only-model"); !got {
t.Fatalf("isCLIOnlyModel(openai/test-cli-only-model) = %v, want true", got)
}
}
69 changes: 69 additions & 0 deletions internal/attractor/engine/codergen_failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,72 @@ func TestShouldFailoverLLMError_GetwdBootstrapErrorDoesNotFailover(t *testing.T)
t.Fatalf("getwd bootstrap errors should not trigger failover")
}
}

func TestAgentLoopProviderOptions_CodexAppServer_UsesFullAutonomousPermissions(t *testing.T) {
got := agentLoopProviderOptions("codex_app_server", "/tmp/worktree")
if len(got) != 1 {
t.Fatalf("provider options length=%d want 1", len(got))
}
raw, ok := got["codex_app_server"]
if !ok {
t.Fatalf("missing codex_app_server provider options: %#v", got)
}
opts, ok := raw.(map[string]any)
if !ok {
t.Fatalf("codex_app_server options type=%T want map[string]any", raw)
}
if gotCwd := fmt.Sprint(opts["cwd"]); gotCwd != "/tmp/worktree" {
t.Fatalf("cwd=%q want %q", gotCwd, "/tmp/worktree")
}
if gotApproval := fmt.Sprint(opts["approvalPolicy"]); gotApproval != "never" {
t.Fatalf("approvalPolicy=%q want %q", gotApproval, "never")
}
if gotSandbox := fmt.Sprint(opts["sandbox"]); gotSandbox != "danger-full-access" {
t.Fatalf("sandbox=%q want %q", gotSandbox, "danger-full-access")
}
rawSandboxPolicy, ok := opts["sandboxPolicy"]
if !ok {
t.Fatalf("missing sandboxPolicy in codex options: %#v", opts)
}
sandboxPolicy, ok := rawSandboxPolicy.(map[string]any)
if !ok {
t.Fatalf("sandboxPolicy type=%T want map[string]any", rawSandboxPolicy)
}
if gotType := fmt.Sprint(sandboxPolicy["type"]); gotType != "dangerFullAccess" {
t.Fatalf("sandboxPolicy.type=%q want %q", gotType, "dangerFullAccess")
}
}

func TestAgentLoopProviderOptions_Cerebras_PreservesReasoningHistory(t *testing.T) {
got := agentLoopProviderOptions("cerebras", "")
raw, ok := got["cerebras"]
if !ok {
t.Fatalf("missing cerebras provider options: %#v", got)
}
opts, ok := raw.(map[string]any)
if !ok {
t.Fatalf("cerebras options type=%T want map[string]any", raw)
}
clearThinking, ok := opts["clear_thinking"].(bool)
if !ok {
t.Fatalf("clear_thinking type=%T want bool", opts["clear_thinking"])
}
if clearThinking {
t.Fatalf("clear_thinking=%v want false", clearThinking)
}
}

func TestAgentLoopProviderOptions_CodexAppServer_OmitsCwdWhenWorktreeEmpty(t *testing.T) {
got := agentLoopProviderOptions("codex-app-server", "")
raw, ok := got["codex_app_server"]
if !ok {
t.Fatalf("missing codex_app_server provider options: %#v", got)
}
opts, ok := raw.(map[string]any)
if !ok {
t.Fatalf("codex_app_server options type=%T want map[string]any", raw)
}
if _, exists := opts["cwd"]; exists {
t.Fatalf("expected cwd to be omitted when worktreeDir is empty: %#v", opts["cwd"])
}
}
Loading