mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 16:01:28 +00:00
254 lines
10 KiB
Go
254 lines
10 KiB
Go
package generation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
)
|
|
|
|
// This file defines the provider-agnostic agent-loop contract used by every
|
|
// server-side AI feature (dashboard agent, contact research, automation AI
|
|
// nodes, inbox agent). Warmbly's hosted product runs on OpenAI; the same
|
|
// interface lets a self-hoster point at any OpenAI-compatible endpoint
|
|
// (Ollama, vLLM, LocalAI, OpenRouter) via an AI_PROVIDER preset or AI_BASE_URL, or use the
|
|
// Anthropic connector, without any caller change.
|
|
|
|
// RiskClass classifies what a tool can do, which drives the approval policy in
|
|
// the agent loop (read auto-runs; write pauses for approval; send always
|
|
// requires an explicit per-action human action).
|
|
type RiskClass string
|
|
|
|
const (
|
|
RiskRead RiskClass = "read"
|
|
RiskWrite RiskClass = "write"
|
|
RiskSend RiskClass = "send"
|
|
)
|
|
|
|
// ToolDef is the LLM-facing definition of a tool the agent loop can call. It is
|
|
// the provider-level projection of a richer aitools.Tool (M2): only what the
|
|
// model needs (name, description, JSON-schema input) plus the Go handler the
|
|
// loop executes. Permission gating and approval happen in the caller/registry,
|
|
// not inside the provider.
|
|
type ToolDef struct {
|
|
Name string
|
|
Description string
|
|
// InputSchema is a JSON Schema object describing the tool's arguments.
|
|
InputSchema map[string]any
|
|
Risk RiskClass
|
|
// Handler executes the tool for the given JSON args and returns a result
|
|
// string (usually JSON) fed back to the model as the tool result. An error
|
|
// is surfaced to the model as a tool error so it can recover or stop.
|
|
Handler func(ctx context.Context, args json.RawMessage) (string, error)
|
|
}
|
|
|
|
// ToolCall is one tool invocation the model requested, carried in an assistant
|
|
// message so the transcript is provider-agnostic and resumable (M3 persists it
|
|
// as agent_messages jsonb).
|
|
type ToolCall struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Args json.RawMessage `json:"args"`
|
|
}
|
|
|
|
// AgentMessage is one entry in the running transcript. Role is one of
|
|
// "user", "assistant", "tool". An assistant turn may carry ToolCalls; a tool
|
|
// turn carries ToolCallID + Content (the result).
|
|
type AgentMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content,omitempty"`
|
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
}
|
|
|
|
// AgentEventType enumerates the streamed step kinds surfaced via OnEvent (the
|
|
// M3 SSE loop maps these to text deltas / tool_start / tool_result events).
|
|
type AgentEventType string
|
|
|
|
const (
|
|
EventText AgentEventType = "text"
|
|
// EventTextDelta is a partial text chunk streamed while the model is
|
|
// still generating; a closing EventText with the full block always
|
|
// follows, so consumers may treat deltas as purely cosmetic.
|
|
EventTextDelta AgentEventType = "text_delta"
|
|
EventToolStart AgentEventType = "tool_start"
|
|
EventToolResult AgentEventType = "tool_result"
|
|
EventIteration AgentEventType = "iteration"
|
|
)
|
|
|
|
// AgentEvent is a single streamed step from RunAgent.
|
|
type AgentEvent struct {
|
|
Type AgentEventType
|
|
Text string
|
|
ToolName string
|
|
ToolArgs json.RawMessage
|
|
ToolResult string
|
|
Iteration int
|
|
}
|
|
|
|
// AgentRequest is one RunAgent invocation.
|
|
type AgentRequest struct {
|
|
// System is the system prompt.
|
|
System string
|
|
// Messages is the running transcript (may include prior tool turns for a
|
|
// resumed run). At minimum one user message.
|
|
Messages []AgentMessage
|
|
// Tools available this run. May be empty for a plain completion.
|
|
Tools []ToolDef
|
|
// Model is the provider model id; empty means the provider's default for
|
|
// the tier (see ModelForTier).
|
|
Model string
|
|
// MaxIterations bounds tool-use rounds (each model call is one iteration).
|
|
// Zero uses the provider default.
|
|
MaxIterations int
|
|
// MaxTokens caps a single completion. Zero uses the provider default.
|
|
MaxTokens int
|
|
// OnEvent, if set, receives streamed step events. Optional.
|
|
OnEvent func(AgentEvent)
|
|
// Approve, if set, is called before executing a non-read tool. Returning
|
|
// ErrApprovalRequired stops the loop and returns a result whose Pending
|
|
// holds the tool call awaiting approval, so the caller can persist state
|
|
// and resume. Returning any other error aborts the run.
|
|
Approve func(ctx context.Context, tool ToolDef, call ToolCall) error
|
|
// PreIteration, if set, is called at the start of each loop iteration
|
|
// (before the model call). Returning an error stops the loop cleanly with
|
|
// StopReason "stopped" and returns the transcript so far. Used to charge
|
|
// per-iteration credits and enforce a per-run budget; the caller records
|
|
// its own reason (e.g. out-of-credits) before returning.
|
|
PreIteration func(ctx context.Context, iteration int) error
|
|
}
|
|
|
|
// PendingToolCall is the tool awaiting approval when a run stops with
|
|
// StopReason "approval_required".
|
|
type PendingToolCall struct {
|
|
Call ToolCall
|
|
Risk RiskClass
|
|
}
|
|
|
|
// AgentResult is the final output of a RunAgent run.
|
|
type AgentResult struct {
|
|
// Text is the model's final assistant text (empty when the run stopped for
|
|
// approval before producing final text).
|
|
Text string
|
|
// Messages is the full transcript including this run's new turns, so a
|
|
// caller can persist it and resume.
|
|
Messages []AgentMessage
|
|
Model string
|
|
// TokensUsed is the summed input+output tokens across all iterations.
|
|
TokensUsed int
|
|
Iterations int
|
|
// StopReason is "stop" (final text), "max_iterations", or
|
|
// "approval_required".
|
|
StopReason string
|
|
// Pending is set only when StopReason == "approval_required".
|
|
Pending *PendingToolCall
|
|
}
|
|
|
|
// Compile-time guarantees that both providers satisfy Provider, and that the
|
|
// OpenAI provider also serves the writing assistant.
|
|
var (
|
|
_ Provider = (*openAIProvider)(nil)
|
|
_ Provider = (*anthropicProvider)(nil)
|
|
_ WritingGenerator = (*openAIProvider)(nil)
|
|
_ WritingGenerator = (*AnthropicClient)(nil)
|
|
_ WritingGenerator = (*GenerationClient)(nil)
|
|
)
|
|
|
|
// CompletionRequest is a single, tool-less generation with an explicit system
|
|
// prompt. Used by reply drafts (M4) and the automation ai_step (generate mode).
|
|
type CompletionRequest struct {
|
|
System string
|
|
Prompt string
|
|
Model string
|
|
MaxTokens int
|
|
// Temperature optionally pins sampling. nil leaves it to the provider default
|
|
// (creative writing); a pointer to 0 forces deterministic output (the reply
|
|
// classifier's Layer 3 needs a stable single-label verdict).
|
|
Temperature *float64
|
|
}
|
|
|
|
// Deterministic returns a *float64 pointing at 0, for
|
|
// CompletionRequest.Temperature when a caller needs a stable, non-sampled result
|
|
// (e.g. single-label classification). Each call returns a fresh pointer.
|
|
func Deterministic() *float64 {
|
|
z := 0.0
|
|
return &z
|
|
}
|
|
|
|
// Provider is a pluggable LLM backend that can run a tool-use agent loop.
|
|
type Provider interface {
|
|
// RunAgent executes the tool-use loop and returns the final result.
|
|
RunAgent(ctx context.Context, req AgentRequest) (*AgentResult, error)
|
|
// Complete runs a single completion (no tools) with an explicit system
|
|
// prompt, returning the text + token usage.
|
|
Complete(ctx context.Context, req CompletionRequest) (*WritingResult, error)
|
|
// ModelForTier returns the provider's model id for the org tier (paid gets
|
|
// the stronger model).
|
|
ModelForTier(paid bool) string
|
|
// Name identifies the provider ("openai" or "anthropic"). Stays stable for
|
|
// accounting even when pointed at a local/self-hosted endpoint.
|
|
Name() string
|
|
// IsLocal reports whether this provider is an explicitly-declared free/local
|
|
// backend (AI_FREE). Callers use it to warn the user and to skip
|
|
// credit charges; it is never inferred from the base URL (OpenRouter/Azure
|
|
// also set one), so it stays a deliberate operator opt-in.
|
|
IsLocal() bool
|
|
}
|
|
|
|
// ErrApprovalRequired is returned by an AgentRequest.Approve hook to pause the
|
|
// loop for human approval of a write/send tool. RunAgent turns it into a
|
|
// result with StopReason "approval_required".
|
|
var ErrApprovalRequired = errors.New("tool approval required")
|
|
|
|
// ErrProviderNotConfigured is returned by NewProvider when neither an OpenAI
|
|
// nor an Anthropic key is configured.
|
|
var ErrProviderNotConfigured = errors.New("no LLM provider configured: set AI_PROVIDER and AI_API_KEY")
|
|
|
|
// Default agent-loop bounds, applied when a request leaves them zero.
|
|
const (
|
|
defaultMaxIterations = 12
|
|
defaultAgentTokens = 2048
|
|
)
|
|
|
|
// ProviderConfig configures provider construction. Only the keys and optional
|
|
// overrides that a self-hoster sets; everything else defaults sensibly.
|
|
type ProviderConfig struct {
|
|
// OpenAIAPIKey enables the OpenAI provider (the preferred/default backend).
|
|
OpenAIAPIKey string
|
|
// OpenAIBaseURL overrides the OpenAI API base (e.g. an OpenAI-compatible
|
|
// self-hosted endpoint). Empty uses the public OpenAI API.
|
|
OpenAIBaseURL string
|
|
// OpenAIModelTrial / OpenAIModelPaid override the per-tier model ids (so a
|
|
// self-hoster can route to whatever their endpoint serves). Empty uses the
|
|
// built-in gpt-4o-mini / gpt-4o defaults (or the local default when Local).
|
|
OpenAIModelTrial string
|
|
OpenAIModelPaid string
|
|
|
|
// Local marks this as a free/local model backend (AI_FREE). It flips
|
|
// the empty-model default to a local-friendly tag and lets the agent warn
|
|
// the user and skip credit charges. Set deliberately by the operator; never
|
|
// inferred from OpenAIBaseURL.
|
|
Local bool
|
|
|
|
// AnthropicAPIKey enables the Anthropic connector (used only when no OpenAI
|
|
// key is set).
|
|
AnthropicAPIKey string
|
|
|
|
// Search is the pluggable web-search client used by the search_web tool
|
|
// (M2). Optional; when nil the tool returns a clean not-configured error.
|
|
Search SearchClient
|
|
}
|
|
|
|
// NewProvider selects and constructs the active provider. OpenAI is preferred
|
|
// (Warmbly's hosted default and the pluggable self-host path); the Anthropic
|
|
// connector is used only when no OpenAI key is present. Returns
|
|
// ErrProviderNotConfigured when neither is set.
|
|
func NewProvider(cfg ProviderConfig) (Provider, error) {
|
|
if cfg.OpenAIAPIKey != "" {
|
|
return newOpenAIProvider(cfg), nil
|
|
}
|
|
if cfg.AnthropicAPIKey != "" {
|
|
return newAnthropicProvider(cfg), nil
|
|
}
|
|
return nil, ErrProviderNotConfigured
|
|
}
|