mirror of
https://github.com/hanzoai/agent.git
synced 2026-10-30 15:05:34 +00:00
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.
281 lines
13 KiB
Go
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
|
|
}
|