ai

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: Apache-2.0 Imports: 15 Imported by: 0

Documentation

Overview

Package ai implements the BYOK AI assistant chat feature: a provider-agnostic turn engine (engine.go) that runs a conversation against an LLM (Provider below) with tool use backed by the existing MCP tool surface (toolcaller.go, internal/mcptools), pausing every mutating tool call for an explicit human confirmation click (classify.go). No AI in the reconciliation path: this package only ever calls the platform's own REST API the same way any other MCP client would (internal/apiclient), matching the CLAUDE.md section 2 non-goal ("AI is a read-and-suggest layer on top of the API, nothing more").

Index

Constants

View Source
const (
	RoleUser      = "user"
	RoleAssistant = "assistant"
)

Role values for Message, matching Anthropic's own role vocabulary. There is no "tool" role here: a tool result travels as a ContentBlock of type ContentBlockToolResult inside a user-role Message, exactly how the Anthropic Messages API itself expects it.

View Source
const (
	ContentBlockText       = "text"
	ContentBlockToolUse    = "tool_use"
	ContentBlockToolResult = "tool_result"
)

ContentBlock kinds a Message can carry.

View Source
const (
	StopReasonEndTurn   = "end_turn"
	StopReasonToolUse   = "tool_use"
	StopReasonMaxTokens = "max_tokens"
	StopReasonOther     = "other"
)

Stop reasons TurnResult.StopReason can carry, a small subset of Anthropic's own stop_reason values relevant to this engine's loop.

View Source
const SecretsAPIKeyEnvKey = "api_key" //nolint:gosec // an envKey name, not a credential value

SecretsAPIKeyEnvKey is the internal/secrets envKey the BYOK LLM API key is stored under, keyed by store.AIAssistantSecretsKey(). Shared between this package (reading it back for a call) and internal/api/ai_settings.go (writing it on PUT), so there is exactly one definition of the envKey string.

Variables

View Source
var (
	// ErrNotConfigured means no provider/model/key has been saved yet.
	// Callers (internal/api) map this to 501, the same "not configured"
	// shape WithEmailSecrets' absence produces.
	ErrNotConfigured = errors.New("ai: assistant is not configured")
	// ErrPendingConfirmation means the session's last assistant turn
	// still has a mutating tool call awaiting a confirmation click:
	// RunTurn refuses to start a new turn until it resolves, so the
	// conversation sent to the model is never missing a tool_result.
	ErrPendingConfirmation = errors.New("ai: a previous tool call is still awaiting confirmation")
	// ErrConfirmationSessionMismatch means the confirmation id exists but
	// belongs to a different session than the URL's {id} named.
	ErrConfirmationSessionMismatch = errors.New("ai: confirmation does not belong to this session")
	// ErrConfirmationAlreadyResolved means POST .../confirmations/{id}
	// was called a second time for an id that already left "pending".
	ErrConfirmationAlreadyResolved = errors.New("ai: confirmation already resolved")
)

Functions

This section is empty.

Types

type AnthropicProvider

type AnthropicProvider struct {
	// contains filtered or unexported fields
}

AnthropicProvider implements Provider against the real Anthropic Messages API, using the streaming (server-sent events) variant so TextDeltaFunc receives real incremental chunks as the model generates them, not a single chunk after the full response lands.

func NewAnthropicProvider

func NewAnthropicProvider(apiKey string, httpClient *http.Client) *AnthropicProvider

NewAnthropicProvider builds a provider using apiKey (the BYOK key resolved from internal/secrets at call time, never persisted here) and httpClient (defaulted to http.DefaultClient if nil).

func (*AnthropicProvider) Complete

func (p *AnthropicProvider) Complete(ctx context.Context, req ChatRequest, onText TextDeltaFunc) (TurnResult, error)

Complete implements Provider by POSTing req to the real Messages API with stream:true and parsing the server-sent event stream, forwarding every text delta to onText as it arrives.

type ChatRequest

type ChatRequest struct {
	System   string
	Messages []Message
	Tools    []ToolSpec
	Model    string
}

ChatRequest is one call to Provider.Complete: the full conversation so far (system prompt separate, matching Anthropic's own top-level `system` field), the tool catalog, and which model to use.

type ContentBlock

type ContentBlock struct {
	Type string

	// Text is set when Type is ContentBlockText.
	Text string

	// ToolUseID, ToolName, ToolInput are set when Type is
	// ContentBlockToolUse: a tool the model wants to invoke.
	ToolUseID string
	ToolName  string
	ToolInput json.RawMessage

	// ToolResultForID, ToolResultContent, ToolResultIsError are set when
	// Type is ContentBlockToolResult: an already-executed tool's outcome,
	// keyed back to the ContentBlockToolUse.ToolUseID it answers.
	ToolResultForID   string
	ToolResultContent string
	ToolResultIsError bool
}

ContentBlock is one block of a Message's content, mirroring the Anthropic Messages API's own content-block union closely enough that building a request is a direct translation, not a redesign.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine runs the chat turn loop: send the conversation to the model, auto-execute any read-only tool call it requests, pause every mutating one for confirmation, and repeat until the model produces a plain reply with no further tool calls.

func NewEngine

func NewEngine(s Store, secrets SecretsResolver, tools ToolExecutor, provider ProviderFactory, brandName string) *Engine

NewEngine builds an Engine. provider defaults to DefaultProviderFactory if nil. brandName names the platform in the system prompt (the caller's own brand.Brand.Name, per section 3 of CLAUDE.md); "" falls back to defaultPlatformName rather than a hardcoded product name.

func (*Engine) IsConfigured

func (e *Engine) IsConfigured(ctx context.Context) (bool, error)

IsConfigured reports whether a provider, model, and key have all been saved, so the API layer can return 501 before ever starting an SSE response, rather than mid-stream.

func (*Engine) ResolveConfirmation

func (e *Engine) ResolveConfirmation(ctx context.Context, sessionID, confirmationID string, approve bool, sink Sink) error

ResolveConfirmation executes (approve=true) or rejects (approve=false) a previously-proposed mutating tool call, then, once every mutating call from that same assistant turn has been resolved, continues the turn using the result(s), streamed to sink the same way RunTurn does.

func (*Engine) RunTurn

func (e *Engine) RunTurn(ctx context.Context, sessionID, userContent string, sink Sink) error

RunTurn appends userContent as a new user message and runs the turn loop until the model stops (no further tool calls) or a mutating tool call is proposed and needs confirmation. sink receives every event as it happens; the caller sends the final "done" SSE event once this returns, success or error.

type Message

type Message struct {
	Role    string
	Content []ContentBlock
}

Message is one turn in a conversation, provider-agnostic. Engine reconstructs this from store.AIChatMessage rows on every turn; Provider implementations translate it into their own wire format.

type Provider

type Provider interface {
	Complete(ctx context.Context, req ChatRequest, onText TextDeltaFunc) (TurnResult, error)
}

Provider sends one turn of a conversation to an LLM and streams its text back. Implementations: AnthropicProvider (anthropic.go) for the real API; tests substitute a fake satisfying just this interface, no network involved.

func DefaultProviderFactory

func DefaultProviderFactory(provider, apiKey string) (Provider, error)

DefaultProviderFactory supports store.AIProviderAnthropic only today; see that constant's own doc comment on why this switch is structured to make adding another provider a small addition, not a rewrite.

type ProviderFactory

type ProviderFactory func(provider, apiKey string) (Provider, error)

ProviderFactory builds a Provider for one BYOK call, given the resolved provider name and API key. Swappable in tests to avoid a real network call.

type SecretsResolver

type SecretsResolver interface {
	Resolve(ctx context.Context, serviceName, envKey string) (string, error)
	Exists(ctx context.Context, serviceName, envKey string) (bool, error)
}

SecretsResolver is the narrow internal/secrets.Manager surface Engine needs to fetch the BYOK key at call time; never cached across turns.

type Sink

type Sink interface {
	TextDelta(text string)
	ToolCallProposed(confirmationID, toolUseID, toolName string, arguments json.RawMessage)
	ToolResult(toolUseID, toolName string, result json.RawMessage, isError bool)
}

Sink receives events as Engine runs a turn, translated by the caller (internal/api) into the SSE event types the API contract documents: TextDelta -> text_delta, ToolCallProposed -> tool_call_proposed, ToolResult -> tool_result. The final "done" event is the caller's own responsibility once RunTurn/ResolveConfirmation returns, not part of this interface, since it must fire exactly once even on an error path.

type Store

type Store interface {
	ListAIChatMessages(ctx context.Context, sessionID string) ([]store.AIChatMessage, error)
	SaveAIChatMessage(ctx context.Context, m store.AIChatMessage) error
	UpdateAIChatMessageToolCalls(ctx context.Context, messageID string, toolCalls []store.AIChatToolCall) error
	GetAIChatMessage(ctx context.Context, id string) (store.AIChatMessage, error)
	SaveAIChatConfirmation(ctx context.Context, c store.AIChatConfirmation) error
	GetAIChatConfirmation(ctx context.Context, id string) (store.AIChatConfirmation, error)
	ResolveAIChatConfirmation(ctx context.Context, id, status string, result json.RawMessage, resolvedAt time.Time) error
	ListPendingAIChatConfirmations(ctx context.Context, messageID string) ([]store.AIChatConfirmation, error)
	GetAIAssistantSettings(ctx context.Context) (store.AIAssistantSettings, error)
}

Store is the narrow internal/store surface Engine needs, satisfied structurally by *store.DB, the same "narrow interface at the boundary" shape internal/secrets.Store already establishes for that package.

type TextDeltaFunc

type TextDeltaFunc func(delta string)

TextDeltaFunc receives one incremental chunk of assistant text as a Provider streams its response, so a caller (internal/api's SSE handler) can forward it as a text_delta event immediately rather than waiting for the whole turn to finish.

type ToolCaller

type ToolCaller struct {
	// contains filtered or unexported fields
}

ToolCaller runs tool calls against the platform's own MCP tool surface, connected in-process: no subprocess, no network transport. It builds the same *mcp.Server internal/mcptools.NewServer gives cmd/levelrail-mcp and connects an MCP client to it over mcp.NewInMemoryTransports, the SDK's own in-process test harness, reused here as a real (not test-only) transport.

func NewToolCaller

func NewToolCaller(ctx context.Context, client *apiclient.Client) (*ToolCaller, error)

NewToolCaller builds a ToolCaller whose tools call back into the control plane's own REST API through client, exactly like an external MCP client (or cmd/levelrail-cli) would.

func (*ToolCaller) Call

func (tc *ToolCaller) Call(ctx context.Context, name string, args json.RawMessage) (result string, isError bool, err error)

Call invokes name with args (a JSON object), returning its result as text (every real tool here returns JSON structured content, joined from TextContent blocks the same way cmd/levelrail-mcp's own tests already extract a result) and whether the call itself errored.

func (*ToolCaller) Close

func (tc *ToolCaller) Close() error

Close ends the underlying MCP session.

func (*ToolCaller) ListTools

func (tc *ToolCaller) ListTools(ctx context.Context) ([]ToolSpec, error)

ListTools returns every tool the platform exposes, translated to the provider-agnostic ToolSpec shape.

type ToolExecutor

type ToolExecutor interface {
	ListTools(ctx context.Context) ([]ToolSpec, error)
	Call(ctx context.Context, name string, args json.RawMessage) (result string, isError bool, err error)
}

ToolExecutor is the narrow ToolCaller surface Engine needs; *ToolCaller satisfies this structurally, a fake satisfies it for tests without a real in-process MCP session.

type ToolSpec

type ToolSpec struct {
	Name        string
	Description string
	InputSchema json.RawMessage
	Traits      ToolTraits
}

ToolSpec is one callable tool's definition, as ToolCaller.ListTools returns it: name, description, and its input JSON Schema, the same three fields an MCP tool and an Anthropic tool definition both reduce to.

type ToolTraits

type ToolTraits struct {
	Known           bool
	ReadOnly        bool
	Destructive     bool
	OpenWorld       bool
	Sensitive       bool
	UntrustedOutput bool
}

ToolTraits is what the assistant knows about a tool from its MCP annotations. The zero value (Known false) is a tool with no annotations, which always requires confirmation.

func TraitsFromMCP

func TraitsFromMCP(a *mcp.ToolAnnotations, meta map[string]any) ToolTraits

TraitsFromMCP derives ToolTraits from a tool's annotations and _meta. Absent annotations mean unknown; an absent OpenWorldHint means open world, per the MCP default.

func (ToolTraits) IngestsUntrusted

func (t ToolTraits) IngestsUntrusted() bool

IngestsUntrusted reports whether running the tool puts untrusted text in the conversation. An unknown tool is assumed to.

func (ToolTraits) RequiresConfirmation

func (t ToolTraits) RequiresConfirmation(tainted bool) bool

RequiresConfirmation reports whether a call must pause for a human click. Anything that is not a known read-only tool always pauses. Once the conversation has ingested untrusted text (tainted), a read-only tool that is outbound or touches secrets pauses too.

type ToolUseCall

type ToolUseCall struct {
	ID    string
	Name  string
	Input json.RawMessage
}

ToolUseCall is one tool invocation the model requested in a single turn, returned as part of TurnResult.

type TurnResult

type TurnResult struct {
	Text       string
	ToolCalls  []ToolUseCall
	StopReason string
}

TurnResult is what Provider.Complete returns once a single model turn finishes: the assistant's full text for this turn (already streamed incrementally via the onText callback, repeated here for storage) plus any tool calls it wants to make.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL