summaryrefslogtreecommitdiff
path: root/internal/provider
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 08:49:43 +0000
commit67e0081c6d573b701ed931f96e14dbe5b4258a17 (patch)
treebc7f8ce0d57876dd85720924d0275dc39c05e5b4 /internal/provider
parent3d286974cdc28c68c5ee536ce9303899dae9540e (diff)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/provider')
-rw-r--r--internal/provider/openaicompat/openaicompat.go140
-rw-r--r--internal/provider/provider.go72
2 files changed, 212 insertions, 0 deletions
diff --git a/internal/provider/openaicompat/openaicompat.go b/internal/provider/openaicompat/openaicompat.go
new file mode 100644
index 0000000..164ebeb
--- /dev/null
+++ b/internal/provider/openaicompat/openaicompat.go
@@ -0,0 +1,140 @@
+// Package openaicompat adapts the existing internal/llm.Client (a small
+// OpenAI-compatible chat-completions HTTP client) to the provider-neutral
+// provider.Provider interface, unchanged in its own behavior — this package
+// only translates request/response shapes.
+package openaicompat
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/thepeterstone/claudomator/internal/llm"
+ "github.com/thepeterstone/claudomator/internal/provider"
+)
+
+// Provider wraps an *llm.Client so it can be driven through the
+// provider-neutral interface.
+type Provider struct {
+ Client *llm.Client
+}
+
+// New returns a provider.Provider backed by client.
+func New(client *llm.Client) *Provider {
+ return &Provider{Client: client}
+}
+
+var _ provider.Provider = (*Provider)(nil)
+
+func (p *Provider) Name() string { return "openaicompat" }
+
+// Chat translates req into an llm.ChatRequest, performs the call via the
+// wrapped client, and translates the result back.
+func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
+ if p == nil || p.Client == nil {
+ return nil, fmt.Errorf("openaicompat: nil client")
+ }
+ llmReq := toLLMRequest(req)
+ resp, err := p.Client.Chat(ctx, llmReq)
+ if err != nil {
+ return nil, err
+ }
+ return fromLLMResponse(resp), nil
+}
+
+// toLLMRequest translates a provider-neutral ChatRequest into the wire shape
+// llm.Client understands. System, if set, becomes a leading role:"system"
+// message — llm.Client/the OpenAI-compatible wire format has no separate
+// top-level system field.
+func toLLMRequest(req provider.ChatRequest) llm.ChatRequest {
+ messages := make([]llm.Message, 0, len(req.Messages)+1)
+ if req.System != "" {
+ messages = append(messages, llm.Message{Role: "system", Content: req.System})
+ }
+ for _, m := range req.Messages {
+ messages = append(messages, toLLMMessages(m)...)
+ }
+
+ var tools []llm.Tool
+ if len(req.Tools) > 0 {
+ tools = make([]llm.Tool, 0, len(req.Tools))
+ for _, ts := range req.Tools {
+ tools = append(tools, llm.Tool{
+ Type: "function",
+ Function: llm.ToolFunction{
+ Name: ts.Name,
+ Description: ts.Description,
+ Parameters: ts.ParametersJSONSchema,
+ },
+ })
+ }
+ }
+
+ return llm.ChatRequest{
+ Model: req.Model,
+ Messages: messages,
+ Temperature: req.Temperature,
+ MaxTokens: req.MaxTokens,
+ Tools: tools,
+ }
+}
+
+// toLLMMessages translates a single provider-neutral Message into zero or more
+// llm.Message values. Assistant turns (with ToolCalls) and plain text turns
+// translate 1:1. Tool-result turns translate to one llm.Message per
+// ToolResult, since the OpenAI wire format represents each tool result as its
+// own role:"tool" message (agentloop always emits one ToolResult per turn
+// today, matching that shape exactly; the loop here is future-proofing for
+// providers/loops that batch multiple results into one turn).
+func toLLMMessages(m provider.Message) []llm.Message {
+ if len(m.ToolResults) > 0 {
+ out := make([]llm.Message, 0, len(m.ToolResults))
+ for _, tr := range m.ToolResults {
+ out = append(out, llm.Message{
+ Role: "tool",
+ ToolCallID: tr.ToolCallID,
+ Name: tr.Name,
+ Content: tr.Content,
+ })
+ }
+ return out
+ }
+
+ lm := llm.Message{Role: m.Role, Content: m.Text}
+ if len(m.ToolCalls) > 0 {
+ lm.ToolCalls = make([]llm.ToolCall, 0, len(m.ToolCalls))
+ for _, tc := range m.ToolCalls {
+ lm.ToolCalls = append(lm.ToolCalls, llm.ToolCall{
+ ID: tc.ID,
+ Type: "function",
+ Function: llm.ToolCallFunction{
+ Name: tc.Name,
+ Arguments: tc.ArgsJSON,
+ },
+ })
+ }
+ }
+ return []llm.Message{lm}
+}
+
+func fromLLMResponse(r *llm.ChatResponse) *provider.ChatResponse {
+ var calls []provider.ToolCall
+ if len(r.ToolCalls) > 0 {
+ calls = make([]provider.ToolCall, 0, len(r.ToolCalls))
+ for _, tc := range r.ToolCalls {
+ calls = append(calls, provider.ToolCall{
+ ID: tc.ID,
+ Name: tc.Function.Name,
+ ArgsJSON: tc.Function.Arguments,
+ })
+ }
+ }
+ return &provider.ChatResponse{
+ Text: r.Content,
+ ToolCalls: calls,
+ StopReason: r.FinishReason,
+ Usage: provider.Usage{
+ InputTokens: r.PromptTokens,
+ OutputTokens: r.OutputTokens,
+ },
+ }
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
new file mode 100644
index 0000000..fd1022d
--- /dev/null
+++ b/internal/provider/provider.go
@@ -0,0 +1,72 @@
+// Package provider defines a provider-neutral chat/tool-use interface. It is
+// independent of any one wire format (OpenAI-compatible, Anthropic Messages,
+// Gemini, etc.) so that internal/agentloop's tool-use control flow can drive
+// any backend that implements Provider.
+//
+// Phase 1 ships exactly one implementation, internal/provider/openaicompat,
+// which adapts the existing internal/llm.Client. Later phases add native
+// Anthropic/OpenAI/Google/Groq/OpenRouter adapters without touching agentloop.
+package provider
+
+import "context"
+
+// Message is one turn in a chat conversation, in provider-neutral shape.
+type Message struct {
+ Role string // "system" | "user" | "assistant" | "tool"
+ Text string
+ ToolCalls []ToolCall // set on assistant turns that invoke tools
+ ToolResults []ToolResult // set on tool-result turns
+}
+
+// ToolCall is a single tool invocation requested by the model.
+type ToolCall struct {
+ ID string
+ Name string
+ ArgsJSON string
+}
+
+// ToolResult is the outcome of executing a ToolCall, fed back to the model.
+type ToolResult struct {
+ ToolCallID string
+ Name string
+ Content string
+ IsError bool
+}
+
+// ToolSpec declares a tool the model may call.
+type ToolSpec struct {
+ Name string
+ Description string
+ ParametersJSONSchema map[string]any
+}
+
+// ChatRequest captures the parameters of a single chat completion call.
+type ChatRequest struct {
+ Model string
+ System string
+ Messages []Message
+ Tools []ToolSpec
+ Temperature *float64
+ MaxTokens int
+}
+
+// Usage reports token accounting and (when known) cost for a single call.
+type Usage struct {
+ InputTokens int
+ OutputTokens int
+ CostUSD float64
+}
+
+// ChatResponse is the aggregated result of a chat completion.
+type ChatResponse struct {
+ Text string
+ ToolCalls []ToolCall
+ StopReason string
+ Usage Usage
+}
+
+// Provider is a chat/tool-use backend: one per LLM vendor/wire-format.
+type Provider interface {
+ Name() string
+ Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
+}