Files
zeekay 5b239e3730 A conversation with no member is nobody's
A conversation recorded before users were kept (2026-09-07) names no
member, and owns() admitted every member for it: in a shared org every
customer listed those threads, read their transcripts, and wrote turns
into them through the record endpoint and the round.

owns() now needs a member who matches, and the list checks the org of
each row.  A SuperAdmin (Principal.Super) alone reads a memberless
thread: ?memberless=true lists them and GET opens one, each audited, and
nothing writes, changes or shares one.  A caller that names no user is
refused, and a turn sent under an id that is not the caller's opens a
fresh thread of theirs, so no turn lands in a foreign thread and the
answer does not say the id exists.  The store logs each org's count of
memberless threads when it first opens the org.
2026-10-16 14:31:31 -07:00

281 lines
13 KiB
Go

// Package agent is a reusable agentic-chat orchestrator: one POST runs a single
// LLM tool-calling round that lets a model manage a system through tools, and it
// PERSISTS conversation history (per-org SQLite via hanzoai/orm). It is a
// library — a host (hanzoai/ai / hanzoai/cloud) mounts it on its OWN zip router so
// the round registers in the SAME router as /v1/chat/completions (a distinct path,
// no route-precedence gamble), and injects the two dependency seams:
//
// - Completer — the host's in-process LLM completion (the ONLY path that both
// returns tool_calls and carries per-org billing);
// - ToolPlane — the host's unified tool registry (list / exists / dispatch).
//
// A round yields four things: the assistant's text (reply), the tool calls the
// server executed against the registry (actions), the tool calls the client must
// apply itself — a graph/UI mutation the server cannot run (ops), and the id of
// the conversation the turn was appended to (conversationId).
//
// This package DELIBERATELY does not import hanzoai/cloud or hanzoai/ai — that
// coupling is the thing it exists to break. It builds directly on zip (routing),
// hanzoai/orm (per-org SQLite persistence) and go-openai (the request/response
// shapes); the host wraps Mount with a thin adapter that supplies the seams.
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
openai "github.com/hanzoai/go-openai"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// Completer runs one chat completion (with tools) and returns the parsed
// response. It is the seam onto the host's in-process LLM path: the real impl
// replays the request carrying the caller's credential (so the host's per-org
// billing runs); tests inject a fake so the round is exercised without a live
// model. agent never imports the package that provides the real Completer.
type Completer interface {
Complete(ctx context.Context, cred map[string]string, req openai.ChatCompletionRequest) (openai.ChatCompletionResponse, error)
}
// UpstreamError is returned by a Completer when the completion service refuses the
// request for the CALLER's OWN reason — a 4xx like 402 insufficient_balance, 429
// rate-limit, or 403 — that must reach the caller intact, not be masked as a
// gateway 502. Body is the completion's verbatim error payload; the round passes
// Status + Body straight through so a client shows the real message ("add credits")
// instead of an opaque "agent: completion" wrapper. A non-4xx completion failure
// (5xx, transport) stays a 502 — that IS a gateway fault.
type UpstreamError struct {
Status int
Body []byte
}
func (e *UpstreamError) Error() string {
return fmt.Sprintf("upstream completion %d: %s", e.Status, e.Body)
}
// Tool is the agent-facing projection of one registered tool offered to the
// model. Schema is the JSON-Schema of the call arguments; Dispatchable is false
// for a listing-only entry; Activated is true only for tools the org turned on.
type Tool struct {
Name string
Description string
Schema json.RawMessage
Activated bool
Dispatchable bool
}
// Scope is the (org, project) a tool listing is resolved for.
type Scope struct {
Org string
Project string
}
// ToolPlane is the org's registered tool registry seam: list the tools offered to
// the model, test whether a call is server-known, and dispatch a call. Dispatch
// takes the live request so the host resolves the caller the ONE canonical way (its
// PrincipalFrom) — the credential is never reconstructed or passed as a value, only
// read from the validated request. List/Exists take a plain (org, project) Scope;
// that carries no credential, so it is safe by value. The host injects its unified
// tool plane; tests inject a stub.
type ToolPlane interface {
List(ctx context.Context, scope Scope) []Tool
Exists(ctx context.Context, scope Scope, name string) bool
Dispatch(c *zip.Ctx, name string, args map[string]any) (any, error)
}
// Principal is the VALIDATED caller a round runs as. Org scopes persistence AND
// the tool listing (never a client-supplied field); Cred is the caller's own
// credential headers, opaque to agent, replayed by the injected Completer /
// ToolPlane so an in-process call carries exactly the caller's identity. Name is
// how the caller is shown to the people a share involves — the sharer to its
// readers, a reader to the sharer: the name they signed in with, never an
// email. Admin reports that the caller administers Org, and Person that the
// caller is a person signed in through the identity provider under a subject
// nobody else presents — the only callers a shared chat opens for — as the
// host's identity provider says. User is the subject a conversation is kept
// under, so it must be one no two people present; a caller without one is
// refused. Super reports platform authority (the host's SuperAdmin): it alone
// reads a conversation recorded with no member, and only reads it.
type Principal struct {
Org string
Project string
User string
Name string
Admin bool
Super bool
Person bool
Cred map[string]string
}
// Deps is agent's OWN small dependency surface — deliberately not cloud.Deps. It
// carries only what this package needs: a logger, the per-org SQLite data root,
// a brand tag, the default served model, and an optional principal resolver
// (defaults to gateway-header identity when nil).
type Deps struct {
// Logger is the canonical Hanzo logger (luxfi/log). Required.
Logger luxlog.Logger
// DataDir is the per-deployment data root. Per-org SQLite files land at
// {DataDir}/orgs/{orgSlug}/agent.db. Required.
DataDir string
// Brand is the white-label brand identifier (logged only).
Brand string
// Model is the served model used when a request supplies none.
Model string
// Principal resolves the validated caller from a request. Defaults to
// header-based resolution (X-Org-Id / X-User-Id) when nil.
Principal func(*zip.Ctx) (Principal, bool)
// Audit appends one record to the host's audit trail for a change a member
// makes: to a share, agent.share.create, agent.share.revoke or
// agent.share.unview (a viewer removed), each naming the share; to a
// conversation, agent.conversation.update (renamed, pinned or archived) or
// agent.conversation.delete, with share "". A SuperAdmin's read of what no
// member owns is recorded too: agent.conversation.read.memberless naming the
// conversation, and agent.conversation.list.memberless naming none.
// Optional; a nil Audit leaves the log line as the only record.
Audit func(c *zip.Ctx, action, conversation, share string)
}
// Service is the mounted orchestrator handle. Its handlers are plain zip
// handlers; Close releases the per-org SQLite stores at host shutdown.
type Service struct {
log luxlog.Logger
model string
store *store
completer Completer
plane ToolPlane
principal func(*zip.Ctx) (Principal, bool)
audit func(c *zip.Ctx, action, conversation, share string)
}
// DefaultPrefix is where the standalone daemon answers. A host whose own router
// already spends that address folds this surface somewhere else with MountAt;
// nothing in the round depends on which address it was given.
const DefaultPrefix = "/v1/agent"
// Mount registers the routes at DefaultPrefix. See MountAt.
func Mount(app *zip.App, deps Deps, completer Completer, plane ToolPlane) (*Service, error) {
return MountAt(app, DefaultPrefix, deps, completer, plane)
}
// MountAt registers the routes under prefix using the injected Completer and
// ToolPlane, and returns the Service so the host can Close it on shutdown. The
// per-org SQLite models are auto-migrated (schema created) on first per-org open.
// prefix is an absolute path chosen by whoever composes the router:
//
// POST {prefix} — run one tool-calling round
// GET {prefix}/presets — list the preset library
// POST {prefix}/conversations — record turns in a conversation
// GET {prefix}/conversations — list the caller's own conversations
// GET {prefix}/conversations/:id — one of them, its messages
// PATCH {prefix}/conversations/:id — rename, pin or archive it (its owner)
// DELETE {prefix}/conversations/:id — delete it, its turns and its links (its owner)
// POST {prefix}/conversations/:id/shares — make a read-only link
// GET {prefix}/conversations/:id/shares — list its live links and viewers
// DELETE {prefix}/conversations/:id/shares/:share — revoke one
// DELETE {prefix}/conversations/:id/shares/:share/viewers/:viewer — remove a viewer
// POST {prefix}/shares/read — open a link
// GET {prefix}/shared — the chats shared with the caller
// GET {prefix}/shared/:share — read one of them
// GET {prefix}/shares — every live link in the org (admin)
// DELETE {prefix}/shares/:share — revoke any of them (admin)
func MountAt(app *zip.App, prefix string, deps Deps, completer Completer, plane ToolPlane) (*Service, error) {
if app == nil {
return nil, fmt.Errorf("agent.Mount: nil zip.App")
}
if luxlog.Default() == nil {
return nil, fmt.Errorf("agent.Mount: nil luxlog.Default()")
}
if completer == nil {
return nil, fmt.Errorf("agent.Mount: nil Completer")
}
if plane == nil {
return nil, fmt.Errorf("agent.Mount: nil ToolPlane")
}
if strings.TrimSpace(deps.DataDir) == "" {
return nil, fmt.Errorf("agent.Mount: empty DataDir")
}
prefix = strings.TrimRight(strings.TrimSpace(prefix), "/")
if !strings.HasPrefix(prefix, "/") {
return nil, fmt.Errorf("agent.Mount: prefix %q is not an absolute path", prefix)
}
resolve := deps.Principal
if resolve == nil {
resolve = headerPrincipal
}
s := &Service{
log: luxlog.Default().New("subsystem", "agent"),
model: strings.TrimSpace(deps.Model),
store: newStore(deps.DataDir),
completer: completer,
plane: plane,
principal: resolve,
audit: deps.Audit,
}
app.Raw(http.MethodPost, prefix, s.handleRun)
app.Raw(http.MethodGet, prefix+"/presets", s.handlePresets)
app.Raw(http.MethodPost, prefix+"/conversations", s.handleRecord)
app.Raw(http.MethodGet, prefix+"/conversations", s.handleListConversations)
app.Raw(http.MethodGet, prefix+"/conversations/:id", s.handleConversation)
app.Raw(http.MethodPatch, prefix+"/conversations/:id", s.handleUpdateConversation)
app.Raw(http.MethodDelete, prefix+"/conversations/:id", s.handleDeleteConversation)
app.Raw(http.MethodPost, prefix+"/conversations/:id/shares", s.handleShare)
app.Raw(http.MethodGet, prefix+"/conversations/:id/shares", s.handleShares)
app.Raw(http.MethodDelete, prefix+"/conversations/:id/shares/:share", s.handleUnshare)
app.Raw(http.MethodDelete, prefix+"/conversations/:id/shares/:share/viewers/:viewer", s.handleUnview)
app.Raw(http.MethodPost, prefix+"/shares/read", s.handleOpenShare)
app.Raw(http.MethodGet, prefix+"/shared", s.handleSharedWithMe)
app.Raw(http.MethodGet, prefix+"/shared/:share", s.handleReadShared)
app.Raw(http.MethodGet, prefix+"/shares", s.handleOrgShares)
app.Raw(http.MethodDelete, prefix+"/shares/:share", s.handleOrgUnshare)
s.log.Info("agent mounted", "route", prefix, "presets", len(presets), "brand", deps.Brand)
return s, nil
}
// Close releases every open per-org store. Idempotent.
func (s *Service) Close() error {
if s == nil || s.store == nil {
return nil
}
return s.store.closeAll()
}
// caller resolves the validated principal or returns a 403 error. Org scopes
// persistence and tools, and User is whose conversation it is: a request that
// names no org, or names nobody, never lists, reads, records or runs.
func (s *Service) caller(c *zip.Ctx) (Principal, error) {
p, ok := s.principal(c)
if !ok || strings.TrimSpace(p.Org) == "" || strings.TrimSpace(p.User) == "" {
return Principal{}, zip.ErrForbidden("a validated principal is required")
}
return p, nil
}
// credentialHeaders are the caller's own credential headers captured for replay
// by the injected Completer / ToolPlane, so an in-process call runs under the SAME
// identity check as a direct HTTP call. NO minted authority header (X-Org-Id/…) is
// captured — those are gateway-minted from a re-validated credential.
var credentialHeaders = []string{"Authorization", "X-Authorization", "Cookie", "Accept-Language", "X-Forwarded-For"}
// headerPrincipal is the default resolver: it reads the gateway-minted,
// ingress-validated identity headers (X-Org-Id / X-User-Id) that zip.Ctx exposes
// and captures the caller's credential headers for replay. A host with a richer
// identity middleware injects its own Deps.Principal (e.g. wrapping the host's
// PrincipalFrom so Project and the validated credential flow through).
func headerPrincipal(c *zip.Ctx) (Principal, bool) {
org := strings.TrimSpace(c.Org())
if org == "" {
return Principal{}, false
}
cred := map[string]string{}
for _, h := range credentialHeaders {
if v := c.Header(h); v != "" {
cred[h] = v
}
}
return Principal{Org: org, User: c.User(), Name: c.Header(zip.HeaderUserName), Admin: c.IsOrgAdmin(), Super: c.IsAdmin(), Person: c.User() != "", Cred: cred}, true
}