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.
485 lines
17 KiB
Go
485 lines
17 KiB
Go
package agent
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
openai "github.com/hanzoai/go-openai"
|
|
"github.com/hanzoai/orm"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// ── request / response ──────────────────────────────────────────────────────────
|
|
|
|
type inMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// runRequest is the round's POST body. Preset is the primary selector;
|
|
// Capability is a legacy alias for the same field. Org is NEVER read from the
|
|
// body — it comes only from the validated principal.
|
|
type runRequest struct {
|
|
Messages []inMessage `json:"messages"`
|
|
Preset string `json:"preset"`
|
|
Capability string `json:"capability"`
|
|
Tools []openai.Tool `json:"tools"`
|
|
System string `json:"system"`
|
|
ConversationID string `json:"conversationId"`
|
|
}
|
|
|
|
// action is one tool call the server executed against the registry.
|
|
type action struct {
|
|
Name string `json:"name"`
|
|
Args map[string]any `json:"args,omitempty"`
|
|
Result any `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// op is one tool call the client must apply itself (a graph/UI mutation).
|
|
type op struct {
|
|
Name string `json:"name"`
|
|
Args map[string]any `json:"args,omitempty"`
|
|
}
|
|
|
|
type runResponse struct {
|
|
Reply string `json:"reply"`
|
|
Actions []action `json:"actions"`
|
|
Ops []op `json:"ops"`
|
|
ConversationID string `json:"conversationId"`
|
|
}
|
|
|
|
// ── run handler ─────────────────────────────────────────────────────────────────
|
|
|
|
// handleRun runs one tool-calling round: resolve the caller, load-or-create the
|
|
// conversation (scoped to the caller's org), persist the incoming user turn, call
|
|
// the injected completion, split the model's tool calls into server-executed
|
|
// actions vs client-applied ops, persist the assistant reply, and return the
|
|
// four-part result plus the conversation id.
|
|
func (s *Service) handleRun(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var body runRequest
|
|
if err := c.Bind(&body); err != nil {
|
|
return err
|
|
}
|
|
if len(body.Messages) == 0 {
|
|
return zip.ErrBadRequest("messages required")
|
|
}
|
|
presetID := strings.TrimSpace(body.Preset)
|
|
if presetID == "" {
|
|
presetID = strings.TrimSpace(body.Capability)
|
|
}
|
|
mode, ok := presetFor(presetID)
|
|
if !ok {
|
|
return zip.ErrBadRequest("unknown preset: " + presetID)
|
|
}
|
|
|
|
ctx := c.Context()
|
|
|
|
conv, err := s.store.loadOrCreateConversation(ctx, p.Org, p.User, body.ConversationID, firstUserContent(body.Messages))
|
|
if err != nil {
|
|
return conversationRefusal(err)
|
|
}
|
|
|
|
if last := lastUserMessage(body.Messages); last != "" {
|
|
if _, err := s.store.appendMessage(ctx, p.Org, conv.Id(), openai.ChatMessageRoleUser, last, "", nil); err != nil {
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: persist user: %v", err)
|
|
}
|
|
}
|
|
|
|
scope := Scope{Org: p.Org, Project: p.Project}
|
|
req := openai.ChatCompletionRequest{
|
|
Model: s.model,
|
|
Messages: assembleMessages(mode, body),
|
|
Tools: s.assembleTools(ctx, mode, body.Tools, scope),
|
|
}
|
|
|
|
resp, err := s.completer.Complete(ctx, p.Cred, req)
|
|
if err != nil {
|
|
// A completion refused for the caller's OWN reason (402 insufficient_balance,
|
|
// 429 rate-limit, 403) is the caller's error, not a gateway fault — pass its
|
|
// status + body through verbatim so the client shows the real billing message,
|
|
// never an opaque 502. Any other failure (5xx, transport) IS a gateway fault.
|
|
var up *UpstreamError
|
|
if errors.As(err, &up) && up.Status >= 400 && up.Status < 500 {
|
|
c.SetHeader("Content-Type", "application/json")
|
|
return c.Bytes(up.Status, up.Body)
|
|
}
|
|
return zip.Errorf(http.StatusBadGateway, "agent: completion: %v", err)
|
|
}
|
|
if len(resp.Choices) == 0 {
|
|
return zip.Errorf(http.StatusBadGateway, "agent: completion returned no choices")
|
|
}
|
|
msg := resp.Choices[0].Message
|
|
|
|
out := runResponse{Reply: msg.Content, Actions: []action{}, Ops: []op{}, ConversationID: conv.Id()}
|
|
for _, tc := range msg.ToolCalls {
|
|
name := tc.Function.Name
|
|
args := decodeArgs(tc.Function.Arguments)
|
|
if mode.ServerExecuted && s.plane.Exists(ctx, scope, name) {
|
|
a := action{Name: name, Args: args}
|
|
if result, derr := s.plane.Dispatch(c, name, args); derr != nil {
|
|
a.Error = derr.Error()
|
|
} else {
|
|
a.Result = result
|
|
}
|
|
out.Actions = append(out.Actions, a)
|
|
continue
|
|
}
|
|
out.Ops = append(out.Ops, op{Name: name, Args: args})
|
|
}
|
|
|
|
// The answer is stamped with the model that produced it only when everything
|
|
// the caller put in front of that model is in the stored transcript a reader
|
|
// of this conversation sees. Caller instructions, caller tools, or turns the
|
|
// store does not hold shaped the answer out of sight, so it is kept unstamped
|
|
// and a share shows it as the sharer's.
|
|
model := ""
|
|
if whole, err := s.store.whole(ctx, p.Org, p.User, conv.Id(), body); err != nil {
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: provenance: %v", err)
|
|
} else if whole {
|
|
model = cmp.Or(strings.TrimSpace(resp.Model), req.Model)
|
|
}
|
|
if err := s.persistAssistant(ctx, p.Org, conv, msg, model); err != nil {
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: persist assistant: %v", err)
|
|
}
|
|
return c.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// persistAssistant marks the conversation spoken in (`touch`), so a listing
|
|
// surfaces recent threads first, and appends the assistant reply (with its tool
|
|
// calls, if any), marked with the model that produced it. A thread its owner
|
|
// deleted while the model answered keeps no reply.
|
|
func (s *Service) persistAssistant(ctx context.Context, org string, conv *Conversation, msg openai.ChatCompletionMessage, model string) error {
|
|
var toolCalls json.RawMessage
|
|
if len(msg.ToolCalls) > 0 {
|
|
b, err := json.Marshal(msg.ToolCalls)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
toolCalls = b
|
|
}
|
|
if alive, err := s.store.touch(ctx, org, conv.Id()); err != nil || !alive {
|
|
return err
|
|
}
|
|
_, err := s.store.appendMessage(ctx, org, conv.Id(), openai.ChatMessageRoleAssistant, msg.Content, model, toolCalls)
|
|
return err
|
|
}
|
|
|
|
// ── read handlers ────────────────────────────────────────────────────────────────
|
|
|
|
func (s *Service) handlePresets(c *zip.Ctx) error {
|
|
return c.JSON(http.StatusOK, map[string]any{"presets": Presets()})
|
|
}
|
|
|
|
type convSummary struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
Pinned bool `json:"pinned"`
|
|
Archived bool `json:"archived"`
|
|
}
|
|
|
|
func summary(cv *Conversation) convSummary {
|
|
return convSummary{ID: cv.Id(), Title: cv.Title, UpdatedAt: cv.UpdatedAt, Pinned: cv.Pinned, Archived: cv.Archived}
|
|
}
|
|
|
|
// handleListConversations lists the caller's conversations, pinned first, then
|
|
// most recently spoken in. Archived ones are left out; `?archived=true` lists
|
|
// them alone. `?memberless=true` lists instead the org's conversations recorded
|
|
// with no member, for a SuperAdmin alone (403 for anyone else), and each such
|
|
// list is audited. GET {prefix}/conversations.
|
|
func (s *Service) handleListConversations(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
keep := func(cv *Conversation) bool { return owns(cv, p.User) }
|
|
memberless := c.Query("memberless") == "true"
|
|
if memberless {
|
|
if !p.Super {
|
|
return zip.ErrForbidden("only a SuperAdmin lists conversations recorded with no member")
|
|
}
|
|
keep = nobodys
|
|
}
|
|
items, err := s.store.listConversations(c.Context(), p.Org, c.Query("archived") == "true", keep)
|
|
if err != nil {
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: list: %v", err)
|
|
}
|
|
out := make([]convSummary, 0, len(items))
|
|
for _, cv := range items {
|
|
out = append(out, summary(cv))
|
|
}
|
|
if memberless {
|
|
s.record(c, p, "agent.conversation.list.memberless", "", "")
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"conversations": out})
|
|
}
|
|
|
|
// handleUpdateConversation renames, pins or archives one of the caller's own
|
|
// conversations: `{title?, pinned?, archived?}`, each absent left as it is.
|
|
// Another member's thread, one recorded with no member, and another org's
|
|
// answer 404. PATCH {prefix}/conversations/:id.
|
|
func (s *Service) handleUpdateConversation(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return zip.ErrBadRequest("conversation id required")
|
|
}
|
|
var body change
|
|
if err := c.Bind(&body); err != nil {
|
|
return err
|
|
}
|
|
if body.Title == nil && body.Pinned == nil && body.Archived == nil {
|
|
return zip.ErrBadRequest("nothing to change: send title, pinned or archived")
|
|
}
|
|
conv, err := s.store.updateConversation(c.Context(), p.Org, p.User, id, body)
|
|
if errors.Is(err, errEmptyTitle) {
|
|
return zip.ErrBadRequest("title must not be empty")
|
|
}
|
|
if err != nil {
|
|
return conversationRefusal(err)
|
|
}
|
|
s.record(c, p, "agent.conversation.update", id, "")
|
|
return c.JSON(http.StatusOK, summary(conv))
|
|
}
|
|
|
|
// handleDeleteConversation deletes one of the caller's own conversations for
|
|
// good: its turns, and every link to it, which stops opening for everyone it
|
|
// was shared with. Answers 404 as the update does. DELETE {prefix}/conversations/:id.
|
|
func (s *Service) handleDeleteConversation(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return zip.ErrBadRequest("conversation id required")
|
|
}
|
|
if err := s.store.deleteConversation(c.Context(), p.Org, p.User, id); err != nil {
|
|
return conversationRefusal(err)
|
|
}
|
|
s.record(c, p, "agent.conversation.delete", id, "")
|
|
return c.JSON(http.StatusOK, map[string]any{"id": id, "deleted": true})
|
|
}
|
|
|
|
// recordRequest is the POST {prefix}/conversations body: the turns to keep, and
|
|
// the thread to keep them in. An absent ConversationID opens a new one, exactly
|
|
// as the round's does.
|
|
type recordRequest struct {
|
|
ConversationID string `json:"conversationId"`
|
|
Messages []inMessage `json:"messages"`
|
|
}
|
|
|
|
// handleRecord writes turns to the store without running a completion.
|
|
//
|
|
// The round already persists what it answers, but it is the only writer, so a
|
|
// surface that streams its own turn through /v1/chat/completions has nowhere to
|
|
// put the transcript — it gets an answer and no history, and the reader's thread
|
|
// list stays empty however much they talk. Streaming is transport and the
|
|
// transcript is a store; they are separate concerns and this is the store's own
|
|
// door.
|
|
//
|
|
// It takes the SAME two store calls the round takes, in the same order, under
|
|
// the same per-org isolation. There is no second store and no second notion of a
|
|
// thread: a conversation recorded here reads back through the two GETs beside
|
|
// it, and the round can continue it by id.
|
|
func (s *Service) handleRecord(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var body recordRequest
|
|
if err := c.Bind(&body); err != nil {
|
|
return err
|
|
}
|
|
if len(body.Messages) == 0 {
|
|
return zip.ErrBadRequest("messages required")
|
|
}
|
|
|
|
ctx := c.Context()
|
|
conv, err := s.store.loadOrCreateConversation(ctx, p.Org, p.User, body.ConversationID, firstUserContent(body.Messages))
|
|
if err != nil {
|
|
return conversationRefusal(err)
|
|
}
|
|
for _, m := range body.Messages {
|
|
role := strings.TrimSpace(m.Role)
|
|
if role == "" || strings.TrimSpace(m.Content) == "" {
|
|
continue
|
|
}
|
|
if _, err := s.store.appendMessage(ctx, p.Org, conv.Id(), role, m.Content, "", nil); err != nil {
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: persist: %v", err)
|
|
}
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"conversationId": conv.Id()})
|
|
}
|
|
|
|
// conversationRefusal turns a store error into the answer a caller gets: another
|
|
// member's thread reads as absent, anything else as the failure it is.
|
|
func conversationRefusal(err error) error {
|
|
if errors.Is(err, orm.ErrNotFound) {
|
|
return zip.ErrNotFound("conversation not found")
|
|
}
|
|
if errors.Is(err, errNoMember) {
|
|
return zip.ErrForbidden("a validated principal is required")
|
|
}
|
|
return zip.Errorf(http.StatusInternalServerError, "agent: conversation: %v", err)
|
|
}
|
|
|
|
type msgOut struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Model string `json:"model,omitempty"`
|
|
ToolCalls json.RawMessage `json:"toolCalls,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
func (s *Service) handleConversation(c *zip.Ctx) error {
|
|
p, err := s.caller(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
if id == "" {
|
|
return zip.ErrBadRequest("conversation id required")
|
|
}
|
|
conv, msgs, err := s.store.conversationMessages(c.Context(), p.Org, p.User, p.Super, id)
|
|
if err != nil {
|
|
return conversationRefusal(err)
|
|
}
|
|
if !owns(conv, p.User) {
|
|
// A SuperAdmin reading a conversation recorded with no member: a privileged
|
|
// read of what nobody can be shown to have written, so it is on the trail.
|
|
s.record(c, p, "agent.conversation.read.memberless", id, "")
|
|
}
|
|
out := make([]msgOut, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
out = append(out, msgOut{ID: m.Id(), Role: m.Role, Content: m.Content, Model: m.Producer, ToolCalls: m.ToolCalls, CreatedAt: m.CreatedAt})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]any{"conversationId": id, "messages": out})
|
|
}
|
|
|
|
// ── assembly ────────────────────────────────────────────────────────────────────
|
|
|
|
// assembleMessages prepends the preset's system prompt (plus any caller-supplied
|
|
// system text) to the caller's messages.
|
|
func assembleMessages(mode Preset, body runRequest) []openai.ChatCompletionMessage {
|
|
system := mode.SystemPrompt
|
|
if extra := strings.TrimSpace(body.System); extra != "" {
|
|
system = strings.TrimSpace(system + "\n\n" + extra)
|
|
}
|
|
msgs := make([]openai.ChatCompletionMessage, 0, len(body.Messages)+1)
|
|
if system != "" {
|
|
msgs = append(msgs, openai.ChatCompletionMessage{Role: openai.ChatMessageRoleSystem, Content: system})
|
|
}
|
|
for _, m := range body.Messages {
|
|
role := strings.TrimSpace(m.Role)
|
|
if role == "" {
|
|
role = openai.ChatMessageRoleUser
|
|
}
|
|
msgs = append(msgs, openai.ChatCompletionMessage{Role: role, Content: m.Content})
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
// assembleTools builds the tool array offered to the model: the preset's builtin
|
|
// tools, then the caller-passed defs, then the org's ACTIVATED + dispatchable
|
|
// registry tools — deduped by function name (an earlier, more-specific def wins).
|
|
func (s *Service) assembleTools(ctx context.Context, mode Preset, caller []openai.Tool, scope Scope) []openai.Tool {
|
|
out := make([]openai.Tool, 0, len(mode.BuiltinTools)+len(caller))
|
|
seen := map[string]bool{}
|
|
add := func(t openai.Tool) {
|
|
if t.Function == nil || t.Function.Name == "" || seen[t.Function.Name] {
|
|
return
|
|
}
|
|
seen[t.Function.Name] = true
|
|
out = append(out, t)
|
|
}
|
|
for _, t := range mode.BuiltinTools {
|
|
add(t)
|
|
}
|
|
for _, t := range caller {
|
|
add(t)
|
|
}
|
|
for _, t := range s.plane.List(ctx, scope) {
|
|
if !t.Activated || !t.Dispatchable || seen[t.Name] {
|
|
continue
|
|
}
|
|
seen[t.Name] = true
|
|
out = append(out, openai.Tool{
|
|
Type: openai.ToolTypeFunction,
|
|
Function: &openai.FunctionDefinition{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: schemaOrEmpty(t.Schema),
|
|
},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// schemaOrEmpty returns the tool's JSON-Schema, or the empty-object schema when a
|
|
// source published none (the model requires a parameters object).
|
|
func schemaOrEmpty(schema json.RawMessage) json.RawMessage {
|
|
if len(schema) == 0 {
|
|
return json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
return schema
|
|
}
|
|
|
|
// decodeArgs parses a tool call's JSON argument string into a map. Empty or
|
|
// malformed arguments yield an empty map rather than an error, so a no-arg call
|
|
// still dispatches / returns as an op.
|
|
func decodeArgs(raw string) map[string]any {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return map[string]any{}
|
|
}
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &args); err != nil || args == nil {
|
|
return map[string]any{}
|
|
}
|
|
return args
|
|
}
|
|
|
|
// lastUserMessage returns the last user turn's content — the request this round
|
|
// answers, persisted as the incoming user message.
|
|
func lastUserMessage(msgs []inMessage) string {
|
|
for _, msg := range slices.Backward(msgs) {
|
|
role := strings.TrimSpace(msg.Role)
|
|
if role == "" || role == openai.ChatMessageRoleUser {
|
|
if strings.TrimSpace(msg.Content) != "" {
|
|
return msg.Content
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstUserContent returns the first user turn's content — used to title a new
|
|
// conversation.
|
|
func firstUserContent(msgs []inMessage) string {
|
|
for _, m := range msgs {
|
|
role := strings.TrimSpace(m.Role)
|
|
if role == "" || role == openai.ChatMessageRoleUser {
|
|
if strings.TrimSpace(m.Content) != "" {
|
|
return m.Content
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|