From 67e0081c6d573b701ed931f96e14dbe5b4258a17 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 08:49:43 +0000 Subject: refactor(executor): extract provider-neutral tool-use loop (Phase 1) Splits LocalRunner's OpenAI-specific agentic loop into reusable, provider- agnostic pieces so later phases can add native Anthropic/OpenAI/Google/Groq/ OpenRouter adapters without duplicating the control flow: - internal/provider: neutral Provider/ChatRequest/ChatResponse types, plus an openaicompat adapter wrapping the existing internal/llm.Client unchanged - internal/sandbox: Sandbox interface + HostSandbox (git clone/push/cleanup, read_file/write_file/run_bash/glob), lifted verbatim from local.go/localtools.go - internal/agentloop: the extracted tool-use loop (request/response/tool- dispatch/loop, ask_user blocking, stream-json envelope, summary fallback) - internal/agentchannel: AgentChannel/SubtaskSpec/BlockedError/ErrAgentBlocked moved out of internal/executor so agentloop can use them without an import cycle; internal/executor re-exports via type aliases, so no call site changes - internal/executor/nativerunner.go: NativeRunner replaces LocalRunner, wiring agentloop.Loop + openaicompat + HostSandbox together - config.Providers map[string]ProviderConfig added (unused until Phase 2+) Zero intended behavior change: go test -race ./... passes across all packages, and end-to-end stream-json/summary/changestats output was verified byte-compatible against a fake OpenAI-compatible server. Adds test coverage for sandbox tool-dispatch (git clone/push, read/write/bash/glob) that LocalRunner never had. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/config/config.go | 17 +++++++++++++++++ internal/config/config_test.go | 43 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) (limited to 'internal/config') diff --git a/internal/config/config.go b/internal/config/config.go index 80777ff..640e933 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -63,6 +63,20 @@ func (r RunnersConfig) GeminiEnabled() bool { return r.Gemini == nil || *r.Ge func (r RunnersConfig) LocalEnabled() bool { return r.Local == nil || *r.Local } func (r RunnersConfig) ContainerEnabled() bool { return r.Container == nil || *r.Container } +// ProviderConfig configures a native (or OpenAI-compatible) LLM provider +// backend for the multi-provider harness — see docs/api-keys-setup.md. Phase +// 1 only adds this type so config has somewhere for it to land; it is not yet +// read by runner construction (no native provider adapters exist yet besides +// the openaicompat one LocalModel already configures). Keyed by provider name +// in the [providers.] TOML table, e.g. [providers.groq], +// [providers.anthropic]. +type ProviderConfig struct { + Endpoint string `toml:"endpoint"` + APIKey string `toml:"api_key"` + DefaultModel string `toml:"default_model"` + TimeoutSeconds int `toml:"timeout_seconds"` +} + type Config struct { DataDir string `toml:"data_dir"` DBPath string `toml:"-"` @@ -89,6 +103,9 @@ type Config struct { LocalModel LocalModel `toml:"local_model"` Runners RunnersConfig `toml:"runners"` Budget BudgetConfig `toml:"budget"` + // Providers configures native/OpenAI-compatible LLM backends by name (see + // ProviderConfig). Unused by runner construction in Phase 1. + Providers map[string]ProviderConfig `toml:"providers"` // ExternalBindAllowed must be explicitly true to bind a non-loopback address. ExternalBindAllowed bool `toml:"external_bind_allowed"` } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0d47ca0..6991354 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -165,3 +165,46 @@ func TestLocalModel_UseForElaborate_ExplicitTrue(t *testing.T) { t.Error("explicit true should opt in") } } + +func TestProvidersConfig_TOMLRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + toml := ` +[providers.groq] +endpoint = "https://api.groq.com/openai/v1" +api_key = "gsk_abc" +default_model = "llama-3.3-70b-versatile" +timeout_seconds = 30 + +[providers.anthropic] +api_key = "sk-ant-xyz" +default_model = "claude-sonnet-4-5" +` + if err := os.WriteFile(path, []byte(toml), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", dir) + + cfg, err := LoadFile(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(cfg.Providers) != 2 { + t.Fatalf("expected 2 providers, got %d: %+v", len(cfg.Providers), cfg.Providers) + } + groq, ok := cfg.Providers["groq"] + if !ok { + t.Fatal("expected [providers.groq] table") + } + if groq.Endpoint != "https://api.groq.com/openai/v1" || groq.APIKey != "gsk_abc" || + groq.DefaultModel != "llama-3.3-70b-versatile" || groq.TimeoutSeconds != 30 { + t.Errorf("groq provider config: %+v", groq) + } + anthropic, ok := cfg.Providers["anthropic"] + if !ok { + t.Fatal("expected [providers.anthropic] table") + } + if anthropic.Endpoint != "" || anthropic.APIKey != "sk-ant-xyz" || anthropic.DefaultModel != "claude-sonnet-4-5" { + t.Errorf("anthropic provider config: %+v", anthropic) + } +} -- cgit v1.2.3