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/provider/openaicompat/openaicompat.go | 140 +++++++++++++++++++++++++ internal/provider/provider.go | 72 +++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 internal/provider/openaicompat/openaicompat.go create mode 100644 internal/provider/provider.go (limited to 'internal/provider') 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) +} -- cgit v1.2.3