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
- Variables
- func WithTurnCredential(ctx context.Context, c TurnCredential) context.Context
- type AnthropicProvider
- type AttributedSink
- type ChatRequest
- type ContentBlock
- type Engine
- func (e *Engine) IsConfigured(ctx context.Context) (bool, error)
- func (e *Engine) ResolveConfirmation(ctx context.Context, sessionID, confirmationID string, approve bool, sink Sink) error
- func (e *Engine) RunTurn(ctx context.Context, sessionID, userContent string, sink Sink) error
- func (e *Engine) SetToolFactory(f ToolFactory)
- type Message
- type Provider
- type ProviderFactory
- type SecretsResolver
- type Sink
- type Store
- type TextDeltaFunc
- type ToolAttribution
- type ToolCaller
- type ToolExecutor
- type ToolFactory
- type ToolSpec
- type ToolTraits
- type ToolUseCall
- type TurnCredential
- type TurnResult
Constants ¶
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.
const ( ContentBlockText = "text" ContentBlockToolUse = "tool_use" ContentBlockToolResult = "tool_result" )
ContentBlock kinds a Message can carry.
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.
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 ¶
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") )
var ErrNoTurnCredential = errors.New("ai: no turn credential: tools run only as the human who sent the message")
ErrNoTurnCredential means a turn reached the tool layer without the short-lived credential of the human who sent it.
Functions ¶
func WithTurnCredential ¶ added in v0.3.0
func WithTurnCredential(ctx context.Context, c TurnCredential) context.Context
WithTurnCredential attaches the turn's credential to ctx.
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 AttributedSink ¶ added in v0.3.0
type AttributedSink interface {
ToolResultAttributed(toolUseID, toolName string, result json.RawMessage, isError bool, attr ToolAttribution)
}
AttributedSink is a Sink that also streams a tool result's attribution.
type ChatRequest ¶
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 ¶
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 ¶
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.
func (*Engine) SetToolFactory ¶ added in v0.3.0
func (e *Engine) SetToolFactory(f ToolFactory)
SetToolFactory makes every turn build its tools from the sender's own turn credential; a turn without one is refused rather than run with e.tools.
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 ¶
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 ¶
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 ToolAttribution ¶ added in v0.3.0
ToolAttribution says who a tool call ran as and which abilities it used.
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 ¶
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.
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 ToolFactory ¶ added in v0.3.0
type ToolFactory func(ctx context.Context, token string) (ToolExecutor, error)
ToolFactory builds the tool surface for one turn, authenticated with token.
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 TurnCredential ¶ added in v0.3.0
type TurnCredential struct {
// Token is a short-lived bearer minted for this turn only, capped to the sender.
Token string
// ActingAs names the human principal the token stands in for.
ActingAs string
// AbilitiesUsed drains the abilities the API checked since its last call; nil is allowed.
AbilitiesUsed func() []string
}
TurnCredential is the authority one chat turn's tool calls run with.
func TurnCredentialFrom ¶ added in v0.3.0
func TurnCredentialFrom(ctx context.Context) (TurnCredential, bool)
TurnCredentialFrom returns the turn credential attached to ctx, if any.
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.