feat: 唤醒式架构批次——capacity/L4/会话编排/管理面板写CRUD/前端集成(77-81)
【capacity 唤醒式核心(77)】internal/runtime/capacity:(Decider L1启发式+L2上下文+人格覆盖→三态静息/唤醒/全能→允许工具集+capacity_awaken事件)。loop 外围允许集过滤(不硬阻断=能力≠边界红线)。config 加 Capacity(Enabled/MinConfidence/ToolSets)。wire buildCapacity 接入 loop。 【L4 记忆驱动意图(80)】recall.go:从记忆召回用户习惯(搞一下xx=办事)→唤醒L1/L2漏掉/升置信;群聊陌生人不读owner习惯(隔离红线);RecordIntent闭环(判定写回记忆越用越准);默认保守静息。 【会话消息编排(78)】gateway/session_orchestrator.go:每会话(模式/pending有界5/tail上下文尾巴20);Submit非阻塞(idle立即/busy入队ack/满拒绝);完成回调按序续接(tail+新消息合并增量注入非全量重放);Interrupt中断;REST 202/409+WS session_busy/pending_ack事件;不重造任务引擎复用loop。 【前端集成(79)】ws.ts 加 capacity_awaken/session_busy/pending_ack 解析;store 加 awakenState/sessionBusy;TopBar/ChatShell 加唤醒态徽标+忙态提示(不阻塞输入框);状态组件 status.tsx;Miuix风格。 【管理面板写CRUD(81)】20个写端点(POST/PUT/DELETE /admin/*,owner+审计落库):personas(真持久化YAML)/users apikey(签发掩码+撤销)/iot控制(真下发)/automations(建/触发/删)/llm(test真调用+临时)/channels(connect/close/身份测试actor白名单)/plugins(卸载)/memories(search真检索)/sessions(export)。无存储如实501不伪造。拆4文件满足≤500红线。前端9模块接真写。 【wire收口】buildCapacity接入loop+OnCapacity事件;server.go newOrchestrator接hub。 实机验证:iot ac.living control 200 真实改变 on:true;observability/metrics 200、health ok;全仓 build/vet/test 47包绿+前端tsc/vite。
This commit is contained in:
1 parent
e0a543cc37
commit
6dbb4a2e18
51 files changed
+5395
-287
No files matched your search
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Service composes the Scheduler (cron), the Engine (event rules) and the
|
||||
@@ -170,6 +171,59 @@ func (s *Service) CancelReminder(ctx context.Context, id string) error {
|
||||
return s.remind.Cancel(ctx, id)
|
||||
}
|
||||
|
||||
// AddRuleByName registers a named cron/event automation whose action records its
|
||||
// fire on the service logger (a real, observable side effect) and returns the
|
||||
// generated rule id. kind must be "cron" or "event"; expression is a cron spec
|
||||
// for cron rules or a bus subject pattern for event rules. A malformed rule or
|
||||
// expression returns an error wrapping ErrInvalidRule / ErrInvalidCron so the
|
||||
// admin handler can answer honestly instead of fabricating a success.
|
||||
func (s *Service) AddRuleByName(ctx context.Context, name, kind, expression string) (string, error) {
|
||||
act, err := NewFuncAction(name, func(_ context.Context, payload any) (any, error) {
|
||||
s.log.Info("admin automation fired", "name", name, "payload", payload)
|
||||
return map[string]any{"fired": name, "payload": payload}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rule := Rule{
|
||||
ID: fmt.Sprintf("auto-%d", time.Now().UnixNano()),
|
||||
Name: name,
|
||||
Trigger: TriggerKind(kind),
|
||||
Expression: expression,
|
||||
Action: act,
|
||||
}
|
||||
if err := s.AddRule(ctx, rule); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rule.ID, nil
|
||||
}
|
||||
|
||||
// RunRule executes one registered rule's action once and returns its output. It
|
||||
// is the manual "run now" path for the admin panel. It returns an error wrapping
|
||||
// ErrNotFound when no rule or reminder carries id.
|
||||
func (s *Service) RunRule(ctx context.Context, id string) (any, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("automations: run rule: %w", ErrInvalidRule)
|
||||
}
|
||||
for _, r := range s.engine.Rules() {
|
||||
if r.ID == id {
|
||||
return r.Action.Execute(ctx, nil)
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
r, ok := s.cronMeta[id]
|
||||
s.mu.RUnlock()
|
||||
if ok {
|
||||
return r.Action.Execute(ctx, nil)
|
||||
}
|
||||
for _, rem := range s.remind.List() {
|
||||
if rem.ID == id {
|
||||
return map[string]any{"reminder": rem.ID, "message": rem.Message, "at": rem.At}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("automations: run rule %q: %w", id, ErrNotFound)
|
||||
}
|
||||
|
||||
// Start starts the scheduler, the event engine and the reminder loop.
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
if err := s.sched.Start(ctx); err != nil {
|
||||
|
||||
@@ -160,7 +160,7 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
||||
// the handler leaks nothing sensitive.
|
||||
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.cfg.LLM
|
||||
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: []AdminLlmProvider{
|
||||
providers := []AdminLlmProvider{
|
||||
{
|
||||
ID: "openai",
|
||||
Name: "OpenAI",
|
||||
@@ -179,7 +179,14 @@ func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
|
||||
Enabled: cfg.Provider == "anthropic",
|
||||
Fallback: cfg.AnthropicFallback,
|
||||
},
|
||||
}})
|
||||
}
|
||||
// Fold in providers added at runtime via the admin "add provider" write.
|
||||
// They are process-lifetime and not bound to the model router, so the panel
|
||||
// reflects them but the response marks nothing else.
|
||||
s.llmMu.RLock()
|
||||
providers = append(providers, s.llmExtra...)
|
||||
s.llmMu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
|
||||
}
|
||||
|
||||
// adminListAutomations lists automations and reminders (admin read).
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||
)
|
||||
|
||||
// IoT device-control and automation-rule admin writes (owner-only, audited).
|
||||
// Device control performs a real control on the owning provider; the others
|
||||
// answer honestly when no scenario/rule store is wired.
|
||||
|
||||
// iotControlRequest is the POST /api/v1/admin/iot/{id}/control body.
|
||||
type iotControlRequest struct {
|
||||
Action string `json:"action"`
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
// adminIoTControl sends a control instruction to a device through the IoT
|
||||
// manager, which applies it on the owning provider and publishes a device_update
|
||||
// event. A real action only when the deployment has a device provider.
|
||||
func (s *Server) adminIoTControl(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req iotControlRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Action) == "" {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "action is required"))
|
||||
return
|
||||
}
|
||||
mgr, ok := s.iot.(*iot.Manager)
|
||||
if !ok {
|
||||
s.auditClaims(r, "iot.control", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "iot_unavailable", "IoT 服务未配置"))
|
||||
return
|
||||
}
|
||||
dev, err := mgr.Control(r.Context(), id, req.Action, req.Params)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "iot.control", "error")
|
||||
writeError(w, mapIotError(err))
|
||||
return
|
||||
}
|
||||
if dev == nil {
|
||||
s.auditClaims(r, "iot.control", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "device %q not found", id))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "iot.control", "ok")
|
||||
writeJSON(w, http.StatusOK, iotDeviceSummary{ID: dev.ID, Name: dev.Name, Type: dev.Type, State: dev.State, Online: dev.Online})
|
||||
}
|
||||
|
||||
// adminIoTScenario answers honestly: the IoT subsystem has no scenario store yet
|
||||
// (it owns device list/control/status only), so scenario write lands in M10.
|
||||
func (s *Server) adminIoTScenario(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "iot.scenario", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 场景存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
|
||||
}
|
||||
|
||||
// adminIoTRule answers honestly: no IoT automation-rule store exists yet.
|
||||
func (s *Server) adminIoTRule(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "iot.rule", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "IoT 自动化规则存储为 M10 提供(当前 IoT 仅有设备列表/控制/状态)", HTTPCode: http.StatusNotImplemented})
|
||||
}
|
||||
|
||||
// mapIotError maps iot sentinel errors to HTTP errors.
|
||||
func mapIotError(err error) *apperr.AppError {
|
||||
switch {
|
||||
case errors.Is(err, iot.ErrDeviceNotFound):
|
||||
return apperr.Wrap(apperr.ErrNotFound, "not_found", "device not found")
|
||||
case errors.Is(err, iot.ErrUnsupported):
|
||||
return apperr.Wrap(apperr.ErrBadRequest, "unsupported_action", "action not supported")
|
||||
case errors.Is(err, iot.ErrOffline):
|
||||
return apperr.Wrap(apperr.ErrConflict, "device_offline", "device offline")
|
||||
case errors.Is(err, iot.ErrInvalidParam):
|
||||
return apperr.Wrap(apperr.ErrBadRequest, "invalid_param", "invalid parameter")
|
||||
default:
|
||||
return apperr.Wrap(apperr.ErrInternal, "iot_control_failed", "device control failed")
|
||||
}
|
||||
}
|
||||
|
||||
// automationCreateRequest is the POST /api/v1/admin/automations body.
|
||||
type automationCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // cron | event
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
// adminCreateAutomation registers a real cron/event rule whose action records its
|
||||
// fire. Invalid cron expressions or malformed rules are answered honestly.
|
||||
func (s *Server) adminCreateAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var req automationCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Expression) == "" {
|
||||
s.auditClaims(r, "automation.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name, kind and expression are required"))
|
||||
return
|
||||
}
|
||||
auto, ok := s.automations.(*automations.Service)
|
||||
if !ok {
|
||||
s.auditClaims(r, "automation.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
|
||||
return
|
||||
}
|
||||
id, err := auto.AddRuleByName(r.Context(), req.Name, req.Kind, req.Expression)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "automation.create", "error")
|
||||
if errors.Is(err, automations.ErrInvalidCron) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_cron", "invalid cron expression"))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, automations.ErrInvalidRule) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "invalid rule"))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to add automation"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "automation.create", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "name": req.Name, "kind": req.Kind})
|
||||
}
|
||||
|
||||
// adminRunAutomation manually triggers a registered rule's action once.
|
||||
func (s *Server) adminRunAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
auto, ok := s.automations.(*automations.Service)
|
||||
if !ok {
|
||||
s.auditClaims(r, "automation.run", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
|
||||
return
|
||||
}
|
||||
out, err := auto.RunRule(r.Context(), id)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "automation.run", "error")
|
||||
if errors.Is(err, automations.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to run automation"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "automation.run", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "output": out})
|
||||
}
|
||||
|
||||
// adminDeleteAutomation removes a registered rule (cron or event) by id.
|
||||
func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
auto, ok := s.automations.(*automations.Service)
|
||||
if !ok {
|
||||
s.auditClaims(r, "automation.delete", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
|
||||
return
|
||||
}
|
||||
if err := auto.RemoveRule(r.Context(), id); err != nil {
|
||||
s.auditClaims(r, "automation.delete", "error")
|
||||
if errors.Is(err, automations.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "automation %q not found", id))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "failed to delete automation"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "automation.delete", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM
|
||||
// test performs a real model call; provider "add" records a process-lifetime
|
||||
// entry. Channel open/close drive the real adapter lifecycle; the identity test
|
||||
// is the structural actor × whitelist rule, never prompt text.
|
||||
|
||||
const (
|
||||
// ownerProfilePath is the owner whitelist loaded for the structural identity
|
||||
// test. It mirrors the path wire.go uses for owner recognition.
|
||||
ownerProfilePath = "configs/users/owner.yaml"
|
||||
// llmTestTimeout bounds a single LLM probe request.
|
||||
llmTestTimeout = 30 * time.Second
|
||||
// llmProbePrompt is the minimal prompt used to verify a model responds.
|
||||
llmProbePrompt = "Reply with the single word: OK"
|
||||
)
|
||||
|
||||
// llmProviderRequest is the POST /api/v1/admin/llm body for adding a provider.
|
||||
type llmProviderRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Model string `json:"model"`
|
||||
Multimodal bool `json:"multimodal"`
|
||||
Fallback string `json:"fallback"`
|
||||
}
|
||||
|
||||
// adminAddLlmProvider records a provider for the process lifetime, reflected in
|
||||
// the GET /admin/llm listing. It is not bound to the model router (config is
|
||||
// immutable after startup), so it is reported as temporary in the response.
|
||||
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||||
var req llmProviderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Model) == "" {
|
||||
s.auditClaims(r, "llm.addProvider", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and model are required"))
|
||||
return
|
||||
}
|
||||
id := req.ID
|
||||
if id == "" {
|
||||
id = newIDHex()
|
||||
}
|
||||
provider := AdminLlmProvider{ID: id, Name: req.Name, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Fallback: req.Fallback}
|
||||
s.llmMu.Lock()
|
||||
s.llmExtra = append(s.llmExtra, provider)
|
||||
s.llmMu.Unlock()
|
||||
s.auditClaims(r, "llm.addProvider", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"provider": provider, "temporary": true})
|
||||
}
|
||||
|
||||
// llmTestRequest is the POST /api/v1/admin/llm/test body.
|
||||
type llmTestRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// adminTestLlm performs a real completion against the supplied provider/model
|
||||
// endpoint and reports the result — a genuine round-trip, not a placeholder.
|
||||
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
|
||||
var req llmTestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
|
||||
s.auditClaims(r, "llm.test", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required"))
|
||||
return
|
||||
}
|
||||
provider := req.Provider
|
||||
if provider == "" {
|
||||
provider = model.ProviderOpenAI
|
||||
}
|
||||
adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model})
|
||||
if err != nil {
|
||||
s.auditClaims(r, "llm.test", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
|
||||
return
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
prompt = llmProbePrompt
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout)
|
||||
defer cancel()
|
||||
resp, err := adapter.Chat(ctx, model.Request{
|
||||
Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)},
|
||||
}, model.Options{Timeout: llmTestTimeout})
|
||||
if err != nil {
|
||||
s.auditClaims(r, "llm.test", "error")
|
||||
s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "llm.test", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
|
||||
}
|
||||
|
||||
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
|
||||
type channelActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
|
||||
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := s.channelName(r, w, "channel.open")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, found := findChannel(s.caps, name)
|
||||
if !found {
|
||||
s.auditClaims(r, "channel.open", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||||
return
|
||||
}
|
||||
if err := ch.Connect(r.Context()); err != nil {
|
||||
s.auditClaims(r, "channel.open", "error")
|
||||
s.log.Error("channel open", "name", name, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.open", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
|
||||
}
|
||||
|
||||
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
|
||||
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := s.channelName(r, w, "channel.close")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ch, found := findChannel(s.caps, name)
|
||||
if !found {
|
||||
s.auditClaims(r, "channel.close", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||||
return
|
||||
}
|
||||
if err := ch.Disconnect(r.Context()); err != nil {
|
||||
s.auditClaims(r, "channel.close", "error")
|
||||
s.log.Error("channel close", "name", name, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "channel.close", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
|
||||
}
|
||||
|
||||
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
|
||||
// account id plus the conversation shape, resolved by the structural actor rule.
|
||||
type channelTestRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
AccountID string `json:"accountId"`
|
||||
IsGroup bool `json:"isGroup"`
|
||||
}
|
||||
|
||||
// adminChannelTest runs the structural identity rule (actor metadata × owner
|
||||
// whitelist), never prompt text. It loads the owner whitelist from disk so a
|
||||
// mismatch degrades to the conservative default (member/stranger).
|
||||
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
|
||||
var req channelTestRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AccountID) == "" {
|
||||
s.auditClaims(r, "channel.identity-test", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required"))
|
||||
return
|
||||
}
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: req.Platform,
|
||||
ChannelID: req.AccountID,
|
||||
SenderID: req.AccountID,
|
||||
Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup},
|
||||
}
|
||||
if req.IsGroup {
|
||||
msg.ChannelType = adapter.ChannelGroup
|
||||
} else {
|
||||
msg.ChannelType = adapter.ChannelDirect
|
||||
}
|
||||
owner := loadOwnerProfile()
|
||||
actor := session.ResolveActor(msg, owner)
|
||||
env := session.EnvFor(actor)
|
||||
s.auditClaims(r, "channel.identity-test", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"role": string(actor.Role),
|
||||
"owner": actor.IsOwner(),
|
||||
"isGroup": actor.IsGroupChat(),
|
||||
"env": string(env),
|
||||
"verdict": describeIdentity(actor, owner),
|
||||
})
|
||||
}
|
||||
|
||||
// channelName decodes the {name} body for open/close routes, writing a 400 on
|
||||
// failure. It reports uniqueness of the decode via ok.
|
||||
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
|
||||
var req channelActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return "", false
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
s.auditClaims(r, op, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return "", false
|
||||
}
|
||||
return req.Name, true
|
||||
}
|
||||
|
||||
// findChannel returns the registered channel adapter named name, if any.
|
||||
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
|
||||
if caps == nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, cap := range caps.List(capreg.KindChannel) {
|
||||
if ch, ok := cap.Value.(sdk.Channel); ok && ch.Name() == name {
|
||||
return ch, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// loadOwnerProfile loads the owner whitelist from disk, returning nil on any
|
||||
// failure so the identity test degrades to the conservative deny.
|
||||
func loadOwnerProfile() *user.OwnerProfile {
|
||||
p, err := user.LoadOwnerProfile(ownerProfilePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// describeIdentity renders the structural verdict for the identity test.
|
||||
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
|
||||
switch {
|
||||
case a.IsGroupChat():
|
||||
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
|
||||
case a.IsOwner():
|
||||
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
|
||||
default:
|
||||
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
|
||||
}
|
||||
}
|
||||
|
||||
// newIDHex returns a 16-byte hex id.
|
||||
func newIDHex() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("id-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader"
|
||||
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
|
||||
)
|
||||
|
||||
// persona-to-identity and API-key admin writes (owner-only, audited). Persona
|
||||
// writes register into the persona registry (real runtime effect) and persist a
|
||||
// YAML file so they survive a restart; API keys are minted/revoked in the
|
||||
// auth.APIKey store.
|
||||
|
||||
const personaDir = "configs/personas"
|
||||
|
||||
// personaUpsertRequest is the POST/PUT /api/v1/admin/personas body.
|
||||
type personaUpsertRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CharacterRef string `json:"characterRef"`
|
||||
Role string `json:"role"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
Personality string `json:"personality"`
|
||||
SpeechStyle string `json:"speechStyle"`
|
||||
Catchphrase string `json:"catchphrase"`
|
||||
Background string `json:"background"`
|
||||
Values []string `json:"values"`
|
||||
Modes []string `json:"modes"`
|
||||
Mode string `json:"mode"`
|
||||
MemoryNS string `json:"memoryNamespace"`
|
||||
}
|
||||
|
||||
// adminCreatePersona registers a new persona in the registry (real runtime
|
||||
// effect) and persists it to the persona YAML library so it survives a restart.
|
||||
func (s *Server) adminCreatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
var req personaUpsertRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ID) == "" || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "id, name and role are required"))
|
||||
return
|
||||
}
|
||||
reg, ok := s.personas.(*personareg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
|
||||
return
|
||||
}
|
||||
if _, exists := reg.Persona(req.ID); exists {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, &apperr.AppError{Code: "duplicate", Message: "人格已存在,请使用编辑", HTTPCode: http.StatusConflict})
|
||||
return
|
||||
}
|
||||
p := personaFromRequest(req)
|
||||
if err := savePersonaYAML(p); err != nil {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
s.log.Error("persona persist", "id", req.ID, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
|
||||
return
|
||||
}
|
||||
if err := reg.RegisterPersona(p); err != nil {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register persona"))
|
||||
return
|
||||
}
|
||||
if err := reg.RegisterCharacter(characterFromRequest(req)); err != nil {
|
||||
s.auditClaims(r, "persona.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_register_failed", "failed to register character"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "persona.create", "ok")
|
||||
writeJSON(w, http.StatusCreated, personaSummaryOf(p, characterFromRequest(req)))
|
||||
}
|
||||
|
||||
// adminUpdatePersona edits an existing persona, persisting the YAML and
|
||||
// replacing the in-memory persona/character.
|
||||
func (s *Server) adminUpdatePersona(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var req personaUpsertRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Role) == "" {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and role are required"))
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
reg, ok := s.personas.(*personareg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_unavailable", "persona registry is not configured"))
|
||||
return
|
||||
}
|
||||
if _, exists := reg.Persona(id); !exists {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "persona %q not found", id))
|
||||
return
|
||||
}
|
||||
p := personaFromRequest(req)
|
||||
if err := savePersonaYAML(p); err != nil {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
s.log.Error("persona persist", "id", id, "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_persist_failed", "failed to persist persona"))
|
||||
return
|
||||
}
|
||||
if err := reg.UpdatePersona(p); err != nil {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update persona"))
|
||||
return
|
||||
}
|
||||
if err := reg.UpdateCharacter(characterFromRequest(req)); err != nil {
|
||||
s.auditClaims(r, "persona.update", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "persona_update_failed", "failed to update character"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "persona.update", "ok")
|
||||
writeJSON(w, http.StatusOK, personaSummaryOf(p, characterFromRequest(req)))
|
||||
}
|
||||
|
||||
// adminCreateUser answers honestly: there is no persistent user table yet (the
|
||||
// deployment is a single admin account + API keys), so user creation is deferred
|
||||
// to M10 rather than fabricated.
|
||||
func (s *Server) adminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "user.create", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)", HTTPCode: http.StatusNotImplemented})
|
||||
}
|
||||
|
||||
// apiKeyIssueRequest is the POST /api/v1/admin/users/apikey body.
|
||||
type apiKeyIssueRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// adminIssueApiKey mints a real API key bound to a role and returns the plaintext
|
||||
// once (the only time it is visible). The id in the response is the masked form
|
||||
// the admin list shows, so it can be used to revoke the key later.
|
||||
func (s *Server) adminIssueApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
var req apiKeyIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
role, err := auth.ParseRole(req.Role)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid role"))
|
||||
return
|
||||
}
|
||||
keys, ok := s.users.(*auth.APIKey)
|
||||
if !ok {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
|
||||
return
|
||||
}
|
||||
key, err := keys.Generate(role)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "apikey.issue", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "apikey_failed", "failed to issue api key"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "apikey.issue", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": auth.MaskKey(key), "key": key, "role": string(role)})
|
||||
}
|
||||
|
||||
// adminRevokeApiKey revokes a key by its masked id (the identifier the admin list
|
||||
// returns). It reports not-found when no key carries that mask.
|
||||
func (s *Server) adminRevokeApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
keys, ok := s.users.(*auth.APIKey)
|
||||
if !ok {
|
||||
s.auditClaims(r, "apikey.revoke", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
|
||||
return
|
||||
}
|
||||
if !keys.RevokeByMask(id) {
|
||||
s.auditClaims(r, "apikey.revoke", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "api key not found"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "apikey.revoke", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "revoked": true})
|
||||
}
|
||||
|
||||
// personaFromRequest builds a loader.Persona from the upsert request, filling a
|
||||
// derived system prompt/characterRef when omitted so a minimal create validates.
|
||||
func personaFromRequest(req personaUpsertRequest) *loader.Persona {
|
||||
systemPrompt := strings.TrimSpace(req.SystemPrompt)
|
||||
if systemPrompt == "" {
|
||||
systemPrompt = fmt.Sprintf("你是 %s,按角色人设 %q 与主人交流。", req.Name, req.Role)
|
||||
}
|
||||
characterRef := strings.TrimSpace(req.CharacterRef)
|
||||
if characterRef == "" {
|
||||
characterRef = req.ID
|
||||
}
|
||||
return &loader.Persona{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
CharacterRef: characterRef,
|
||||
Identity: loader.Identity{Role: req.Role, SystemPrompt: systemPrompt},
|
||||
Personality: req.Personality,
|
||||
SpeechStyle: req.SpeechStyle,
|
||||
Catchphrase: req.Catchphrase,
|
||||
Background: req.Background,
|
||||
Values: req.Values,
|
||||
Emotion: loader.EmotionParams{MemoryNamespace: strings.TrimSpace(req.MemoryNS)},
|
||||
}
|
||||
}
|
||||
|
||||
// characterFromRequest builds the registry.Character bound to the persona.
|
||||
func characterFromRequest(req personaUpsertRequest) *personareg.Character {
|
||||
modes := append([]string(nil), req.Modes...)
|
||||
if len(modes) == 0 && req.Mode != "" {
|
||||
modes = []string{req.Mode}
|
||||
}
|
||||
return &personareg.Character{ID: req.ID, Name: req.Name, PersonaID: req.ID, Modes: modes}
|
||||
}
|
||||
|
||||
// personaSummaryOf maps a persona+character to its wire summary.
|
||||
func personaSummaryOf(p *loader.Persona, c *personareg.Character) PersonaSummary {
|
||||
return PersonaSummary{
|
||||
ID: p.ID,
|
||||
Name: c.Name,
|
||||
Character: c.Name,
|
||||
IsDefault: c.Default,
|
||||
Mode: firstMode(c.Modes),
|
||||
MemoryNS: p.MemoryNamespace(),
|
||||
}
|
||||
}
|
||||
|
||||
// savePersonaYAML persists p to the persona library as <id>.yaml so it survives
|
||||
// a restart (wire.go loads this directory). The id is sanitized so user input
|
||||
// cannot traverse out of the library directory.
|
||||
func savePersonaYAML(p *loader.Persona) error {
|
||||
data, err := yaml.Marshal(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persona: marshal %q: %w", p.ID, err)
|
||||
}
|
||||
if err := os.MkdirAll(personaDir, 0o755); err != nil {
|
||||
return fmt.Errorf("persona: mkdir: %w", err)
|
||||
}
|
||||
path := filepath.Join(personaDir, sanitizeID(p.ID)+".yaml")
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("persona: write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeID keeps only URL/path-safe characters so a persona id cannot escape
|
||||
// the persona directory.
|
||||
func sanitizeID(id string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// Plugin uninstall, memory retrieval and session export admin writes
|
||||
// (owner-only, audited). Uninstall removes a capability from the registry; memory
|
||||
// search runs a real retrieval; session export returns the session metadata.
|
||||
|
||||
// pluginActionRequest is the POST /api/v1/admin/plugins/install|uninstall body.
|
||||
type pluginActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// adminPluginInstall answers honestly: installing a plugin needs a real plugin
|
||||
// source (compiled binary or skill YAML), which the admin API cannot supply. It
|
||||
// returns a clear 501 rather than fabricating a registered plugin.
|
||||
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件安装需插件源码清单(编译二进制/YAML)为 M10 提供;卸载可直接调用 registry", HTTPCode: http.StatusNotImplemented})
|
||||
}
|
||||
|
||||
// adminPluginUninstall removes a registered capability from the registry — a real
|
||||
// effect that stops the plugin being enumerated or dispatched.
|
||||
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
reg, ok := s.caps.(*capreg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||||
return
|
||||
}
|
||||
if err := reg.Remove(req.Name); err != nil {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
if errors.Is(err, capreg.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", req.Name))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "uninstalled": true})
|
||||
}
|
||||
|
||||
// memorySearchRequest is the POST /api/v1/admin/memories/search body.
|
||||
type memorySearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
PersonaID string `json:"personaId"`
|
||||
TopK int `json:"topK"`
|
||||
}
|
||||
|
||||
// memorySearchHit is one admin memory retrieval hit.
|
||||
type memorySearchHit struct {
|
||||
ID string `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Text string `json:"text"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// adminMemorySearch runs a real retrieval over the memory store. The retrieval
|
||||
// ranks by keyword relevance × decay; the semantic/vector path requires the
|
||||
// composition root to inject the vector index, so this answers on the keyword
|
||||
// path and reports semantic=false honestly.
|
||||
func (s *Server) adminMemorySearch(w http.ResponseWriter, r *http.Request) {
|
||||
var req memorySearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Query) == "" {
|
||||
s.auditClaims(r, "memory.search", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "query is required"))
|
||||
return
|
||||
}
|
||||
store, ok := s.memories.(*memory.Store)
|
||||
if !ok {
|
||||
s.auditClaims(r, "memory.search", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_unavailable", "memory store is not configured"))
|
||||
return
|
||||
}
|
||||
personaID := strings.TrimSpace(req.PersonaID)
|
||||
if personaID == "" {
|
||||
personaID = "cyrene"
|
||||
}
|
||||
topK := req.TopK
|
||||
if topK <= 0 {
|
||||
topK = 10
|
||||
}
|
||||
results, err := memory.NewRetriever(store).Retrieve(r.Context(), personaID, req.Query, topK)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "memory.search", "error")
|
||||
s.log.Error("memory search", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "memory_search_failed", "failed to search memory"))
|
||||
return
|
||||
}
|
||||
hits := make([]memorySearchHit, 0, len(results))
|
||||
for _, res := range results {
|
||||
hits = append(hits, memorySearchHit{ID: res.Item.ID, Scope: string(res.Item.Scope), Text: res.Item.Content, Score: res.Score})
|
||||
}
|
||||
s.auditClaims(r, "memory.search", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"query": req.Query, "personaId": personaID, "semantic": false, "hits": hits})
|
||||
}
|
||||
|
||||
// adminExportSession exports a session as JSON. Without a conversation-message
|
||||
// store the export carries the session identity metadata that exists today; a
|
||||
// full transcript export is deferred to M10.
|
||||
func (s *Server) adminExportSession(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
sess := s.findSession(r.Context(), id)
|
||||
if sess == nil {
|
||||
s.auditClaims(r, "session.export", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "session %q not found", id))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "session.export", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"session": summaryOfSession(*sess)})
|
||||
}
|
||||
|
||||
// findSession returns the session whose SessionID equals id, if known.
|
||||
func (s *Server) findSession(ctx context.Context, id string) *session.Session {
|
||||
if s.sessions == nil {
|
||||
return nil
|
||||
}
|
||||
list, err := s.sessions.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].SessionID == id {
|
||||
return &list[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -130,7 +130,34 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// The Web main session is the owner's private conversation (JWT = owner), so
|
||||
// it binds the full-access env: memory read+write, unchanged behavior.
|
||||
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
if s.hub.orchestrator != nil {
|
||||
sr, res := s.hub.orchestrator.SubmitSync(r.Context(), req.SessionID, msgs, loop.OwnerEnv())
|
||||
if !sr.Accepted {
|
||||
if sr.PendingFull {
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "chat_busy", "会话正忙,待处理消息已满"))
|
||||
} else {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "text or image is required"))
|
||||
}
|
||||
return
|
||||
}
|
||||
// A busy session returns a non-blocking ack (design/15 §2.1) instead of
|
||||
// blocking the request on a turn that may run for a while.
|
||||
if sr.Pending {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"queued": true, "queueDepth": sr.QueueDepth})
|
||||
return
|
||||
}
|
||||
if sr.Err != nil {
|
||||
s.log.Error("chat run", "err", sr.Err, "session", req.SessionID)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, chatResponse{Reply: res.Text, Steps: res.Steps, StopReason: res.StopReason})
|
||||
return
|
||||
}
|
||||
// Legacy synchronous path when no orchestrator is wired (a hub built without
|
||||
// one keeps the exact pre-design/15 reply contract).
|
||||
res, err := s.runner.Run(loop.WithSessionEnv(r.Context(), loop.OwnerEnv()), req.SessionID, msgs)
|
||||
if err != nil {
|
||||
s.log.Error("chat run", "err", err, "session", req.SessionID)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "chat_failed", "chat failed"))
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
@@ -234,9 +235,15 @@ type Server struct {
|
||||
voice VoiceService
|
||||
streaming bool
|
||||
frontend fs.FS
|
||||
webhooks map[string]http.Handler
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
// llmMu guards llmExtra, the process-lifetime providers added at runtime via
|
||||
// the admin "add provider" write. They are reflected in GET /admin/llm but are
|
||||
// not bound to the model router (config is immutable after startup), so they
|
||||
// are reported as temporary.
|
||||
llmMu sync.RWMutex
|
||||
llmExtra []AdminLlmProvider
|
||||
webhooks map[string]http.Handler
|
||||
log *slog.Logger
|
||||
hub *hub
|
||||
}
|
||||
|
||||
// New validates cfg and deps and returns a ready Server. Auth is required; the
|
||||
@@ -272,6 +279,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
log: sourcedLog(deps.Log),
|
||||
}
|
||||
s.hub = newHub(s)
|
||||
s.hub.orchestrator = newOrchestrator(s)
|
||||
s.hub.start()
|
||||
return s, nil
|
||||
}
|
||||
@@ -315,6 +323,32 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", adminAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy)))
|
||||
|
||||
// Admin writes: owner-only, audited. Reads above accept owner/admin; writes
|
||||
// are reserved for the owner (RBAC red line). Each handler persists to the
|
||||
// real backing store or answers honestly with a clear "M10 / not-wired" error
|
||||
// rather than fabricating a success.
|
||||
ownerAuth := s.auth.Authorize(auth.RoleOwner)
|
||||
mux.Handle("POST /api/v1/admin/personas", ownerAuth(http.HandlerFunc(s.adminCreatePersona)))
|
||||
mux.Handle("PUT /api/v1/admin/personas/{id}", ownerAuth(http.HandlerFunc(s.adminUpdatePersona)))
|
||||
mux.Handle("POST /api/v1/admin/users", ownerAuth(http.HandlerFunc(s.adminCreateUser)))
|
||||
mux.Handle("POST /api/v1/admin/users/apikey", ownerAuth(http.HandlerFunc(s.adminIssueApiKey)))
|
||||
mux.Handle("DELETE /api/v1/admin/users/apikey/{id}", ownerAuth(http.HandlerFunc(s.adminRevokeApiKey)))
|
||||
mux.Handle("POST /api/v1/admin/iot/{id}/control", ownerAuth(http.HandlerFunc(s.adminIoTControl)))
|
||||
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
|
||||
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
|
||||
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
|
||||
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
|
||||
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
|
||||
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
|
||||
mux.Handle("POST /api/v1/admin/llm/test", ownerAuth(http.HandlerFunc(s.adminTestLlm)))
|
||||
mux.Handle("POST /api/v1/admin/channels/open", ownerAuth(http.HandlerFunc(s.adminChannelOpen)))
|
||||
mux.Handle("POST /api/v1/admin/channels/close", ownerAuth(http.HandlerFunc(s.adminChannelClose)))
|
||||
mux.Handle("POST /api/v1/admin/channels/test", ownerAuth(http.HandlerFunc(s.adminChannelTest)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/install", ownerAuth(http.HandlerFunc(s.adminPluginInstall)))
|
||||
mux.Handle("POST /api/v1/admin/plugins/uninstall", ownerAuth(http.HandlerFunc(s.adminPluginUninstall)))
|
||||
mux.Handle("POST /api/v1/admin/memories/search", ownerAuth(http.HandlerFunc(s.adminMemorySearch)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", adminAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
|
||||
// External webhook callbacks (e.g. the webhook channel) are mounted without
|
||||
// gateway auth; each handler enforces its own signature/authorization.
|
||||
for path, hdl := range s.webhooks {
|
||||
@@ -374,6 +408,24 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Close frees the hub's bus subscription and closes live connections.
|
||||
func (s *Server) Close() { s.hub.close() }
|
||||
|
||||
// newOrchestrator builds the session-message orchestrator (design/15) bound to
|
||||
// this server's hub. The TurnRunner shares the hub's runTurn (so a turn always
|
||||
// emits the same stream contract), and the StateSink fans per-session state
|
||||
// transitions out through the hub broadcast for the frontend. It is wrapped in a
|
||||
// function so the composition root can later override the injection (an explicit
|
||||
// wiring point rather than a hard-coded construct).
|
||||
func newOrchestrator(s *Server) *Orchestrator {
|
||||
return NewOrchestrator(OrchestratorOptions{
|
||||
Run: func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
return s.hub.runTurn(ctx, sessionID, messages, env)
|
||||
},
|
||||
Emit: func(sessionID, typ string, data map[string]any) {
|
||||
s.hub.broadcast(wsEvent{Type: typ, Data: data})
|
||||
},
|
||||
Log: s.log,
|
||||
})
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with source "gateway", substituting a
|
||||
// discard logger for nil so Server can always log safely.
|
||||
func sourcedLog(l *slog.Logger) *slog.Logger {
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// This file implements the session-message orchestrator (DevDocs/design/15).
|
||||
//
|
||||
// It is deliberately NOT a task engine. design/04 §2 (task/subagent) decides
|
||||
// whether a heavy job gets pushed to the background; this orchestrator decides
|
||||
// how NEW user messages queue and resume while a session is busy. The two layers
|
||||
// are orthogonal and compose: a session runs a task (04), a new message arrives
|
||||
// mid-run (15), and the orchestrator holds it in a bounded pending queue and
|
||||
// resumes it with the previous turn's context once the current turn finishes.
|
||||
//
|
||||
// Non-blocking is a hard constraint: Submit/Interrupt never wait on a running
|
||||
// turn. The queue is bounded per session and isolated across sessions, and no
|
||||
// identity decision is made here — the orchestrator only orders intent/messages,
|
||||
// identity stays in the channel actor metadata (project red line).
|
||||
|
||||
// Mode is the per-session busy-state lane of the orchestrator. It is exposed so
|
||||
// a loop/subagent hook can raise it to executing/subagent (an injection point
|
||||
// the composition root may wire later), but the orchestrator itself tracks an
|
||||
// abstract "busy" via mode != ModeIdle.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
// ModeIdle means the session has no turn in flight and no resume pending.
|
||||
ModeIdle Mode = "idle"
|
||||
// ModeThinking means a turn is in flight (the initial start state).
|
||||
ModeThinking Mode = "thinking"
|
||||
// ModeExecuting means a turn is executing tools/substeps.
|
||||
ModeExecuting Mode = "executing"
|
||||
// ModeSubagent means a turn is delegated to a subagent (design/04 §2).
|
||||
ModeSubagent Mode = "subagent"
|
||||
)
|
||||
|
||||
// State event types pushed over the WS channel to the frontend. The data map of
|
||||
// each carries sessionId so a client can correlate per-session state.
|
||||
const (
|
||||
EventSessionBusy = "session_busy"
|
||||
EventPendingAck = "pending_ack"
|
||||
EventPendingDrain = "pending_drain"
|
||||
EventSessionIdle = "session_idle"
|
||||
EventSessionInterrupt = "session_interrupted"
|
||||
EventPendingFull = "pending_full"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultMaxPending bounds the per-session pending queue (design/15 §4).
|
||||
defaultMaxPending = 5
|
||||
// defaultMaxTail bounds the conversation tail kept for continuation, so the
|
||||
// resumed context is incremental, not a full replay (design/15 §2.2, §4).
|
||||
defaultMaxTail = 20
|
||||
// defaultSessionID is used when a caller passes an empty session id.
|
||||
defaultSessionID = "web"
|
||||
)
|
||||
|
||||
// SubmitResult is the outcome of handing one user turn to the orchestrator. It
|
||||
// is always returned without blocking: Accepted is false only when the turn was
|
||||
// rejected (pending queue full, or an empty turn), never because a turn is busy.
|
||||
type SubmitResult struct {
|
||||
// Accepted reports whether the turn was taken. A false value with
|
||||
// PendingFull or Empty carries the reject reason; it never means "try later".
|
||||
Accepted bool
|
||||
// Immediate reports that a turn started now because the session was idle.
|
||||
Immediate bool
|
||||
// Pending reports that the turn was queued behind a busy turn.
|
||||
Pending bool
|
||||
// QueueDepth is the pending depth after this insertion (0 when Immediate).
|
||||
QueueDepth int
|
||||
// PendingFull reports the turn was rejected because the queue is at capacity
|
||||
// (design/15 §4: bounded pending, over-capacity is refused with a hint).
|
||||
PendingFull bool
|
||||
// Empty reports the turn was rejected because it carried no text or image.
|
||||
Empty bool
|
||||
// Err is set on the synchronous path when the run errored; the REST caller
|
||||
// uses it to return a 5xx rather than a fabricated reply.
|
||||
Err error
|
||||
}
|
||||
|
||||
// TurnRunner executes one agent turn and returns its result. The gateway wires
|
||||
// it to the loop plus the streaming broadcast, so the orchestrator stays agnostic
|
||||
// of how a turn is physically driven (it never re-implements a loop engine).
|
||||
type TurnRunner func(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error)
|
||||
|
||||
// StateSink reports a per-session state transition (busy/pending/idle/interrupt)
|
||||
// to the transport. The gateway wires it to the WS hub broadcast.
|
||||
type StateSink func(sessionID, typ string, data map[string]any)
|
||||
|
||||
// OrchestratorOptions configures an Orchestrator. Run and Log are required
|
||||
// (Log may be a discard logger); Emit is optional (state events are dropped when
|
||||
// nil). MaxPending/MaxTail fall back to the defaults when non-positive.
|
||||
type OrchestratorOptions struct {
|
||||
Run TurnRunner
|
||||
Emit StateSink
|
||||
Log *slog.Logger
|
||||
MaxPending int
|
||||
MaxTail int
|
||||
}
|
||||
|
||||
// sessionState is the per-session message state (design/15 §2). Every mutation
|
||||
// happens under the Orchestrator mutex; the actual loop run is done outside the
|
||||
// lock so a slow model call never blocks Submit.
|
||||
type sessionState struct {
|
||||
mode Mode
|
||||
gen uint64 // generation: bumped on interrupt and on a fresh top-level turn
|
||||
cancel context.CancelFunc
|
||||
pending []pendingMsg
|
||||
tail []model.Message // bounded conversation tail for context continuation
|
||||
}
|
||||
|
||||
// pendingMsg is one queued user turn. It carries its own env so a resume runs
|
||||
// under the same memory-isolation boundary the message arrived with.
|
||||
type pendingMsg struct {
|
||||
messages []model.Message
|
||||
env loop.SessionEnv
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// Orchestrator owns the per-session message state machine. It is concurrency-safe
|
||||
// and non-blocking: Submit/Interrupt lock only to mutate state, then hand the
|
||||
// actual run off to a goroutine. It is the single owner of "is this session busy"
|
||||
// for the chat path; the loop/task/subagent engines are reused, not re-created.
|
||||
type Orchestrator struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*sessionState
|
||||
run TurnRunner
|
||||
emit StateSink
|
||||
log *slog.Logger
|
||||
maxPending int
|
||||
maxTail int
|
||||
}
|
||||
|
||||
// NewOrchestrator builds an orchestrator from opts. Run is required: it is the
|
||||
// turn-driver the orchestrator reuses (the loop, never a re-implemented engine).
|
||||
// A nil Run makes every Submit reject rather than run, so a misconfiguration
|
||||
// cannot silently leave a session busy. Tests inject a fake runner here.
|
||||
func NewOrchestrator(opts OrchestratorOptions) *Orchestrator {
|
||||
maxPending := opts.MaxPending
|
||||
if maxPending <= 0 {
|
||||
maxPending = defaultMaxPending
|
||||
}
|
||||
maxTail := opts.MaxTail
|
||||
if maxTail <= 0 {
|
||||
maxTail = defaultMaxTail
|
||||
}
|
||||
if opts.Log == nil {
|
||||
opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
return &Orchestrator{
|
||||
states: make(map[string]*sessionState),
|
||||
run: opts.Run,
|
||||
emit: opts.Emit,
|
||||
log: opts.Log,
|
||||
maxPending: maxPending,
|
||||
maxTail: maxTail,
|
||||
}
|
||||
}
|
||||
|
||||
// Submit hands one user turn to the session. It never blocks: an idle session
|
||||
// starts a turn on a background goroutine and returns Immediate; a busy session
|
||||
// enqueues the turn into the bounded pending queue and returns Pending; a full
|
||||
// queue refuses with PendingFull. Interrupt words are NOT auto-detected here —
|
||||
// the caller (frontend/intent classifier) calls Interrupt when the user asks to
|
||||
// stop, per design/15 §3 (interrupt is an intent, not an identity decision).
|
||||
func (o *Orchestrator) Submit(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) SubmitResult {
|
||||
sr, _ := o.submitInternal(ctx, o.normalize(sessionID), messages, env, false)
|
||||
return sr
|
||||
}
|
||||
|
||||
// SubmitSync is the synchronous variant used by the REST non-streaming path. An
|
||||
// idle session runs the turn to completion and returns the Result; a busy session
|
||||
// returns a Pending result without blocking so the caller can reply with a
|
||||
// non-blocking ack (design/15 §2.1). It never waits on an in-flight turn.
|
||||
func (o *Orchestrator) SubmitSync(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (SubmitResult, *loop.Result) {
|
||||
return o.submitInternal(ctx, o.normalize(sessionID), messages, env, true)
|
||||
}
|
||||
|
||||
// submitInternal is the shared Submit/SubmitSync body. sync selects the
|
||||
// synchronous turn path (REST) vs the background goroutine path (WS).
|
||||
func (o *Orchestrator) submitInternal(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv, sync bool) (SubmitResult, *loop.Result) {
|
||||
if o.run == nil {
|
||||
o.log.Error("orchestrator submit", "err", "no runner configured", "session", sessionID)
|
||||
return SubmitResult{Accepted: false}, nil
|
||||
}
|
||||
if o.messagesEmpty(messages) {
|
||||
return SubmitResult{Empty: true}, nil
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil {
|
||||
st = &sessionState{mode: ModeIdle}
|
||||
o.states[sessionID] = st
|
||||
}
|
||||
if st.mode != ModeIdle {
|
||||
if len(st.pending) >= o.maxPending {
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventPendingFull, map[string]any{"sessionId": sessionID, "mode": mode, "max": o.maxPending})
|
||||
return SubmitResult{PendingFull: true}, nil
|
||||
}
|
||||
st.pending = append(st.pending, pendingMsg{messages: messages, env: env, at: time.Now()})
|
||||
depth := len(st.pending)
|
||||
mode := st.mode
|
||||
o.mu.Unlock()
|
||||
o.emitState(sessionID, EventPendingAck, map[string]any{"sessionId": sessionID, "mode": mode, "queueDepth": depth})
|
||||
return SubmitResult{Accepted: true, Pending: true, QueueDepth: depth}, nil
|
||||
}
|
||||
|
||||
st.gen++
|
||||
gen := st.gen
|
||||
st.mode = ModeThinking
|
||||
st.tail = o.boundedTail(messages)
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
st.cancel = cancel
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": ModeThinking})
|
||||
|
||||
if sync {
|
||||
res, err := o.run(runCtx, sessionID, messages, env)
|
||||
if err != nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err)
|
||||
return SubmitResult{Accepted: true, Immediate: true, Err: err}, res
|
||||
}
|
||||
|
||||
go o.goTurn(sessionID, gen, messages, env, runCtx, cancel)
|
||||
return SubmitResult{Accepted: true, Immediate: true}, nil
|
||||
}
|
||||
|
||||
// goTurn runs a single turn on a background goroutine and, when it finishes,
|
||||
// hands the outcome to completeTurn so the orchestrator can resume the queue.
|
||||
func (o *Orchestrator) goTurn(sessionID string, gen uint64, messages []model.Message, env loop.SessionEnv, ctx context.Context, cancel context.CancelFunc) {
|
||||
defer cancel()
|
||||
res, err := o.run(ctx, sessionID, messages, env)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
o.log.Error("orchestrator turn", "err", err, "session", sessionID)
|
||||
}
|
||||
_ = o.completeTurn(sessionID, gen, res, err)
|
||||
}
|
||||
|
||||
// completeTurn runs after a turn settles (either it finished, errored, or was
|
||||
// canceled). It folds the assistant reply into the session's continuation tail,
|
||||
// then pops the next pending turn and resumes it carrying that tail — so the next
|
||||
// message "remembers" the prior context instead of starting from a blank slate
|
||||
// (design/15 §2.2: incremental, not full replay). A stale generation (an
|
||||
// interrupt or a newer top-level turn superseded this one) is a no-op.
|
||||
func (o *Orchestrator) completeTurn(sessionID string, gen uint64, res *loop.Result, runErr error) error {
|
||||
o.mu.Lock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil || st.gen != gen {
|
||||
o.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if res != nil && res.Text != "" {
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, model.TextMessage(model.RoleAssistant, res.Text)))
|
||||
}
|
||||
if len(st.pending) > 0 {
|
||||
next := st.pending[0]
|
||||
st.pending = st.pending[1:]
|
||||
st.tail = o.boundedTail(concatMessages(st.tail, next.messages...))
|
||||
st.mode = ModeThinking
|
||||
st.gen++
|
||||
gen = st.gen
|
||||
runCtx, cancel := context.WithCancel(context.Background())
|
||||
st.cancel = cancel
|
||||
depth := len(st.pending)
|
||||
resume := append([]model.Message(nil), st.tail...)
|
||||
env := next.env
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventPendingDrain, map[string]any{"sessionId": sessionID, "mode": ModeThinking, "queueDepth": depth})
|
||||
go o.goTurn(sessionID, gen, resume, env, runCtx, cancel)
|
||||
return nil
|
||||
}
|
||||
|
||||
st.mode = ModeIdle
|
||||
st.cancel = nil
|
||||
o.mu.Unlock()
|
||||
|
||||
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": ModeIdle})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Interrupt cancels the in-flight turn, clears the pending queue and returns the
|
||||
// session to idle (design/15 §3, the "停下/算了/换个话题" path). It is the only
|
||||
// way a busy session resets without draining the queue; the running goroutine's
|
||||
// completion becomes a stale generation and does not resume anything. It returns
|
||||
// the number of pending turns that were dropped.
|
||||
func (o *Orchestrator) Interrupt(sessionID string) int {
|
||||
return o.interrupt(o.normalize(sessionID))
|
||||
}
|
||||
|
||||
// InterruptAll cancels every in-flight session and clears all pending queues. It
|
||||
// is used on gateway shutdown so no background turn outlives the hub. It returns
|
||||
// the total number of dropped pending turns.
|
||||
func (o *Orchestrator) InterruptAll() int {
|
||||
o.mu.Lock()
|
||||
total := 0
|
||||
for id, st := range o.states {
|
||||
total += o.interruptLocked(id, st)
|
||||
}
|
||||
o.mu.Unlock()
|
||||
return total
|
||||
}
|
||||
|
||||
func (o *Orchestrator) interrupt(sessionID string) int {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.interruptLocked(sessionID, o.states[sessionID])
|
||||
}
|
||||
|
||||
func (o *Orchestrator) interruptLocked(sessionID string, st *sessionState) int {
|
||||
if st == nil || st.mode == ModeIdle {
|
||||
return 0
|
||||
}
|
||||
if st.cancel != nil {
|
||||
st.cancel()
|
||||
}
|
||||
dropped := len(st.pending)
|
||||
st.pending = nil
|
||||
st.gen++
|
||||
st.mode = ModeIdle
|
||||
st.cancel = nil
|
||||
o.emitState(sessionID, EventSessionInterrupt, map[string]any{"sessionId": sessionID, "mode": ModeIdle, "dropped": dropped})
|
||||
return dropped
|
||||
}
|
||||
|
||||
// SetMode raises the reported busy-state lane of a session (thinking →
|
||||
// executing → subagent). It is an injection point a loop/subagent hook can call;
|
||||
// any non-idle mode keeps the session busy, and idle un-busies it. A nil session
|
||||
// is created lazily so a hook may set a mode before the first message.
|
||||
func (o *Orchestrator) SetMode(sessionID string, mode Mode) {
|
||||
sessionID = o.normalize(sessionID)
|
||||
o.mu.Lock()
|
||||
st := o.states[sessionID]
|
||||
if st == nil {
|
||||
st = &sessionState{mode: mode}
|
||||
o.states[sessionID] = st
|
||||
} else {
|
||||
st.mode = mode
|
||||
}
|
||||
o.mu.Unlock()
|
||||
if mode == ModeIdle {
|
||||
o.emitState(sessionID, EventSessionIdle, map[string]any{"sessionId": sessionID, "mode": mode})
|
||||
return
|
||||
}
|
||||
o.emitState(sessionID, EventSessionBusy, map[string]any{"sessionId": sessionID, "mode": mode})
|
||||
}
|
||||
|
||||
// Busy reports whether the session currently has a turn in flight.
|
||||
func (o *Orchestrator) Busy(sessionID string) bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st := o.states[o.normalize(sessionID)]
|
||||
return st != nil && st.mode != ModeIdle
|
||||
}
|
||||
|
||||
// QueueDepth reports the number of pending turns for a session.
|
||||
func (o *Orchestrator) QueueDepth(sessionID string) int {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st := o.states[o.normalize(sessionID)]
|
||||
if st == nil {
|
||||
return 0
|
||||
}
|
||||
return len(st.pending)
|
||||
}
|
||||
|
||||
// ModeOf reports the current busy-state lane of a session (idle when unknown).
|
||||
func (o *Orchestrator) ModeOf(sessionID string) Mode {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
st := o.states[o.normalize(sessionID)]
|
||||
if st == nil {
|
||||
return ModeIdle
|
||||
}
|
||||
return st.mode
|
||||
}
|
||||
|
||||
// normalize maps an empty session id to the default so the state map is stable
|
||||
// for callers that omit it (the Web main session).
|
||||
func (o *Orchestrator) normalize(sessionID string) string {
|
||||
if sessionID == "" {
|
||||
return defaultSessionID
|
||||
}
|
||||
return sessionID
|
||||
}
|
||||
|
||||
// emitState forwards a state transition to the transport when one is wired.
|
||||
func (o *Orchestrator) emitState(sessionID, typ string, data map[string]any) {
|
||||
if o.emit != nil {
|
||||
o.emit(sessionID, typ, data)
|
||||
}
|
||||
}
|
||||
|
||||
// boundedTail returns a fresh copy of the last maxTail messages, so continuation
|
||||
// stays incremental and bounded (design/15 §2.2, §4) and never aliases the input
|
||||
// slice or another state's tail.
|
||||
func (o *Orchestrator) boundedTail(msgs []model.Message) []model.Message {
|
||||
if len(msgs) <= o.maxTail {
|
||||
return append([]model.Message(nil), msgs...)
|
||||
}
|
||||
out := make([]model.Message, o.maxTail)
|
||||
copy(out, msgs[len(msgs)-o.maxTail:])
|
||||
return out
|
||||
}
|
||||
|
||||
// messagesEmpty reports whether a turn carries no usable text or image, so the
|
||||
// orchestrator does not mark a session busy on a vacuous message.
|
||||
func (o *Orchestrator) messagesEmpty(msgs []model.Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText && c.Text != "" {
|
||||
return false
|
||||
}
|
||||
if c.Type == model.ContentTypeImage && c.ImageURL != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// concatMessages returns a new slice combining a and b (never reusing a or b
|
||||
// backing arrays) so the continuation tail is safe to hand to a concurrent run.
|
||||
func concatMessages(a []model.Message, b ...model.Message) []model.Message {
|
||||
out := make([]model.Message, 0, len(a)+len(b))
|
||||
out = append(out, a...)
|
||||
out = append(out, b...)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// callRecord is one invocation captured by the fake runner.
|
||||
type callRecord struct {
|
||||
sessionID string
|
||||
messages []model.Message
|
||||
env loop.SessionEnv
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// gatedRunner is a controllable TurnRunner. Each Run blocks until it consumes one
|
||||
// ticket from tickets, or until ctx is canceled (the interrupt path). When it
|
||||
// proceeds it records the call and returns a deterministic reply. A nil err
|
||||
// produces a reply holding the *incoming* messages as text, so a test can assert
|
||||
// exactly what context the resumed turn carried.
|
||||
type gatedRunner struct {
|
||||
mu sync.Mutex
|
||||
calls []callRecord
|
||||
tickets chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func newGatedRunner(cap int) *gatedRunner {
|
||||
return &gatedRunner{tickets: make(chan struct{}, cap)}
|
||||
}
|
||||
|
||||
func (g *gatedRunner) Run(ctx context.Context, sessionID string, messages []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
select {
|
||||
case <-g.tickets:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
g.mu.Lock()
|
||||
n := len(g.calls) + 1
|
||||
g.calls = append(g.calls, callRecord{sessionID: sessionID, messages: append([]model.Message(nil), messages...), env: env, ctx: ctx})
|
||||
g.mu.Unlock()
|
||||
if g.err != nil {
|
||||
return nil, g.err
|
||||
}
|
||||
reply := n + len(messages)
|
||||
return &loop.Result{Text: fmt.Sprintf("reply-%d", reply), Steps: 1, StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
// waitCalls blocks until at least n calls are recorded and returns a copy of the
|
||||
// first n, so a test can advance the gated chain deterministically.
|
||||
func (g *gatedRunner) waitCalls(n int) []callRecord {
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
g.mu.Lock()
|
||||
if len(g.calls) >= n {
|
||||
out := append([]callRecord(nil), g.calls[:n]...)
|
||||
g.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
g.mu.Unlock()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
panic("timed out waiting for runner calls")
|
||||
}
|
||||
|
||||
// count returns the total recorded calls.
|
||||
func (g *gatedRunner) count() int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return len(g.calls)
|
||||
}
|
||||
|
||||
// eventRec is one state transition captured by the recording sink.
|
||||
type eventRec struct {
|
||||
sessionID, typ string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
// recordingSink captures every emitted state transition.
|
||||
type recordingSink struct {
|
||||
mu sync.Mutex
|
||||
events []eventRec
|
||||
}
|
||||
|
||||
func (s *recordingSink) Emit(sessionID, typ string, data map[string]any) {
|
||||
s.mu.Lock()
|
||||
s.events = append(s.events, eventRec{sessionID: sessionID, typ: typ, data: data})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *recordingSink) types() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]string, 0, len(s.events))
|
||||
for _, e := range s.events {
|
||||
out = append(out, e.typ)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *recordingSink) last() (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.events) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return s.events[len(s.events)-1].typ, true
|
||||
}
|
||||
|
||||
// userMsg builds a text-only user message.
|
||||
func userMsg(text string) model.Message {
|
||||
return model.TextMessage(model.RoleUser, text)
|
||||
}
|
||||
|
||||
// messageText returns the concatenated text of a message's text parts.
|
||||
func messageText(m model.Message) string {
|
||||
var out string
|
||||
for _, c := range m.Content {
|
||||
if c.Type == model.ContentTypeText {
|
||||
out += c.Text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seedOrchestrator builds an orchestrator with a gated runner and a recording
|
||||
// sink, both returned so the test can drive the chain and assert state events.
|
||||
func seedOrchestrator(run *gatedRunner, maxPending int) (*Orchestrator, *recordingSink) {
|
||||
sink := &recordingSink{}
|
||||
o := NewOrchestrator(OrchestratorOptions{
|
||||
Run: run.Run,
|
||||
Emit: sink.Emit,
|
||||
Log: slog.Default(),
|
||||
MaxPending: maxPending,
|
||||
MaxTail: 20,
|
||||
})
|
||||
return o, sink
|
||||
}
|
||||
|
||||
// readOnlyEnv returns the group-chat / stranger read-only memory lane.
|
||||
func readOnlyEnv() loop.SessionEnv {
|
||||
return loop.SessionEnv{MemWritable: false}
|
||||
}
|
||||
|
||||
func TestSubmitIdleStartsImmediate(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(1)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
|
||||
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || !sr.Immediate {
|
||||
t.Fatalf("submit = %+v, want Accepted+Immediate", sr)
|
||||
}
|
||||
run.tickets <- struct{}{}
|
||||
got := run.waitCalls(1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(got))
|
||||
}
|
||||
if o.Busy("s1") {
|
||||
t.Fatal("session should be idle after a completed turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitBusyPendingNotDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(4)
|
||||
o, sink := seedOrchestrator(run, 5)
|
||||
|
||||
// Turn A starts and blocks (no ticket yet), so the session is busy.
|
||||
a := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
|
||||
if !a.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", a)
|
||||
}
|
||||
// B and C arrive while A is in flight: they must be queued, not dropped.
|
||||
b := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv())
|
||||
c := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv())
|
||||
if !b.Accepted || !b.Pending || b.QueueDepth != 1 {
|
||||
t.Fatalf("B = %+v, want Pending depth=1", b)
|
||||
}
|
||||
if !c.Accepted || !c.Pending || c.QueueDepth != 2 {
|
||||
t.Fatalf("C = %+v, want Pending depth=2", c)
|
||||
}
|
||||
if o.QueueDepth("s1") != 2 {
|
||||
t.Fatalf("queue depth = %d, want 2", o.QueueDepth("s1"))
|
||||
}
|
||||
|
||||
// Release the chain: A, then B, then C. The emitted ack should include the
|
||||
// pending depth, and the sink should have seen pending_ack events.
|
||||
types := sink.types()
|
||||
foundAck := false
|
||||
for _, ty := range types {
|
||||
if ty == EventPendingAck {
|
||||
foundAck = true
|
||||
}
|
||||
}
|
||||
if !foundAck {
|
||||
t.Fatalf("no pending_ack in events: %v", types)
|
||||
}
|
||||
|
||||
run.tickets <- struct{}{}
|
||||
run.tickets <- struct{}{}
|
||||
run.tickets <- struct{}{}
|
||||
if n := len(run.waitCalls(3)); n != 3 {
|
||||
t.Fatalf("total calls = %d, want 3 (A+B+C)", n)
|
||||
}
|
||||
if o.QueueDepth("s1") != 0 || o.Busy("s1") {
|
||||
t.Fatalf("session should be idle and empty after drain: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitBusyResumeCarriesPreviousContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(3)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", sr)
|
||||
}
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, readOnlyEnv()); !sr.Pending {
|
||||
t.Fatalf("B = %+v, want Pending", sr)
|
||||
}
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Pending {
|
||||
t.Fatalf("C = %+v, want Pending", sr)
|
||||
}
|
||||
|
||||
run.tickets <- struct{}{} // A
|
||||
run.tickets <- struct{}{} // B
|
||||
run.tickets <- struct{}{} // C
|
||||
got := run.waitCalls(3)
|
||||
|
||||
// A starts from a blank turn.
|
||||
if len(got[0].messages) != 1 || messageText(got[0].messages[0]) != "A" {
|
||||
t.Fatalf("A messages = %v, want [A]", messageTextList(got[0].messages))
|
||||
}
|
||||
// B must resume carrying A's assistant reply folded into the tail.
|
||||
if !matchesText(got[1].messages, "A", "B") {
|
||||
t.Fatalf("B messages = %v, want B to resume carrying the prior tail", messageTextList(got[1].messages))
|
||||
}
|
||||
// C must resume carrying A's and B's assistant replies.
|
||||
if !matchesText(got[2].messages, "A", "C") {
|
||||
t.Fatalf("C messages = %v, want C to resume carrying the prior tail", messageTextList(got[2].messages))
|
||||
}
|
||||
// B's env must be the one the message arrived with (read-only lane).
|
||||
if got[1].env.MemWritable {
|
||||
t.Fatal("B resumed with MemWritable=true, want the caller's read-only env")
|
||||
}
|
||||
}
|
||||
|
||||
// matchesText reports whether a resumed turn begins with the captured first user
|
||||
// turn (so the prior context was carried over), ends with the expected new user
|
||||
// turn, and contains at least one assistant reply in between.
|
||||
func matchesText(msgs []model.Message, first, last string) bool {
|
||||
if len(msgs) < 3 {
|
||||
return false
|
||||
}
|
||||
if messageText(msgs[0]) != first {
|
||||
return false
|
||||
}
|
||||
if messageText(msgs[len(msgs)-1]) != last {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(msgs)-1; i++ {
|
||||
if msgs[i].Role == model.RoleAssistant {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func messageTextList(msgs []model.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, messageText(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSubmitPendingBoundedRejectsOverflow(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
o, _ := seedOrchestrator(run, 3) // tight queue
|
||||
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", sr)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
sr := o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("p%d", i))}, loop.OwnerEnv())
|
||||
if !sr.Pending || sr.QueueDepth != i+1 {
|
||||
t.Fatalf("pending %d = %+v, want Pending depth=%d", i, sr, i+1)
|
||||
}
|
||||
}
|
||||
// Queue is at capacity (3), so the next message is refused, not dropped.
|
||||
over := o.Submit(context.Background(), "s1", []model.Message{userMsg("overflow")}, loop.OwnerEnv())
|
||||
if over.Accepted || !over.PendingFull {
|
||||
t.Fatalf("overflow = %+v, want PendingFull reject", over)
|
||||
}
|
||||
if o.QueueDepth("s1") != 3 {
|
||||
t.Fatalf("queue depth = %d, want 3 (unchanged)", o.QueueDepth("s1"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterruptCancelsTurnAndClearsPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(2)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("A = %+v, want Immediate", sr)
|
||||
}
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv()); !sr.Pending {
|
||||
t.Fatalf("B = %+v, want Pending", sr)
|
||||
}
|
||||
|
||||
// Release no ticket: A is blocked in flight. Interrupt cancels it and clears B.
|
||||
dropped := o.Interrupt("s1")
|
||||
if dropped != 1 {
|
||||
t.Fatalf("dropped = %d, want 1", dropped)
|
||||
}
|
||||
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
|
||||
t.Fatalf("session not idle after interrupt: busy=%v depth=%d", o.Busy("s1"), o.QueueDepth("s1"))
|
||||
}
|
||||
|
||||
// A new message after the interrupt must start fresh (Immediate), and the
|
||||
// canceled chain must not have resumed B.
|
||||
if sr := o.Submit(context.Background(), "s1", []model.Message{userMsg("C")}, loop.OwnerEnv()); !sr.Immediate {
|
||||
t.Fatalf("C = %+v, want Immediate after interrupt", sr)
|
||||
}
|
||||
run.tickets <- struct{}{}
|
||||
got := run.waitCalls(1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("calls = %d, want 1 (only C, not the canceled A or B)", len(got))
|
||||
}
|
||||
if messageText(got[0].messages[0]) != "C" {
|
||||
t.Fatalf("only surviving call = %v, want the C turn", messageTextList(got[0].messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitConcurrentSameSessionIsSafeAndBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(8)
|
||||
o, _ := seedOrchestrator(run, 3)
|
||||
|
||||
const senders = 8
|
||||
var wg sync.WaitGroup
|
||||
accepted := make(chan SubmitResult, senders)
|
||||
for i := 0; i < senders; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
accepted <- o.Submit(context.Background(), "s1", []model.Message{userMsg(fmt.Sprintf("m%d", i))}, loop.OwnerEnv())
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(accepted)
|
||||
|
||||
immediate, pending, full := 0, 0, 0
|
||||
for sr := range accepted {
|
||||
switch {
|
||||
case sr.Immediate:
|
||||
immediate++
|
||||
case sr.Pending:
|
||||
pending++
|
||||
case sr.PendingFull:
|
||||
full++
|
||||
default:
|
||||
t.Fatalf("unexpected submit result: %+v", sr)
|
||||
}
|
||||
}
|
||||
// With maxPending=3: exactly 1 turn runs immediately and up to 3 queue behind
|
||||
// it; the remaining senders are refused at capacity rather than dropped.
|
||||
if immediate != 1 {
|
||||
t.Fatalf("immediate = %d, want 1", immediate)
|
||||
}
|
||||
if pending != 3 {
|
||||
t.Fatalf("pending = %d, want 3", pending)
|
||||
}
|
||||
if full != senders-4 {
|
||||
t.Fatalf("pendingFull = %d, want %d", full, senders-4)
|
||||
}
|
||||
if o.QueueDepth("s1") != 3 {
|
||||
t.Fatalf("queue depth = %d, want 3", o.QueueDepth("s1"))
|
||||
}
|
||||
|
||||
// Drain the chain: 1 running + 3 queued = 4 turns. No panic / no lost message.
|
||||
for i := 0; i < 4; i++ {
|
||||
run.tickets <- struct{}{}
|
||||
}
|
||||
if n := len(run.waitCalls(4)); n != 4 {
|
||||
t.Fatalf("total calls = %d, want 4", n)
|
||||
}
|
||||
if o.Busy("s1") || o.QueueDepth("s1") != 0 {
|
||||
t.Fatalf("session should be idle and empty after drain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitEmptyRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(1)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
sr := o.Submit(context.Background(), "s1", nil, loop.OwnerEnv())
|
||||
if sr.Accepted || !sr.Empty {
|
||||
t.Fatalf("empty submit = %+v, want Empty reject", sr)
|
||||
}
|
||||
if run.count() != 0 {
|
||||
t.Fatalf("runner invoked for an empty turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitSyncIdleReturnsResultBusyReturnsPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(2)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
|
||||
// Idle → synchronous run returns the reply immediately. Push a ticket so the
|
||||
// gated runner's blocking select resolves during the synchronous wait.
|
||||
run.tickets <- struct{}{}
|
||||
sr, res := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("hi")}, loop.OwnerEnv())
|
||||
if !sr.Accepted || !sr.Immediate {
|
||||
t.Fatalf("sync idle = %+v, want Accepted+Immediate", sr)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("sync idle should return a result")
|
||||
}
|
||||
|
||||
// Hold a busy turn in flight via an async Submit (blocked, no ticket), then a
|
||||
// sync submit must return a Pending ack without blocking and without a result.
|
||||
if sr2 := o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv()); !sr2.Immediate {
|
||||
t.Fatalf("async A = %+v, want Immediate", sr2)
|
||||
}
|
||||
sr3, res3 := o.SubmitSync(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
|
||||
if !sr3.Accepted || !sr3.Pending {
|
||||
t.Fatalf("sync busy = %+v, want Pending", sr3)
|
||||
}
|
||||
if res3 != nil {
|
||||
t.Fatal("sync busy should not return a result (must not block)")
|
||||
}
|
||||
run.tickets <- struct{}{} // let A finish so the orchestrator is idle again
|
||||
}
|
||||
|
||||
func TestInterruptAllClearsEverySession(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(2)
|
||||
o, _ := seedOrchestrator(run, 5)
|
||||
|
||||
o.Submit(context.Background(), "s1", []model.Message{userMsg("A")}, loop.OwnerEnv())
|
||||
o.Submit(context.Background(), "s1", []model.Message{userMsg("B")}, loop.OwnerEnv())
|
||||
o.Submit(context.Background(), "s2", []model.Message{userMsg("X")}, loop.OwnerEnv())
|
||||
|
||||
if got := o.InterruptAll(); got != 1 {
|
||||
t.Fatalf("InterruptAll dropped = %d, want 1 (B)", got)
|
||||
}
|
||||
if o.Busy("s1") || o.Busy("s2") {
|
||||
t.Fatal("all sessions should be idle after InterruptAll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModeBusyAndIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
run := newGatedRunner(1)
|
||||
o, sink := seedOrchestrator(run, 5)
|
||||
|
||||
o.SetMode("s1", ModeExecuting)
|
||||
if !o.Busy("s1") || o.ModeOf("s1") != ModeExecuting {
|
||||
t.Fatalf("after executing: busy=%v mode=%q", o.Busy("s1"), o.ModeOf("s1"))
|
||||
}
|
||||
o.SetMode("s1", ModeIdle)
|
||||
if o.Busy("s1") {
|
||||
t.Fatal("session must be idle after SetMode(idle)")
|
||||
}
|
||||
if ty, ok := sink.last(); !ok || ty != EventSessionIdle {
|
||||
t.Fatalf("last event = %q, want session_idle", ty)
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,15 @@ type hub struct {
|
||||
runner Chatter
|
||||
auditor SubagentAuditor
|
||||
taskOutputs TaskOutputStore
|
||||
voice VoiceService
|
||||
streaming bool
|
||||
log *slog.Logger
|
||||
upgrader websocket.Upgrader
|
||||
origins map[string]bool
|
||||
allowAll bool
|
||||
cancel context.CancelFunc
|
||||
streamSeq atomic.Uint64
|
||||
voice VoiceService
|
||||
streaming bool
|
||||
orchestrator *Orchestrator
|
||||
log *slog.Logger
|
||||
upgrader websocket.Upgrader
|
||||
origins map[string]bool
|
||||
allowAll bool
|
||||
cancel context.CancelFunc
|
||||
streamSeq atomic.Uint64
|
||||
}
|
||||
|
||||
// newHub builds a hub bound to the server's dependencies.
|
||||
@@ -124,11 +125,15 @@ func (h *hub) start() {
|
||||
}
|
||||
}
|
||||
|
||||
// close cancels the bus subscription and shuts every live connection down.
|
||||
// close cancels the bus subscription, interrupts every orchestrator session (so
|
||||
// no background turn outlives the hub), and shuts every live connection down.
|
||||
func (h *hub) close() {
|
||||
if h.cancel != nil {
|
||||
h.cancel()
|
||||
}
|
||||
if h.orchestrator != nil {
|
||||
h.orchestrator.InterruptAll()
|
||||
}
|
||||
h.mu.Lock()
|
||||
conns := make([]*wsConn, 0, len(h.conns))
|
||||
for c := range h.conns {
|
||||
@@ -205,20 +210,19 @@ func (h *hub) relay(ctx context.Context, e bus.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamChat runs one agent turn for a chat trigger and broadcasts the turn
|
||||
// terminus (stream_end) with a fresh turnId; on the non-streaming fallback path
|
||||
// it also emits a single stream_chunk. When the loop is streaming, the per-event
|
||||
// deltas arrive via the OnStream→bus→relay path as structured message.*/
|
||||
// thinking.*/tool_call.* events. It runs on its own goroutine so a slow model
|
||||
// streamChat is the ws.chat trigger entry point. It decodes the request, and
|
||||
// routes the turn through the session orchestrator when one is wired (the
|
||||
// non-blocking busy/pending/resume path of design/15); otherwise it falls back to
|
||||
// the legacy direct run so a hub built without an orchestrator keeps the exact
|
||||
// stream_chunk/stream_end contract. It runs on its own goroutine so a slow model
|
||||
// call does not stall the bus subscription worker.
|
||||
func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
var req chatStreamRequest
|
||||
if b, err := json.Marshal(payload); err == nil {
|
||||
_ = json.Unmarshal(b, &req)
|
||||
}
|
||||
turnID := newTurnID()
|
||||
if h.runner == nil || (strings.TrimSpace(req.Text) == "" && strings.TrimSpace(req.Image) == "") {
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: newTurnID(), Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
@@ -226,21 +230,55 @@ func (h *hub) streamChat(ctx context.Context, payload any) {
|
||||
}
|
||||
// The Web main session is the owner's private conversation (JWT = owner), so
|
||||
// it binds the full-access env: memory read+write, unchanged behavior.
|
||||
res, err := h.runner.Run(loop.WithSessionEnv(ctx, loop.OwnerEnv()), req.SessionID, userChatMessages(req.Text, req.Image))
|
||||
if err != nil {
|
||||
h.log.Error("ws chat run", "err", err, "session", req.SessionID)
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
|
||||
msgs := userChatMessages(req.Text, req.Image)
|
||||
if h.orchestrator != nil {
|
||||
_ = h.orchestrator.Submit(context.Background(), req.SessionID, msgs, loop.OwnerEnv())
|
||||
return
|
||||
}
|
||||
// When the loop is streaming (OnStream wired), the structured deltas already
|
||||
// fan out as message.*/thinking.*/tool_call.* events through the
|
||||
// OnStream→bus→relay path; a second whole-text chunk would duplicate the
|
||||
// tail. The non-streaming path (or a stub runner) still emits one chunk so
|
||||
// the stream contract holds in tests.
|
||||
_, _ = h.runTurn(ctx, req.SessionID, msgs, loop.OwnerEnv())
|
||||
}
|
||||
|
||||
// runTurn executes one agent turn and broadcasts its output stream. It is the
|
||||
// unit both the orchestrator (via its injected TurnRunner) and the legacy
|
||||
// fallback path share, so a turn always emits the same stream_chunk/stream_end
|
||||
// events regardless of who drives it. When the loop is streaming (OnStream wired)
|
||||
// the structured deltas already fan out as message.*/thinking.*/tool_call.*
|
||||
// events through the OnStream→bus→relay path, so only the non-streaming path
|
||||
// emits a single whole-text chunk to hold the stream contract.
|
||||
func (h *hub) runTurn(ctx context.Context, sessionID string, msgs []model.Message, env loop.SessionEnv) (*loop.Result, error) {
|
||||
turnID := newTurnID()
|
||||
if h.runner == nil || len(msgs) == 0 {
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "idle"}})
|
||||
return &loop.Result{StopReason: "idle"}, nil
|
||||
}
|
||||
res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, msgs)
|
||||
if err != nil {
|
||||
h.log.Error("chat run", "err", err, "session", sessionID)
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": "error", "error": err.Error()}})
|
||||
return nil, err
|
||||
}
|
||||
if !h.streaming {
|
||||
h.broadcast(wsEvent{Type: "stream_chunk", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"delta": res.Text}})
|
||||
}
|
||||
h.broadcast(wsEvent{Type: "stream_end", TurnID: turnID, Seq: int(h.streamSeq.Add(1)), Data: map[string]any{"stopReason": res.StopReason, "steps": res.Steps}})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// handleInterrupt routes a client "interrupt" frame (design/15 §3) to the
|
||||
// orchestrator, canceling the in-flight turn and clearing the pending queue for
|
||||
// the requested session. It is a no-op when no orchestrator is wired.
|
||||
func (h *hub) handleInterrupt(payload json.RawMessage) {
|
||||
if h.orchestrator == nil {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
_ = json.Unmarshal(payload, &req)
|
||||
if req.SessionID == "" {
|
||||
return
|
||||
}
|
||||
h.orchestrator.Interrupt(req.SessionID)
|
||||
}
|
||||
|
||||
// broadcastStream maps a streamed model.StreamEvent to its downstream WS event
|
||||
|
||||
@@ -14,6 +14,10 @@ func (c *wsConn) route(in wsInbound) {
|
||||
c.publishPublic("ws.mode_switch", rawData(in.Data))
|
||||
case "chat":
|
||||
c.publishPublic(wsChatSubject, rawData(in.Data))
|
||||
case "interrupt":
|
||||
// design/15 §3: user says "停下/算了" → cancel the in-flight turn and
|
||||
// clear the session's pending queue, so the next message starts fresh.
|
||||
c.h.handleInterrupt(in.Data)
|
||||
case "voice_input":
|
||||
// Voice routes through the injected STT→chat→TTS pipeline. Without a
|
||||
// voice service it replies with a clear error rather than dropping the
|
||||
|
||||
Reference in new issue
Block a user