diff --git a/cmd/gateway.go b/cmd/gateway.go
index 583ab1ae5..214ffbaa9 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -8,6 +8,7 @@ import (
"os/signal"
"path/filepath"
"syscall"
+ "time"
"github.com/google/uuid"
@@ -308,6 +309,18 @@ func runGateway() {
}()
}
+ // Sweep orphan traces left by previous crashes (running > 1h)
+ if pgStores.Tracing != nil {
+ go func() {
+ n, err := pgStores.Tracing.SweepOrphanTraces(context.Background(), time.Hour)
+ if err != nil {
+ slog.Warn("orphan trace sweep failed", "error", err)
+ } else if n > 0 {
+ slog.Info("orphan trace sweep complete", "swept", n)
+ }
+ }()
+ }
+
// Redis cache: compiled via build tags. Build with 'go build -tags redis' to enable.
redisClient := initRedisClient(cfg)
defer shutdownRedis(redisClient)
@@ -818,6 +831,11 @@ func runGateway() {
// Register channels/instances/links/teams RPC methods
wireChannelRPCMethods(server, pgStores, channelMgr, agentRouter, msgBus)
+ // Register party mode WS RPC methods
+ if pgStores.Party != nil {
+ methods.NewPartyMethods(pgStores.Party, pgStores.Agents, providerRegistry, msgBus).Register(server.Router())
+ }
+
// Wire channel event subscribers (cache invalidation, pairing, cascade disable)
wireChannelEventSubscribers(msgBus, server, pgStores, channelMgr, instanceLoader, pairingMethods, cfg)
diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go
index 395b42590..fb69e1020 100644
--- a/cmd/gateway_providers.go
+++ b/cmd/gateway_providers.go
@@ -319,7 +319,8 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
prov.WithProviderType(p.ProviderType)
registry.Register(prov)
default:
- prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "")
+ defaultModel := extractDefaultModel(p.Settings)
+ prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, defaultModel)
prov.WithProviderType(p.ProviderType)
if p.ProviderType == store.ProviderMiniMax {
prov.WithChatPath("/text/chatcompletion_v2")
@@ -329,3 +330,17 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
slog.Info("registered provider from DB", "name", p.Name)
}
}
+
+// extractDefaultModel reads default_model from a provider's settings JSONB.
+func extractDefaultModel(settings json.RawMessage) string {
+ if len(settings) == 0 {
+ return ""
+ }
+ var s struct {
+ DefaultModel string `json:"default_model"`
+ }
+ if json.Unmarshal(settings, &s) == nil {
+ return s.DefaultModel
+ }
+ return ""
+}
diff --git a/internal/agent/loop.go b/internal/agent/loop.go
index 910dbc19a..c563089e4 100644
--- a/internal/agent/loop.go
+++ b/internal/agent/loop.go
@@ -325,7 +325,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
reminder := "[System] " + strings.Join(parts, "\n\n")
messages = append(messages,
providers.Message{Role: "user", Content: reminder},
- providers.Message{Role: "assistant", Content: "I see the task status. Let me handle accordingly."},
+ // No assistant prefill โ thinking models reject it.
)
}
}
@@ -430,7 +430,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
Tools: toolDefs,
Model: l.model,
Options: map[string]any{
- providers.OptMaxTokens: 8192,
+ providers.OptMaxTokens: l.maxTokens,
providers.OptTemperature: 0.7,
providers.OptSessionKey: req.SessionKey,
providers.OptAgentID: l.agentUUID.String(),
@@ -506,6 +506,28 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
}
+ // Truncation guard: if response was cut off (max_tokens reached) and has tool calls,
+ // the tool call arguments are likely incomplete/malformed. Skip execution and ask
+ // the LLM to re-issue with complete arguments or break into smaller parts.
+ if resp.FinishReason == "length" && len(resp.ToolCalls) > 0 {
+ slog.Warn("truncated tool calls detected",
+ "agent", l.id, "iteration", iteration,
+ "tool_calls", len(resp.ToolCalls), "max_tokens", l.maxTokens)
+ messages = append(messages,
+ providers.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls,
+ Thinking: resp.Thinking, RawAssistantContent: resp.RawAssistantContent},
+ providers.Message{
+ Role: "user",
+ Content: "[System] Your response was truncated (max_tokens reached). The last tool call had incomplete arguments. Do NOT re-issue the same large tool call. Instead, break your work into smaller steps or respond with text only.",
+ },
+ )
+ pendingMsgs = append(pendingMsgs,
+ providers.Message{Role: "assistant", Content: resp.Content, ToolCalls: resp.ToolCalls},
+ providers.Message{Role: "user", Content: "[System] Response truncated โ tool call skipped."},
+ )
+ continue
+ }
+
if resp.Usage != nil {
totalUsage.PromptTokens += resp.Usage.PromptTokens
totalUsage.CompletionTokens += resp.Usage.CompletionTokens
diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go
index 763ff8201..e3de46456 100644
--- a/internal/agent/loop_history.go
+++ b/internal/agent/loop_history.go
@@ -285,7 +285,7 @@ func limitHistoryTurns(msgs []providers.Message, limit int) []providers.Message
// - Orphaned tool messages at start of history (after truncation)
// - tool_result without matching tool_use in preceding assistant message
// - assistant with tool_calls but missing tool_results
-// sanitizeHistory repairs tool_use/tool_result pairing in session history.
+//
// Returns the cleaned messages and the number of messages that were dropped or synthesized.
func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) {
if len(msgs) == 0 {
@@ -301,6 +301,7 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) {
"tool_call_id", msgs[start].ToolCallID)
dropped++
start++
+ dropped++
}
if start >= len(msgs) {
diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go
index 43c8c4060..c82582d52 100644
--- a/internal/agent/loop_types.go
+++ b/internal/agent/loop_types.go
@@ -46,6 +46,7 @@ type Loop struct {
contextWindow int
maxIterations int
maxToolCalls int
+ maxTokens int
workspace string
workspaceSharing *store.WorkspaceSharingConfig
@@ -154,6 +155,7 @@ type LoopConfig struct {
ContextWindow int
MaxIterations int
MaxToolCalls int
+ MaxTokens int
Workspace string
WorkspaceSharing *store.WorkspaceSharingConfig
@@ -263,6 +265,7 @@ func NewLoop(cfg LoopConfig) *Loop {
contextWindow: cfg.ContextWindow,
maxIterations: cfg.MaxIterations,
maxToolCalls: cfg.MaxToolCalls,
+ maxTokens: cfg.MaxTokens,
workspace: cfg.Workspace,
workspaceSharing: cfg.WorkspaceSharing,
restrictToWs: cfg.RestrictToWs,
diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go
index 6257f8d9b..57856babb 100644
--- a/internal/agent/resolver.go
+++ b/internal/agent/resolver.go
@@ -231,6 +231,10 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
if maxIter <= 0 {
maxIter = 20
}
+ maxTokens := ag.ParseMaxTokens()
+ if maxTokens <= 0 {
+ maxTokens = 8192
+ }
// Per-agent config overrides (fallback to global defaults from config.json)
compactionCfg := deps.CompactionCfg
@@ -357,6 +361,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
Model: ag.Model,
ContextWindow: contextWindow,
MaxIterations: maxIter,
+ MaxTokens: maxTokens,
Workspace: workspace,
RestrictToWs: &restrictVal,
SubagentsCfg: ag.ParseSubagentsConfig(),
diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go
index 68af4b556..650e3d1a2 100644
--- a/internal/channels/zalo/zalo.go
+++ b/internal/channels/zalo/zalo.go
@@ -89,7 +89,7 @@ func (c *Channel) Start(ctx context.Context) error {
if err != nil {
return fmt.Errorf("zalo getMe failed: %w", err)
}
- slog.Info("zalo bot connected", "bot_id", info.ID, "bot_name", info.Name)
+ slog.Info("zalo bot connected", "bot_id", info.ID, "bot_name", info.Label())
c.SetRunning(true)
@@ -418,29 +418,41 @@ type zaloAPIResponse struct {
}
type zaloBotInfo struct {
- ID string `json:"id"`
- Name string `json:"name"`
+ ID string `json:"id"`
+ Name string `json:"account_name"`
+ DisplayName string `json:"display_name"`
+}
+
+func (b *zaloBotInfo) Label() string {
+ if b.DisplayName != "" {
+ return b.DisplayName
+ }
+ return b.Name
}
type zaloMessage struct {
- MessageID string `json:"message_id"`
- Text string `json:"text"`
- Photo string `json:"photo"`
- PhotoURL string `json:"photo_url"`
- Caption string `json:"caption"`
- From zaloFrom `json:"from"`
- Chat zaloChat `json:"chat"`
- Date int64 `json:"date"`
+ MessageID string `json:"message_id"`
+ MessageType string `json:"message_type"`
+ Text string `json:"text"`
+ Photo string `json:"photo"`
+ PhotoURL string `json:"photo_url"`
+ Caption string `json:"caption"`
+ From zaloFrom `json:"from"`
+ Chat zaloChat `json:"chat"`
+ Date int64 `json:"date"`
}
type zaloFrom struct {
- ID string `json:"id"`
- Username string `json:"username"`
+ ID string `json:"id"`
+ Username string `json:"username"`
+ DisplayName string `json:"display_name"`
+ IsBot bool `json:"is_bot"`
}
type zaloChat struct {
- ID string `json:"id"`
- Type string `json:"type"`
+ ID string `json:"id"`
+ Type string `json:"type"`
+ ChatType string `json:"chat_type"`
}
type zaloUpdate struct {
@@ -514,11 +526,28 @@ func (c *Channel) getUpdates(timeout int) ([]zaloUpdate, error) {
return nil, err
}
+ // Try array first
var updates []zaloUpdate
- if err := json.Unmarshal(result, &updates); err != nil {
+ if err := json.Unmarshal(result, &updates); err == nil {
+ return updates, nil
+ }
+
+ // Try single object (Zalo Bot Platform returns one update at a time)
+ var single zaloUpdate
+ if err := json.Unmarshal(result, &single); err == nil && single.EventName != "" {
+ slog.Info("zalo update received", "event", single.EventName)
+ return []zaloUpdate{single}, nil
+ }
+
+ // Try wrapped {"updates": [...]}
+ var wrapped struct {
+ Updates []zaloUpdate `json:"updates"`
+ }
+ if err := json.Unmarshal(result, &wrapped); err != nil {
+ slog.Warn("zalo getUpdates unknown format", "raw", string(result[:min(len(result), 500)]))
return nil, fmt.Errorf("unmarshal updates: %w", err)
}
- return updates, nil
+ return wrapped.Updates, nil
}
func (c *Channel) sendMessage(chatID, text string) error {
diff --git a/internal/gateway/methods/party.go b/internal/gateway/methods/party.go
new file mode 100644
index 000000000..bac3a4975
--- /dev/null
+++ b/internal/gateway/methods/party.go
@@ -0,0 +1,564 @@
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "sort"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+ "github.com/nextlevelbuilder/goclaw/internal/gateway"
+ "github.com/nextlevelbuilder/goclaw/internal/party"
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// PartyMethods handles party.* WebSocket RPC methods.
+type PartyMethods struct {
+ partyStore store.PartyStore
+ agentStore store.AgentStore
+ providerReg *providers.Registry
+ msgBus *bus.MessageBus
+}
+
+// NewPartyMethods creates a new PartyMethods handler.
+func NewPartyMethods(partyStore store.PartyStore, agentStore store.AgentStore, providerReg *providers.Registry, msgBus *bus.MessageBus) *PartyMethods {
+ return &PartyMethods{
+ partyStore: partyStore,
+ agentStore: agentStore,
+ providerReg: providerReg,
+ msgBus: msgBus,
+ }
+}
+
+// Register registers all party.* methods on the router.
+func (m *PartyMethods) Register(router *gateway.MethodRouter) {
+ router.Register(protocol.MethodPartyStart, m.handleStart)
+ router.Register(protocol.MethodPartyRound, m.handleRound)
+ router.Register(protocol.MethodPartyQuestion, m.handleQuestion)
+ router.Register(protocol.MethodPartyAddContext, m.handleAddContext)
+ router.Register(protocol.MethodPartySummary, m.handleSummary)
+ router.Register(protocol.MethodPartyExit, m.handleExit)
+ router.Register(protocol.MethodPartyList, m.handleList)
+}
+
+// getEngine returns a party engine using the best available provider.
+// Prefers providers with a non-empty DefaultModel (DB providers with settings),
+// falling back to the first name alphabetically for deterministic selection.
+func (m *PartyMethods) getEngine() (*party.Engine, error) {
+ names := m.providerReg.List()
+ if len(names) == 0 {
+ return nil, fmt.Errorf("no LLM providers available")
+ }
+
+ // Prefer a provider with DefaultModel set (typically DB providers with settings.default_model)
+ var bestName string
+ for _, name := range names {
+ p, err := m.providerReg.Get(name)
+ if err != nil {
+ continue
+ }
+ if p.DefaultModel() != "" {
+ if bestName == "" || name < bestName {
+ bestName = name
+ }
+ }
+ }
+ // Fallback: pick first alphabetically for determinism
+ if bestName == "" {
+ sort.Strings(names)
+ bestName = names[0]
+ }
+
+ provider, err := m.providerReg.Get(bestName)
+ if err != nil {
+ return nil, fmt.Errorf("provider %s: %w", bestName, err)
+ }
+ return party.NewEngine(m.partyStore, m.agentStore, provider), nil
+}
+
+// emitterForClient creates an EventEmitter that broadcasts to all connected WS clients.
+func (m *PartyMethods) emitterForClient(client *gateway.Client) party.EventEmitter {
+ return func(event protocol.EventFrame) {
+ client.SendEvent(event)
+ }
+}
+
+type partyStartParams struct {
+ Topic string `json:"topic"`
+ TeamPreset string `json:"team_preset,omitempty"`
+ Personas []string `json:"personas,omitempty"`
+ ContextURLs []string `json:"context_urls,omitempty"`
+}
+
+func (m *PartyMethods) handleStart(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params partyStartParams
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ if params.Topic == "" {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "topic is required"))
+ return
+ }
+
+ // Resolve personas from preset or custom list
+ personaKeys := params.Personas
+ if params.TeamPreset != "" {
+ for _, preset := range party.PresetTeams() {
+ if preset.Key == params.TeamPreset {
+ personaKeys = preset.Personas
+ break
+ }
+ }
+ }
+ if len(personaKeys) == 0 {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "no personas selected"))
+ return
+ }
+
+ engine, err := m.getEngine()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Load persona info from DB
+ personas, err := engine.LoadPersonas(ctx, personaKeys)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Marshal persona keys for storage
+ personasJSON, _ := json.Marshal(personaKeys)
+
+ // Create session
+ sess := &store.PartySessionData{
+ Topic: params.Topic,
+ TeamPreset: params.TeamPreset,
+ Status: store.PartyStatusDiscussing,
+ Mode: store.PartyModeStandard,
+ MaxRounds: 10,
+ UserID: client.UserID(),
+ Personas: personasJSON,
+ }
+ if err := m.partyStore.CreateSession(ctx, sess); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ slog.Info("party: session started", "session_id", sess.ID, "topic", params.Topic, "personas", len(personas))
+
+ // Emit started event
+ emit := m.emitterForClient(client)
+ personaInfos := make([]map[string]string, len(personas))
+ for i, p := range personas {
+ personaInfos[i] = map[string]string{
+ "agent_key": p.AgentKey,
+ "display_name": p.DisplayName,
+ "emoji": p.Emoji,
+ "movie_ref": p.MovieRef,
+ }
+ }
+ emit(*protocol.NewEvent(protocol.EventPartyStarted, map[string]any{
+ "session_id": sess.ID,
+ "topic": params.Topic,
+ "personas": personaInfos,
+ }))
+
+ // Generate introductions for each persona
+ for _, p := range personas {
+ intro := fmt.Sprintf("%s %s reporting in. Ready to discuss: %s", p.Emoji, p.DisplayName, params.Topic)
+ emit(*protocol.NewEvent(protocol.EventPartyPersonaIntro, map[string]any{
+ "session_id": sess.ID,
+ "persona": p.AgentKey,
+ "emoji": p.Emoji,
+ "content": intro,
+ }))
+ }
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "session_id": sess.ID,
+ "personas": personaInfos,
+ "status": sess.Status,
+ }))
+}
+
+type partyRoundParams struct {
+ SessionID string `json:"session_id"`
+ Mode string `json:"mode,omitempty"`
+}
+
+func (m *PartyMethods) handleRound(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params partyRoundParams
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ sessID, err := uuid.Parse(params.SessionID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id"))
+ return
+ }
+
+ sess, err := m.partyStore.GetSession(ctx, sessID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found"))
+ return
+ }
+
+ if sess.Status != store.PartyStatusDiscussing {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "session is not in discussing state"))
+ return
+ }
+
+ // Increment round
+ sess.Round++
+ mode := params.Mode
+ if mode == "" {
+ mode = sess.Mode
+ }
+
+ engine, err := m.getEngine()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Load personas
+ var personaKeys []string
+ json.Unmarshal(sess.Personas, &personaKeys)
+ personas, err := engine.LoadPersonas(ctx, personaKeys)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ emit := m.emitterForClient(client)
+ emit(*protocol.NewEvent(protocol.EventPartyRoundStarted, map[string]any{
+ "session_id": sess.ID,
+ "round": sess.Round,
+ "mode": mode,
+ }))
+
+ // Run the round
+ var result *party.RoundResult
+ switch mode {
+ case store.PartyModeDeep:
+ result, err = engine.RunDeepRound(ctx, sess, personas, emit)
+ case store.PartyModeTokenRing:
+ result, err = engine.RunTokenRingRound(ctx, sess, personas, emit)
+ default:
+ result, err = engine.RunStandardRound(ctx, sess, personas, emit)
+ }
+ if err != nil {
+ slog.Error("party: round failed", "session", sess.ID, "round", sess.Round, "mode", mode, "error", err)
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Append to history
+ var history []party.RoundResult
+ json.Unmarshal(sess.History, &history)
+ history = append(history, *result)
+ historyJSON, _ := json.Marshal(history)
+
+ // Update session
+ if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{
+ "round": sess.Round,
+ "mode": mode,
+ "history": historyJSON,
+ }); err != nil {
+ slog.Warn("party: failed to update session", "error", err)
+ }
+
+ emit(*protocol.NewEvent(protocol.EventPartyRoundComplete, map[string]any{
+ "session_id": sess.ID,
+ "round": sess.Round,
+ "mode": mode,
+ }))
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "round": sess.Round,
+ "mode": mode,
+ "messages": result.Messages,
+ }))
+}
+
+type partyQuestionParams struct {
+ SessionID string `json:"session_id"`
+ Text string `json:"text"`
+}
+
+func (m *PartyMethods) handleQuestion(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params partyQuestionParams
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ sessID, err := uuid.Parse(params.SessionID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id"))
+ return
+ }
+
+ sess, err := m.partyStore.GetSession(ctx, sessID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found"))
+ return
+ }
+
+ // Temporarily set topic to the question for this round
+ originalTopic := sess.Topic
+ sess.Topic = fmt.Sprintf("%s\n\nUser question: %s", originalTopic, params.Text)
+ sess.Round++
+
+ engine, err := m.getEngine()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ var personaKeys []string
+ json.Unmarshal(sess.Personas, &personaKeys)
+ personas, err := engine.LoadPersonas(ctx, personaKeys)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ emit := m.emitterForClient(client)
+ result, err := engine.RunStandardRound(ctx, sess, personas, emit)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Restore topic and update session
+ sess.Topic = originalTopic
+ var history []party.RoundResult
+ json.Unmarshal(sess.History, &history)
+ history = append(history, *result)
+ historyJSON, _ := json.Marshal(history)
+
+ if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{
+ "round": sess.Round,
+ "history": historyJSON,
+ }); err != nil {
+ slog.Warn("party: failed to update session", "error", err)
+ }
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "round": sess.Round,
+ "messages": result.Messages,
+ }))
+}
+
+type partyAddContextParams struct {
+ SessionID string `json:"session_id"`
+ Type string `json:"type"`
+ Name string `json:"name,omitempty"`
+ Content string `json:"content,omitempty"`
+ URL string `json:"url,omitempty"`
+}
+
+func (m *PartyMethods) handleAddContext(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params partyAddContextParams
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ sessID, err := uuid.Parse(params.SessionID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id"))
+ return
+ }
+
+ sess, err := m.partyStore.GetSession(ctx, sessID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found"))
+ return
+ }
+
+ // Parse existing context
+ var sessionCtx map[string]any
+ if json.Unmarshal(sess.Context, &sessionCtx) != nil {
+ sessionCtx = make(map[string]any)
+ }
+
+ // Add new context based on type
+ switch params.Type {
+ case "document":
+ docs, _ := sessionCtx["documents"].([]any)
+ docs = append(docs, map[string]string{"name": params.Name, "content": params.Content, "source": "upload"})
+ sessionCtx["documents"] = docs
+ case "meeting_notes":
+ sessionCtx["meeting_notes"] = params.Content
+ case "custom":
+ sessionCtx["custom"] = params.Content
+ default:
+ sessionCtx[params.Type] = params.Content
+ }
+
+ contextJSON, _ := json.Marshal(sessionCtx)
+ if err := m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{
+ "context": contextJSON,
+ }); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ emit := m.emitterForClient(client)
+ emit(*protocol.NewEvent(protocol.EventPartyContextAdded, map[string]any{
+ "session_id": sess.ID,
+ "name": params.Name,
+ "type": params.Type,
+ }))
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "ok": true,
+ "context_count": len(sessionCtx),
+ }))
+}
+
+func (m *PartyMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params struct {
+ SessionID string `json:"session_id"`
+ }
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ sessID, err := uuid.Parse(params.SessionID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id"))
+ return
+ }
+
+ sess, err := m.partyStore.GetSession(ctx, sessID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found"))
+ return
+ }
+
+ engine, err := m.getEngine()
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ summary, err := engine.GenerateSummary(ctx, sess)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ // Store summary in session
+ summaryJSON, _ := json.Marshal(summary)
+ m.partyStore.UpdateSession(ctx, sess.ID, map[string]any{
+ "summary": summaryJSON,
+ "status": store.PartyStatusSummarizing,
+ })
+
+ emit := m.emitterForClient(client)
+ emit(*protocol.NewEvent(protocol.EventPartySummaryReady, map[string]any{
+ "session_id": sess.ID,
+ "summary": summary,
+ }))
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, summary))
+}
+
+func (m *PartyMethods) handleExit(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params struct {
+ SessionID string `json:"session_id"`
+ FollowUp string `json:"follow_up,omitempty"`
+ }
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ sessID, err := uuid.Parse(params.SessionID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid session_id"))
+ return
+ }
+
+ sess, err := m.partyStore.GetSession(ctx, sessID)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, "session not found"))
+ return
+ }
+
+ // Generate summary if not yet done
+ var summary *party.SummaryResult
+ if sess.Summary == nil || string(sess.Summary) == "null" {
+ engine, err := m.getEngine()
+ if err == nil {
+ summary, _ = engine.GenerateSummary(ctx, sess)
+ }
+ } else {
+ json.Unmarshal(sess.Summary, &summary)
+ }
+
+ // Close session
+ updates := map[string]any{"status": store.PartyStatusClosed}
+ if summary != nil {
+ summaryJSON, _ := json.Marshal(summary)
+ updates["summary"] = summaryJSON
+ }
+ m.partyStore.UpdateSession(ctx, sess.ID, updates)
+
+ emit := m.emitterForClient(client)
+ emit(*protocol.NewEvent(protocol.EventPartyClosed, map[string]any{
+ "session_id": sess.ID,
+ }))
+
+ response := map[string]any{
+ "session_id": sess.ID,
+ "status": store.PartyStatusClosed,
+ }
+ if summary != nil {
+ response["summary"] = summary
+ }
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, response))
+}
+
+func (m *PartyMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
+ var params struct {
+ Status string `json:"status,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ }
+ if err := json.Unmarshal(req.Params, ¶ms); err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid params"))
+ return
+ }
+
+ limit := params.Limit
+ if limit <= 0 {
+ limit = 20
+ }
+
+ sessions, err := m.partyStore.ListSessions(ctx, client.UserID(), params.Status, limit)
+ if err != nil {
+ client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error()))
+ return
+ }
+
+ client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
+ "sessions": sessions,
+ "count": len(sessions),
+ }))
+}
diff --git a/internal/party/engine.go b/internal/party/engine.go
new file mode 100644
index 000000000..3362524e4
--- /dev/null
+++ b/internal/party/engine.go
@@ -0,0 +1,307 @@
+package party
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "github.com/nextlevelbuilder/goclaw/internal/providers"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+ "github.com/nextlevelbuilder/goclaw/pkg/protocol"
+)
+
+// EventEmitter sends party events to connected clients.
+type EventEmitter func(event protocol.EventFrame)
+
+// Engine orchestrates party mode discussions.
+type Engine struct {
+ partyStore store.PartyStore
+ agentStore store.AgentStore
+ provider providers.Provider
+}
+
+// NewEngine creates a new party engine.
+func NewEngine(partyStore store.PartyStore, agentStore store.AgentStore, provider providers.Provider) *Engine {
+ return &Engine{
+ partyStore: partyStore,
+ agentStore: agentStore,
+ provider: provider,
+ }
+}
+
+// LoadPersonas loads persona info from agent DB for the given keys.
+func (e *Engine) LoadPersonas(ctx context.Context, keys []string) ([]PersonaInfo, error) {
+ var personas []PersonaInfo
+ for _, key := range keys {
+ agent, err := e.agentStore.GetByKey(ctx, key)
+ if err != nil {
+ return nil, fmt.Errorf("persona %s not found: %w", key, err)
+ }
+ pi := PersonaInfo{
+ AgentKey: key,
+ DisplayName: agent.DisplayName,
+ }
+ // Extract persona metadata from other_config
+ var cfg map[string]json.RawMessage
+ if json.Unmarshal(agent.OtherConfig, &cfg) == nil {
+ if personaJSON, ok := cfg["persona"]; ok {
+ var pm struct {
+ Emoji string `json:"emoji"`
+ MovieRef string `json:"movie_ref"`
+ SpeakingStyle string `json:"speaking_style"`
+ ExpertiseWeight map[string]float64 `json:"expertise_weight"`
+ }
+ if json.Unmarshal(personaJSON, &pm) == nil {
+ pi.Emoji = pm.Emoji
+ pi.MovieRef = pm.MovieRef
+ pi.SpeakingStyle = pm.SpeakingStyle
+ pi.ExpertiseWeight = pm.ExpertiseWeight
+ }
+ }
+ }
+ if pi.Emoji == "" {
+ pi.Emoji = "๐ค"
+ }
+ personas = append(personas, pi)
+ }
+ return personas, nil
+}
+
+// RunStandardRound executes a standard mode round (1 LLM call, all personas).
+func (e *Engine) RunStandardRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) {
+ slog.Info("party: standard round", "session", session.ID, "round", session.Round)
+
+ systemPrompt := "You are a party mode facilitator. Generate responses for each persona in character."
+ userPrompt := BuildStandardRoundPrompt(session, personas)
+
+ resp, err := e.llmCall(ctx, systemPrompt, userPrompt)
+ if err != nil {
+ return nil, fmt.Errorf("standard round LLM call: %w", err)
+ }
+
+ messages := parsePersonaMessages(resp, personas)
+ for _, m := range messages {
+ emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{
+ "session_id": session.ID, "persona": m.PersonaKey,
+ "emoji": m.Emoji, "content": m.Content,
+ }))
+ }
+
+ return &RoundResult{Round: session.Round, Mode: store.PartyModeStandard, Messages: messages}, nil
+}
+
+// RunDeepRound executes Deep Mode: parallel thinking โ cross-talk.
+func (e *Engine) RunDeepRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) {
+ slog.Info("party: deep round (parallel)", "session", session.ID, "round", session.Round, "personas", len(personas))
+
+ // Step 1: Parallel thinking
+ thoughts, err := e.runParallelThinking(ctx, session, personas, emit)
+ if err != nil {
+ return nil, fmt.Errorf("parallel thinking: %w", err)
+ }
+
+ // Step 2: Cross-talk (1 LLM call)
+ systemPrompt := "You are a party mode facilitator generating cross-talk between personas."
+ userPrompt := BuildCrossTalkPrompt(session, personas, thoughts)
+
+ resp, err := e.llmCall(ctx, systemPrompt, userPrompt)
+ if err != nil {
+ return nil, fmt.Errorf("cross-talk LLM call: %w", err)
+ }
+
+ messages := parsePersonaMessages(resp, personas)
+ for i := range messages {
+ // Attach thinking from Step 1
+ for _, t := range thoughts {
+ if t.PersonaKey == messages[i].PersonaKey {
+ messages[i].Thinking = t.Content
+ break
+ }
+ }
+ emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{
+ "session_id": session.ID, "persona": messages[i].PersonaKey,
+ "emoji": messages[i].Emoji, "content": messages[i].Content,
+ }))
+ }
+
+ return &RoundResult{Round: session.Round, Mode: store.PartyModeDeep, Messages: messages}, nil
+}
+
+// RunTokenRingRound executes Token-Ring: parallel thinking โ sequential turns.
+func (e *Engine) RunTokenRingRound(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) (*RoundResult, error) {
+ slog.Info("party: token-ring round", "session", session.ID, "round", session.Round, "personas", len(personas))
+
+ // Step 1: Parallel thinking
+ thoughts, err := e.runParallelThinking(ctx, session, personas, emit)
+ if err != nil {
+ return nil, fmt.Errorf("parallel thinking: %w", err)
+ }
+
+ // Step 2: Sequential turns
+ var messages []PersonaMessage
+ var priorTurns []PersonaMessage
+
+ for i, persona := range personas {
+ isLast := i == len(personas)-1
+
+ soulMD := e.loadPersonaSoulMD(ctx, persona.AgentKey)
+ systemPrompt := BuildPersonaSystemPrompt(persona, session, soulMD)
+ userPrompt := BuildTokenRingTurnPrompt(session, persona, thoughts, priorTurns, isLast)
+
+ resp, err := e.llmCall(ctx, systemPrompt, userPrompt)
+ if err != nil {
+ slog.Warn("party: token-ring turn failed", "persona", persona.AgentKey, "error", err)
+ continue
+ }
+
+ msg := PersonaMessage{
+ PersonaKey: persona.AgentKey,
+ DisplayName: persona.DisplayName,
+ Emoji: persona.Emoji,
+ Content: strings.TrimSpace(resp),
+ }
+ messages = append(messages, msg)
+ priorTurns = append(priorTurns, msg)
+
+ // Emit immediately โ user sees each persona respond in real-time
+ emit(*protocol.NewEvent(protocol.EventPartyPersonaSpoke, map[string]any{
+ "session_id": session.ID, "persona": msg.PersonaKey,
+ "emoji": msg.Emoji, "content": msg.Content,
+ }))
+ }
+
+ return &RoundResult{Round: session.Round, Mode: store.PartyModeTokenRing, Messages: messages}, nil
+}
+
+// runParallelThinking executes independent thinking for all personas in parallel.
+func (e *Engine) runParallelThinking(ctx context.Context, session *store.PartySessionData, personas []PersonaInfo, emit EventEmitter) ([]PersonaThought, error) {
+ thoughts := make([]PersonaThought, len(personas))
+ errs := make([]error, len(personas))
+ var wg sync.WaitGroup
+
+ for i, persona := range personas {
+ wg.Add(1)
+ go func(idx int, p PersonaInfo) {
+ defer wg.Done()
+
+ emit(*protocol.NewEvent(protocol.EventPartyPersonaThinking, map[string]any{
+ "session_id": session.ID, "persona": p.AgentKey, "emoji": p.Emoji,
+ }))
+
+ soulMD := e.loadPersonaSoulMD(ctx, p.AgentKey)
+ systemPrompt := BuildPersonaSystemPrompt(p, session, soulMD)
+ userPrompt := BuildThinkingPrompt(session, p)
+
+ resp, err := e.llmCall(ctx, systemPrompt, userPrompt)
+ if err != nil {
+ errs[idx] = err
+ return
+ }
+ thoughts[idx] = PersonaThought{PersonaKey: p.AgentKey, Emoji: p.Emoji, Content: strings.TrimSpace(resp)}
+ }(i, persona)
+ }
+ wg.Wait()
+
+ for i, err := range errs {
+ if err != nil {
+ return nil, fmt.Errorf("persona %s thinking failed: %w", personas[i].AgentKey, err)
+ }
+ }
+
+ return thoughts, nil
+}
+
+// GenerateSummary generates a discussion summary.
+func (e *Engine) GenerateSummary(ctx context.Context, session *store.PartySessionData) (*SummaryResult, error) {
+ prompt := BuildSummaryPrompt(session)
+ resp, err := e.llmCall(ctx, "You are a discussion summarizer. Generate structured markdown summaries.", prompt)
+ if err != nil {
+ return nil, fmt.Errorf("summary LLM call: %w", err)
+ }
+
+ var personaKeys []string
+ json.Unmarshal(session.Personas, &personaKeys)
+
+ return &SummaryResult{
+ Topic: session.Topic,
+ Rounds: session.Round,
+ Personas: personaKeys,
+ Markdown: resp,
+ }, nil
+}
+
+func (e *Engine) llmCall(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
+ req := providers.ChatRequest{
+ Messages: []providers.Message{
+ {Role: "system", Content: systemPrompt},
+ {Role: "user", Content: userPrompt},
+ },
+ Options: map[string]any{
+ "max_tokens": 4096,
+ },
+ }
+ resp, err := e.provider.Chat(ctx, req)
+ if err != nil {
+ return "", err
+ }
+ return resp.Content, nil
+}
+
+func (e *Engine) loadPersonaSoulMD(ctx context.Context, agentKey string) string {
+ agent, err := e.agentStore.GetByKey(ctx, agentKey)
+ if err != nil {
+ return ""
+ }
+ files, _ := e.agentStore.GetAgentContextFiles(ctx, agent.ID)
+ for _, f := range files {
+ if f.FileName == "SOUL.md" {
+ return f.Content
+ }
+ }
+ return ""
+}
+
+// parsePersonaMessages parses LLM output into individual persona messages.
+func parsePersonaMessages(resp string, personas []PersonaInfo) []PersonaMessage {
+ var messages []PersonaMessage
+ lines := strings.Split(resp, "\n")
+
+ var current *PersonaMessage
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ matched := false
+ for _, p := range personas {
+ if strings.HasPrefix(line, p.Emoji) {
+ if current != nil {
+ messages = append(messages, *current)
+ }
+ content := line
+ prefix := p.Emoji + " " + p.DisplayName + ":"
+ if strings.HasPrefix(line, prefix) {
+ content = strings.TrimSpace(line[len(prefix):])
+ }
+ current = &PersonaMessage{
+ PersonaKey: p.AgentKey,
+ DisplayName: p.DisplayName,
+ Emoji: p.Emoji,
+ Content: content,
+ }
+ matched = true
+ break
+ }
+ }
+ if !matched && current != nil {
+ current.Content += "\n" + line
+ }
+ }
+ if current != nil {
+ messages = append(messages, *current)
+ }
+ return messages
+}
diff --git a/internal/party/prompt.go b/internal/party/prompt.go
new file mode 100644
index 000000000..7217e92bd
--- /dev/null
+++ b/internal/party/prompt.go
@@ -0,0 +1,161 @@
+package party
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// BuildPersonaSystemPrompt builds the system prompt for a persona in a party round.
+func BuildPersonaSystemPrompt(persona PersonaInfo, session *store.PartySessionData, soulMD string) string {
+ var sb strings.Builder
+
+ // Persona identity
+ sb.WriteString(soulMD)
+ sb.WriteString("\n\n")
+
+ // Party context
+ sb.WriteString("\n")
+ sb.WriteString(fmt.Sprintf("%s\n", session.Topic))
+
+ var ctx map[string]json.RawMessage
+ if json.Unmarshal(session.Context, &ctx) == nil {
+ if docs, ok := ctx["documents"]; ok {
+ sb.WriteString("\n")
+ sb.Write(docs)
+ sb.WriteString("\n\n")
+ }
+ if code, ok := ctx["codebase"]; ok {
+ sb.WriteString("\n")
+ sb.Write(code)
+ sb.WriteString("\n\n")
+ }
+ if notes, ok := ctx["meeting_notes"]; ok {
+ sb.WriteString("\n")
+ sb.Write(notes)
+ sb.WriteString("\n\n")
+ }
+ if custom, ok := ctx["custom"]; ok {
+ sb.WriteString("\n")
+ sb.Write(custom)
+ sb.WriteString("\n\n")
+ }
+ }
+ sb.WriteString("\n\n")
+
+ // Round history (sliding window โ last 3 rounds)
+ var history []RoundResult
+ if json.Unmarshal(session.History, &history) == nil && len(history) > 0 {
+ start := 0
+ if len(history) > 3 {
+ start = len(history) - 3
+ }
+ sb.WriteString("\n")
+ for _, r := range history[start:] {
+ sb.WriteString(fmt.Sprintf("Round %d [%s]:\n", r.Round, r.Mode))
+ for _, m := range r.Messages {
+ sb.WriteString(fmt.Sprintf(" %s %s: %s\n", m.Emoji, m.DisplayName, truncate(m.Content, 500)))
+ }
+ }
+ sb.WriteString("\n")
+ }
+
+ return sb.String()
+}
+
+// BuildStandardRoundPrompt builds the user message for a standard round.
+func BuildStandardRoundPrompt(session *store.PartySessionData, personas []PersonaInfo) string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Round %d discussion about: %s\n\n", session.Round, session.Topic))
+ sb.WriteString("Respond as each of these personas IN CHARACTER. Each persona gives their expert analysis.\n")
+ sb.WriteString("Format each response as: {emoji} {name}: {response}\n")
+ sb.WriteString("Encourage genuine disagreement where expertise conflicts.\n\n")
+ sb.WriteString("Personas:\n")
+ for _, p := range personas {
+ sb.WriteString(fmt.Sprintf("- %s %s (%s)\n", p.Emoji, p.DisplayName, p.SpeakingStyle))
+ }
+ return sb.String()
+}
+
+// BuildThinkingPrompt builds the user message for Deep Mode Step 1 (independent thinking).
+func BuildThinkingPrompt(session *store.PartySessionData, persona PersonaInfo) string {
+ return fmt.Sprintf(
+ "Round %d: Think independently about \"%s\".\n"+
+ "Share your analysis from your %s expertise.\n"+
+ "Be specific, cite relevant standards/principles.\n"+
+ "Identify risks, opportunities, and trade-offs.\n"+
+ "Stay completely in character as %s.",
+ session.Round, session.Topic, persona.DisplayName, persona.DisplayName)
+}
+
+// BuildCrossTalkPrompt builds the prompt for Deep Mode Step 2 (cross-talk from collected thoughts).
+func BuildCrossTalkPrompt(session *store.PartySessionData, personas []PersonaInfo, thoughts []PersonaThought) string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Round %d cross-talk about: %s\n\n", session.Round, session.Topic))
+ sb.WriteString("Each persona has shared their independent thinking below.\n")
+ sb.WriteString("Now generate cross-talk: personas respond to EACH OTHER.\n")
+ sb.WriteString("Challenge disagreements explicitly. Build on agreements.\n")
+ sb.WriteString("Stay in character. Format: {emoji} {name}: {response}\n\n")
+
+ sb.WriteString("\n")
+ for _, t := range thoughts {
+ sb.WriteString(fmt.Sprintf("<%s>\n%s\n%s>\n", t.PersonaKey, t.Content, t.PersonaKey))
+ }
+ sb.WriteString("\n")
+ return sb.String()
+}
+
+// BuildTokenRingTurnPrompt builds the prompt for one persona's turn in Token-Ring mode.
+func BuildTokenRingTurnPrompt(session *store.PartySessionData, persona PersonaInfo, thoughts []PersonaThought, priorTurns []PersonaMessage, isLast bool) string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Round %d, your turn in the discussion about: %s\n\n", session.Round, session.Topic))
+
+ sb.WriteString("Independent thoughts from all personas:\n")
+ for _, t := range thoughts {
+ sb.WriteString(fmt.Sprintf("- %s: %s\n", t.PersonaKey, truncate(t.Content, 300)))
+ }
+
+ if len(priorTurns) > 0 {
+ sb.WriteString("\nPrior responses this round:\n")
+ for _, m := range priorTurns {
+ sb.WriteString(fmt.Sprintf(" %s %s: %s\n", m.Emoji, m.DisplayName, m.Content))
+ }
+ sb.WriteString("\nRespond to what others have said. Challenge or build on their points.\n")
+ }
+
+ if isLast {
+ sb.WriteString("\nYou are the LAST speaker. Synthesize: what does the team agree on? What remains unresolved?\n")
+ }
+
+ sb.WriteString("\nStay completely in character. Be direct and specific.")
+ return sb.String()
+}
+
+// BuildSummaryPrompt builds the prompt for generating the discussion summary.
+func BuildSummaryPrompt(session *store.PartySessionData) string {
+ return fmt.Sprintf(`Summarize this party mode discussion.
+
+Topic: %s
+Rounds: %d
+
+Discussion history:
+%s
+
+Generate a structured summary with:
+1. Points of Agreement (unanimous decisions)
+2. Points of Disagreement (who disagrees, why)
+3. Decisions Made
+4. Action Items (action, assignee persona, deadline suggestion, checkpoint link)
+5. Compliance Notes (if any security/PCI-DSS/SBV items)
+
+Format as clean markdown.`, session.Topic, session.Round, string(session.History))
+}
+
+func truncate(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen] + "..."
+}
diff --git a/internal/party/types.go b/internal/party/types.go
new file mode 100644
index 000000000..933d0f97d
--- /dev/null
+++ b/internal/party/types.go
@@ -0,0 +1,77 @@
+package party
+
+// PersonaInfo holds runtime persona metadata loaded from agent DB.
+type PersonaInfo struct {
+ AgentKey string `json:"agent_key"`
+ DisplayName string `json:"display_name"`
+ Emoji string `json:"emoji"`
+ MovieRef string `json:"movie_ref"`
+ SpeakingStyle string `json:"speaking_style"`
+ ExpertiseWeight map[string]float64 `json:"expertise_weight,omitempty"`
+}
+
+// PersonaThought is one persona's independent thinking output (Deep Mode Step 1).
+type PersonaThought struct {
+ PersonaKey string `json:"persona_key"`
+ Emoji string `json:"emoji"`
+ Content string `json:"content"`
+}
+
+// PersonaMessage is a persona's spoken message in a round.
+type PersonaMessage struct {
+ PersonaKey string `json:"persona_key"`
+ DisplayName string `json:"display_name"`
+ Emoji string `json:"emoji"`
+ Content string `json:"content"`
+ Thinking string `json:"thinking,omitempty"`
+}
+
+// RoundResult contains all persona messages for one round.
+type RoundResult struct {
+ Round int `json:"round"`
+ Mode string `json:"mode"`
+ Messages []PersonaMessage `json:"messages"`
+}
+
+// SummaryResult contains the party discussion summary.
+type SummaryResult struct {
+ Topic string `json:"topic"`
+ Rounds int `json:"rounds"`
+ Personas []string `json:"personas"`
+ Agreements []string `json:"agreements"`
+ Disagreements []string `json:"disagreements"`
+ Decisions []string `json:"decisions"`
+ ActionItems []ActionItem `json:"action_items"`
+ Compliance []string `json:"compliance_notes,omitempty"`
+ Markdown string `json:"markdown"`
+}
+
+// ActionItem is a follow-up task from the discussion.
+type ActionItem struct {
+ Action string `json:"action"`
+ Assignee string `json:"assignee"`
+ Deadline string `json:"deadline,omitempty"`
+ CPLink string `json:"cp_link,omitempty"`
+}
+
+// PresetTeam defines a preset team composition.
+type PresetTeam struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Personas []string `json:"personas"`
+ UseCase string `json:"use_case"`
+ Facilitator string `json:"facilitator"`
+ Mandatory []string `json:"mandatory,omitempty"`
+}
+
+// PresetTeams returns the 6 preset team compositions.
+func PresetTeams() []PresetTeam {
+ return []PresetTeam{
+ {Key: "payment_feature", Name: "Payment Feature", Personas: []string{"tony-stark-persona", "neo-persona", "batman-persona", "judge-dredd-persona", "columbo-persona"}, UseCase: "Payment flows, settlement", Facilitator: "gandalf-persona"},
+ {Key: "security_review", Name: "Security Review", Personas: []string{"batman-persona", "judge-dredd-persona", "neo-persona", "scotty-persona"}, UseCase: "Threat modeling, pre-CP3", Facilitator: "batman-persona"},
+ {Key: "sprint_planning", Name: "Sprint Planning", Personas: []string{"tony-stark-persona", "sherlock-persona", "neo-persona", "gandalf-persona", "columbo-persona"}, UseCase: "Sprint kickoff, PRD review", Facilitator: "gandalf-persona"},
+ {Key: "architecture_decision", Name: "Architecture Decision", Personas: []string{"neo-persona", "spock-persona", "scotty-persona", "batman-persona"}, UseCase: "ADR, tech stack eval", Facilitator: "morpheus-persona"},
+ {Key: "ux_review", Name: "UX Review", Personas: []string{"edna-mode-persona", "tony-stark-persona", "spider-man-persona", "ethan-hunt-persona", "columbo-persona"}, UseCase: "Design review", Facilitator: "edna-mode-persona"},
+ {Key: "incident_response", Name: "Incident Response", Personas: []string{"scotty-persona", "neo-persona", "batman-persona", "nick-fury-persona"}, UseCase: "Production incidents", Facilitator: "nick-fury-persona"},
+ }
+}
diff --git a/internal/providers/codex.go b/internal/providers/codex.go
index 9df7e43e6..fca2791da 100644
--- a/internal/providers/codex.go
+++ b/internal/providers/codex.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
"strings"
"time"
@@ -98,7 +99,10 @@ func (p *CodexProvider) ChatStream(ctx context.Context, req ChatRequest, onChunk
continue
}
args := make(map[string]any)
- _ = json.Unmarshal([]byte(acc.rawArgs), &args)
+ if err := json.Unmarshal([]byte(acc.rawArgs), &args); err != nil && acc.rawArgs != "" {
+ slog.Warn("truncated tool call arguments (codex stream)",
+ "tool", acc.name, "error", err, "raw_len", len(acc.rawArgs))
+ }
result.ToolCalls = append(result.ToolCalls, ToolCall{
ID: acc.callID,
Name: acc.name,
diff --git a/internal/providers/openai.go b/internal/providers/openai.go
index f9ad5f8f7..91050bf95 100644
--- a/internal/providers/openai.go
+++ b/internal/providers/openai.go
@@ -7,7 +7,9 @@ import (
"encoding/json"
"fmt"
"io"
+ "log/slog"
"net/http"
+ "sort"
"strings"
"time"
)
@@ -194,11 +196,21 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req ChatRequest, onChun
return nil, fmt.Errorf("%s: stream read error: %w", p.name, err)
}
- // Parse accumulated tool call arguments
- for i := 0; i < len(accumulators); i++ {
- acc := accumulators[i]
+ // Parse accumulated tool call arguments.
+ // Keys are SSE tool_call indices which may be non-contiguous (e.g. {0, 2}),
+ // so we sort keys instead of assuming sequential 0..len-1.
+ indices := make([]int, 0, len(accumulators))
+ for idx := range accumulators {
+ indices = append(indices, idx)
+ }
+ sort.Ints(indices)
+ for _, idx := range indices {
+ acc := accumulators[idx]
args := make(map[string]any)
- _ = json.Unmarshal([]byte(acc.rawArgs), &args)
+ if err := json.Unmarshal([]byte(acc.rawArgs), &args); err != nil && acc.rawArgs != "" {
+ slog.Warn("truncated tool call arguments (stream)",
+ "tool", acc.Name, "error", err, "raw_len", len(acc.rawArgs))
+ }
acc.Arguments = args
if acc.thoughtSig != "" {
acc.Metadata = map[string]string{"thought_signature": acc.thoughtSig}
@@ -379,7 +391,10 @@ func (p *OpenAIProvider) parseResponse(resp *openAIResponse) *ChatResponse {
for _, tc := range msg.ToolCalls {
args := make(map[string]any)
- _ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil && tc.Function.Arguments != "" {
+ slog.Warn("truncated tool call arguments (non-stream)",
+ "tool", tc.Function.Name, "error", err, "raw_len", len(tc.Function.Arguments))
+ }
call := ToolCall{
ID: tc.ID,
Name: strings.TrimSpace(tc.Function.Name),
diff --git a/internal/scheduler/lanes.go b/internal/scheduler/lanes.go
index 1af97d573..eb2488c01 100644
--- a/internal/scheduler/lanes.go
+++ b/internal/scheduler/lanes.go
@@ -92,6 +92,9 @@ func (l *Lane) Submit(ctx context.Context, fn func()) error {
go func() {
defer func() {
+ if r := recover(); r != nil {
+ slog.Error("panic in lane worker", "lane", l.name, "panic", r)
+ }
l.active.Add(-1)
l.wg.Done()
l.sem <- token // return token
diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go
index 14ef8f190..069e0f386 100644
--- a/internal/sessions/manager.go
+++ b/internal/sessions/manager.go
@@ -260,6 +260,19 @@ func (m *Manager) GetLastPromptTokens(key string) (int, int) {
return 0, 0
}
+// SetHistory replaces the full message history for a session.
+func (m *Manager) SetHistory(key string, msgs []providers.Message) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ s, ok := m.sessions[key]
+ if !ok {
+ return
+ }
+ s.Messages = msgs
+ s.Updated = time.Now()
+}
+
// TruncateHistory keeps only the last N messages.
func (m *Manager) TruncateHistory(key string, keepLast int) {
m.mu.Lock()
@@ -278,16 +291,6 @@ func (m *Manager) TruncateHistory(key string, keepLast int) {
s.Updated = time.Now()
}
-// SetHistory replaces a session's message history with the given slice.
-func (m *Manager) SetHistory(key string, msgs []providers.Message) {
- m.mu.Lock()
- defer m.mu.Unlock()
-
- if s, ok := m.sessions[key]; ok {
- s.Messages = msgs
- s.Updated = time.Now()
- }
-}
// Reset clears a session's history and summary.
func (m *Manager) Reset(key string) {
diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go
index 60827c7cb..e45e8e378 100644
--- a/internal/store/agent_store.go
+++ b/internal/store/agent_store.go
@@ -124,6 +124,21 @@ func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig {
return &c
}
+// ParseMaxTokens extracts max_tokens from other_config JSONB.
+// Returns 0 if not configured (caller should apply default).
+func (a *AgentData) ParseMaxTokens() int {
+ if len(a.OtherConfig) == 0 {
+ return 0
+ }
+ var cfg struct {
+ MaxTokens int `json:"max_tokens"`
+ }
+ if json.Unmarshal(a.OtherConfig, &cfg) != nil {
+ return 0
+ }
+ return cfg.MaxTokens
+}
+
// ParseThinkingLevel extracts thinking_level from other_config JSONB.
// Returns "" if not configured (meaning "off").
func (a *AgentData) ParseThinkingLevel() string {
diff --git a/internal/store/party_store.go b/internal/store/party_store.go
new file mode 100644
index 000000000..31e0b80ed
--- /dev/null
+++ b/internal/store/party_store.go
@@ -0,0 +1,56 @@
+package store
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// Party session statuses.
+const (
+ PartyStatusAssembling = "assembling"
+ PartyStatusDiscussing = "discussing"
+ PartyStatusSummarizing = "summarizing"
+ PartyStatusClosed = "closed"
+)
+
+// Party discussion modes.
+const (
+ PartyModeStandard = "standard"
+ PartyModeDeep = "deep"
+ PartyModeTokenRing = "token_ring"
+)
+
+// PartySessionData represents a party mode session.
+type PartySessionData struct {
+ ID uuid.UUID `json:"id"`
+ Topic string `json:"topic"`
+ TeamPreset string `json:"team_preset,omitempty"`
+ Status string `json:"status"`
+ Mode string `json:"mode"`
+ Round int `json:"round"`
+ MaxRounds int `json:"max_rounds"`
+ UserID string `json:"user_id"`
+ Channel string `json:"channel,omitempty"`
+ ChatID string `json:"chat_id,omitempty"`
+ Personas json.RawMessage `json:"personas"`
+ Context json.RawMessage `json:"context"`
+ History json.RawMessage `json:"history"`
+ Summary json.RawMessage `json:"summary,omitempty"`
+ Artifacts json.RawMessage `json:"artifacts"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// PartyStore manages party mode sessions.
+type PartyStore interface {
+ CreateSession(ctx context.Context, session *PartySessionData) error
+ GetSession(ctx context.Context, id uuid.UUID) (*PartySessionData, error)
+ UpdateSession(ctx context.Context, id uuid.UUID, updates map[string]any) error
+ ListSessions(ctx context.Context, userID string, status string, limit int) ([]*PartySessionData, error)
+ // GetActiveSession returns the active (assembling/discussing) session for a user+channel+chat.
+ GetActiveSession(ctx context.Context, userID, channel, chatID string) (*PartySessionData, error)
+ DeleteSession(ctx context.Context, id uuid.UUID) error
+}
diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go
index 58b1ea0a8..04fc0668a 100644
--- a/internal/store/pg/factory.go
+++ b/internal/store/pg/factory.go
@@ -44,5 +44,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
Contacts: NewPGContactStore(db),
Activity: NewPGActivityStore(db),
Snapshots: NewPGSnapshotStore(db),
+ Party: NewPGPartyStore(db),
}, nil
}
diff --git a/internal/store/pg/party.go b/internal/store/pg/party.go
new file mode 100644
index 000000000..ecb597db7
--- /dev/null
+++ b/internal/store/pg/party.go
@@ -0,0 +1,140 @@
+package pg
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const partySelectCols = `id, topic, team_preset, status, mode, round, max_rounds,
+ user_id, channel, chat_id, personas, context, history,
+ COALESCE(summary, 'null'), artifacts, created_at, updated_at`
+
+// PGPartyStore implements PartyStore backed by PostgreSQL.
+type PGPartyStore struct {
+ db *sql.DB
+}
+
+// NewPGPartyStore creates a new PGPartyStore.
+func NewPGPartyStore(db *sql.DB) *PGPartyStore {
+ return &PGPartyStore{db: db}
+}
+
+func (s *PGPartyStore) CreateSession(ctx context.Context, sess *store.PartySessionData) error {
+ if sess.ID == uuid.Nil {
+ sess.ID = store.GenNewID()
+ }
+ now := time.Now()
+ sess.CreatedAt = now
+ sess.UpdatedAt = now
+ if len(sess.Personas) == 0 {
+ sess.Personas = json.RawMessage("[]")
+ }
+ if len(sess.Context) == 0 {
+ sess.Context = json.RawMessage("{}")
+ }
+ if len(sess.History) == 0 {
+ sess.History = json.RawMessage("[]")
+ }
+ if len(sess.Artifacts) == 0 {
+ sess.Artifacts = json.RawMessage("[]")
+ }
+
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO party_sessions
+ (id, topic, team_preset, status, mode, round, max_rounds,
+ user_id, channel, chat_id, personas, context, history, artifacts, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
+ sess.ID, sess.Topic, sess.TeamPreset, sess.Status, sess.Mode,
+ sess.Round, sess.MaxRounds, sess.UserID, sess.Channel, sess.ChatID,
+ sess.Personas, sess.Context, sess.History, sess.Artifacts,
+ sess.CreatedAt, sess.UpdatedAt)
+ return err
+}
+
+func (s *PGPartyStore) GetSession(ctx context.Context, id uuid.UUID) (*store.PartySessionData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+partySelectCols+` FROM party_sessions WHERE id = $1`, id)
+ return scanPartyRow(row)
+}
+
+func (s *PGPartyStore) GetActiveSession(ctx context.Context, userID, channel, chatID string) (*store.PartySessionData, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT `+partySelectCols+` FROM party_sessions
+ WHERE user_id = $1 AND channel = $2 AND chat_id = $3
+ AND status IN ('assembling', 'discussing')
+ ORDER BY created_at DESC LIMIT 1`, userID, channel, chatID)
+ sess, err := scanPartyRow(row)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return sess, err
+}
+
+func (s *PGPartyStore) UpdateSession(ctx context.Context, id uuid.UUID, updates map[string]any) error {
+ updates["updated_at"] = time.Now()
+ return execMapUpdate(ctx, s.db, "party_sessions", id, updates)
+}
+
+func (s *PGPartyStore) ListSessions(ctx context.Context, userID string, status string, limit int) ([]*store.PartySessionData, error) {
+ query := `SELECT ` + partySelectCols + ` FROM party_sessions WHERE user_id = $1`
+ args := []any{userID}
+ if status != "" {
+ query += ` AND status = $2`
+ args = append(args, status)
+ }
+ query += ` ORDER BY created_at DESC`
+ if limit > 0 {
+ query += fmt.Sprintf(` LIMIT %d`, limit)
+ }
+
+ rows, err := s.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var sessions []*store.PartySessionData
+ for rows.Next() {
+ sess, err := scanPartyRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ sessions = append(sessions, sess)
+ }
+ return sessions, rows.Err()
+}
+
+func (s *PGPartyStore) DeleteSession(ctx context.Context, id uuid.UUID) error {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM party_sessions WHERE id = $1`, id)
+ return err
+}
+
+func scanPartyRow(row *sql.Row) (*store.PartySessionData, error) {
+ var s store.PartySessionData
+ err := row.Scan(&s.ID, &s.Topic, &s.TeamPreset, &s.Status, &s.Mode,
+ &s.Round, &s.MaxRounds, &s.UserID, &s.Channel, &s.ChatID,
+ &s.Personas, &s.Context, &s.History, &s.Summary, &s.Artifacts,
+ &s.CreatedAt, &s.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
+
+func scanPartyRows(rows *sql.Rows) (*store.PartySessionData, error) {
+ var s store.PartySessionData
+ err := rows.Scan(&s.ID, &s.Topic, &s.TeamPreset, &s.Status, &s.Mode,
+ &s.Round, &s.MaxRounds, &s.UserID, &s.Channel, &s.ChatID,
+ &s.Personas, &s.Context, &s.History, &s.Summary, &s.Artifacts,
+ &s.CreatedAt, &s.UpdatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &s, nil
+}
diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go
index 5033b6dea..29e466a45 100644
--- a/internal/store/pg/sessions_ops.go
+++ b/internal/store/pg/sessions_ops.go
@@ -6,24 +6,24 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
-func (s *PGSessionStore) TruncateHistory(key string, keepLast int) {
+func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[key]; ok {
- if keepLast <= 0 {
- data.Messages = []providers.Message{}
- } else if len(data.Messages) > keepLast {
- data.Messages = data.Messages[len(data.Messages)-keepLast:]
- }
+ data.Messages = msgs
data.Updated = time.Now()
}
}
-func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) {
+func (s *PGSessionStore) TruncateHistory(key string, keepLast int) {
s.mu.Lock()
defer s.mu.Unlock()
if data, ok := s.cache[key]; ok {
- data.Messages = msgs
+ if keepLast <= 0 {
+ data.Messages = []providers.Message{}
+ } else if len(data.Messages) > keepLast {
+ data.Messages = data.Messages[len(data.Messages)-keepLast:]
+ }
data.Updated = time.Now()
}
}
diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go
index 2fa06a3f7..34c9bcef2 100644
--- a/internal/store/pg/tracing.go
+++ b/internal/store/pg/tracing.go
@@ -428,6 +428,18 @@ func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.U
return cost, err
}
+func (s *PGTracingStore) SweepOrphanTraces(ctx context.Context, maxAge time.Duration) (int, error) {
+ cutoff := time.Now().Add(-maxAge)
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE traces SET status = 'error', error = 'orphan: process crashed', end_time = NOW()
+ WHERE status = 'running' AND created_at < $1`, cutoff)
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ return int(n), nil
+}
+
func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
var conditions []string
var args []any
diff --git a/internal/store/session_store.go b/internal/store/session_store.go
index 5f4d0435f..6d9d44332 100644
--- a/internal/store/session_store.go
+++ b/internal/store/session_store.go
@@ -102,8 +102,8 @@ type SessionStore interface {
GetContextWindow(key string) int
SetLastPromptTokens(key string, tokens, msgCount int)
GetLastPromptTokens(key string) (tokens, msgCount int)
- TruncateHistory(key string, keepLast int)
SetHistory(key string, msgs []providers.Message)
+ TruncateHistory(key string, keepLast int)
Reset(key string)
Delete(key string) error
List(agentID string) []SessionInfo
diff --git a/internal/store/stores.go b/internal/store/stores.go
index f89abbe9f..23a4b6c3e 100644
--- a/internal/store/stores.go
+++ b/internal/store/stores.go
@@ -25,4 +25,5 @@ type Stores struct {
Contacts ContactStore
Activity ActivityStore
Snapshots SnapshotStore
+ Party PartyStore
}
diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go
index 1dd800f6b..a5903fe26 100644
--- a/internal/store/tracing_store.go
+++ b/internal/store/tracing_store.go
@@ -141,4 +141,7 @@ type TracingStore interface {
// Cost aggregation
GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error)
GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error)
+
+ // Maintenance
+ SweepOrphanTraces(ctx context.Context, maxAge time.Duration) (int, error)
}
diff --git a/internal/tools/team_tasks_tool.go b/internal/tools/team_tasks_tool.go
index 7f7e5224e..0a40fb793 100644
--- a/internal/tools/team_tasks_tool.go
+++ b/internal/tools/team_tasks_tool.go
@@ -272,6 +272,16 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
status = store.TeamTaskStatusBlocked
}
+ channel := ToolChannelFromCtx(ctx)
+ meta, _ := args["metadata"].(map[string]any)
+ if meta == nil {
+ meta = make(map[string]any)
+ }
+ if senderID := store.SenderIDFromContext(ctx); senderID != "" {
+ meta["sender_id"] = senderID
+ }
+ meta["channel"] = channel
+
task := &store.TeamTaskData{
TeamID: team.ID,
Subject: subject,
@@ -279,8 +289,9 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any)
Status: status,
BlockedBy: blockedBy,
Priority: priority,
+ Metadata: meta,
UserID: store.UserIDFromContext(ctx),
- Channel: ToolChannelFromCtx(ctx),
+ Channel: channel,
}
if err := t.manager.teamStore.CreateTask(ctx, task); err != nil {
diff --git a/internal/tools/team_tasks_tool_test.go b/internal/tools/team_tasks_tool_test.go
new file mode 100644
index 000000000..ff0f2de2a
--- /dev/null
+++ b/internal/tools/team_tasks_tool_test.go
@@ -0,0 +1,300 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// โโ Mock stores โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+type mockTeamStore struct {
+ store.TeamStore // embed to satisfy full interface
+ team *store.TeamData
+ createdTask *store.TeamTaskData
+}
+
+func (m *mockTeamStore) GetTeamForAgent(_ context.Context, _ uuid.UUID) (*store.TeamData, error) {
+ if m.team == nil {
+ return nil, nil
+ }
+ return m.team, nil
+}
+
+func (m *mockTeamStore) CreateTask(_ context.Context, task *store.TeamTaskData) error {
+ task.ID = uuid.New()
+ task.CreatedAt = time.Now()
+ m.createdTask = task
+ return nil
+}
+
+type mockAgentStore struct {
+ store.AgentStore
+}
+
+func (m *mockAgentStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) {
+ return nil, fmt.Errorf("not found")
+}
+func (m *mockAgentStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
+ return nil, fmt.Errorf("not found")
+}
+
+// โโ Helper โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+func makeTestTeam(leadID uuid.UUID, settings json.RawMessage) *store.TeamData {
+ return &store.TeamData{
+ BaseModel: store.BaseModel{ID: uuid.New()},
+ Name: "test-team",
+ LeadAgentID: leadID,
+ Status: "active",
+ Settings: settings,
+ }
+}
+
+func makeCtx(agentID uuid.UUID, userID, senderID, channel string) context.Context {
+ ctx := context.Background()
+ ctx = store.WithAgentID(ctx, agentID)
+ ctx = store.WithUserID(ctx, userID)
+ if senderID != "" {
+ ctx = store.WithSenderID(ctx, senderID)
+ }
+ ctx = WithToolChannel(ctx, channel)
+ return ctx
+}
+
+// โโ Tests: sender_id tracking โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+func TestExecuteCreate_SenderIDTracking(t *testing.T) {
+ leadID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+
+ ts := &mockTeamStore{team: team}
+ mgr := NewTeamToolManager(ts, &mockAgentStore{}, nil)
+ tool := NewTeamTasksTool(mgr)
+
+ ctx := makeCtx(leadID, "group:telegram:chat123", "user-456", "telegram")
+ args := map[string]any{
+ "action": "create",
+ "subject": "Test task with sender_id",
+ }
+
+ result := tool.executeCreate(ctx, args)
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+
+ if ts.createdTask == nil {
+ t.Fatal("expected task to be created")
+ }
+
+ meta := ts.createdTask.Metadata
+ if meta == nil {
+ t.Fatal("expected metadata to be non-nil")
+ }
+
+ if sid, ok := meta["sender_id"].(string); !ok || sid != "user-456" {
+ t.Errorf("expected sender_id=user-456, got %v", meta["sender_id"])
+ }
+ if ch, ok := meta["channel"].(string); !ok || ch != "telegram" {
+ t.Errorf("expected channel=telegram, got %v", meta["channel"])
+ }
+}
+
+func TestExecuteCreate_NoSenderID(t *testing.T) {
+ leadID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+
+ ts := &mockTeamStore{team: team}
+ mgr := NewTeamToolManager(ts, &mockAgentStore{}, nil)
+ tool := NewTeamTasksTool(mgr)
+
+ // No sender ID in context (delegate channel, internal agent-to-agent)
+ ctx := makeCtx(leadID, "delegate:system", "", "delegate")
+ args := map[string]any{
+ "action": "create",
+ "subject": "Internal task",
+ }
+
+ result := tool.executeCreate(ctx, args)
+ if result.IsError {
+ t.Fatalf("expected success, got error: %s", result.ForLLM)
+ }
+
+ meta := ts.createdTask.Metadata
+ if meta == nil {
+ t.Fatal("expected metadata to be non-nil")
+ }
+
+ // sender_id should NOT be present (empty sender)
+ if _, ok := meta["sender_id"]; ok {
+ t.Error("expected no sender_id for delegate channel")
+ }
+ // channel should still be present
+ if ch, ok := meta["channel"].(string); !ok || ch != "delegate" {
+ t.Errorf("expected channel=delegate, got %v", meta["channel"])
+ }
+}
+
+// โโ Tests: requireLead โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+func TestExecuteCreate_RequireLead_Rejected(t *testing.T) {
+ leadID := uuid.New()
+ nonLeadID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+
+ ts := &mockTeamStore{team: team}
+ mgr := NewTeamToolManager(ts, &mockAgentStore{}, nil)
+ tool := NewTeamTasksTool(mgr)
+
+ // Non-lead agent trying to create task via telegram
+ ctx := makeCtx(nonLeadID, "group:telegram:chat123", "user-789", "telegram")
+ args := map[string]any{
+ "action": "create",
+ "subject": "Unauthorized task",
+ }
+
+ result := tool.executeCreate(ctx, args)
+ if !result.IsError {
+ t.Fatal("expected error for non-lead agent")
+ }
+ if !strings.Contains(result.ForLLM, "only the team lead") {
+ t.Errorf("expected 'only the team lead' error, got: %s", result.ForLLM)
+ }
+}
+
+func TestExecuteCreate_RequireLead_DelegateBypass(t *testing.T) {
+ leadID := uuid.New()
+ nonLeadID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+
+ ts := &mockTeamStore{team: team}
+ mgr := NewTeamToolManager(ts, &mockAgentStore{}, nil)
+ tool := NewTeamTasksTool(mgr)
+
+ // Non-lead agent via delegate channel (internal agent-to-agent) should bypass
+ ctx := makeCtx(nonLeadID, "delegate:system", "", "delegate")
+ args := map[string]any{
+ "action": "create",
+ "subject": "Delegated task",
+ }
+
+ result := tool.executeCreate(ctx, args)
+ if result.IsError {
+ t.Fatalf("delegate channel should bypass requireLead, got: %s", result.ForLLM)
+ }
+}
+
+// โโ Tests: checkTeamAccess โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+func TestCheckTeamAccess_AllowChannels(t *testing.T) {
+ settings := json.RawMessage(`{"allow_channels":["telegram","delegate","system"]}`)
+
+ // Allowed channel
+ if err := checkTeamAccess(settings, "user1", "telegram"); err != nil {
+ t.Errorf("telegram should be allowed: %v", err)
+ }
+
+ // Blocked channel
+ if err := checkTeamAccess(settings, "user1", "slack"); err == nil {
+ t.Error("slack should be denied")
+ }
+
+ // delegate always passes
+ if err := checkTeamAccess(settings, "user1", "delegate"); err != nil {
+ t.Errorf("delegate should always pass: %v", err)
+ }
+
+ // system always passes
+ if err := checkTeamAccess(settings, "user1", "system"); err != nil {
+ t.Errorf("system should always pass: %v", err)
+ }
+}
+
+func TestCheckTeamAccess_DenyOverAllow(t *testing.T) {
+ settings := json.RawMessage(`{
+ "allow_user_ids": ["user-A", "user-B"],
+ "deny_user_ids": ["user-B"]
+ }`)
+
+ // user-A allowed
+ if err := checkTeamAccess(settings, "user-A", "telegram"); err != nil {
+ t.Errorf("user-A should be allowed: %v", err)
+ }
+
+ // user-B denied (deny > allow)
+ if err := checkTeamAccess(settings, "user-B", "telegram"); err == nil {
+ t.Error("user-B should be denied (deny overrides allow)")
+ }
+
+ // user-C not in allow list
+ if err := checkTeamAccess(settings, "user-C", "telegram"); err == nil {
+ t.Error("user-C should be denied (not in allow list)")
+ }
+}
+
+func TestCheckTeamAccess_EmptySettings(t *testing.T) {
+ // Empty settings = open access
+ if err := checkTeamAccess(nil, "anyone", "any-channel"); err != nil {
+ t.Errorf("empty settings should allow all: %v", err)
+ }
+ if err := checkTeamAccess(json.RawMessage(`{}`), "anyone", "any-channel"); err != nil {
+ t.Errorf("empty JSON settings should allow all: %v", err)
+ }
+}
+
+func TestCheckTeamAccess_DenyChannels(t *testing.T) {
+ settings := json.RawMessage(`{"deny_channels":["whatsapp"]}`)
+
+ if err := checkTeamAccess(settings, "user1", "telegram"); err != nil {
+ t.Errorf("telegram should be allowed: %v", err)
+ }
+ if err := checkTeamAccess(settings, "user1", "whatsapp"); err == nil {
+ t.Error("whatsapp should be denied")
+ }
+}
+
+// โโ Tests: requireLead unit โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+func TestRequireLead_LeadAllowed(t *testing.T) {
+ leadID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+ mgr := NewTeamToolManager(&mockTeamStore{}, &mockAgentStore{}, nil)
+
+ ctx := makeCtx(leadID, "user1", "", "telegram")
+ if err := mgr.requireLead(ctx, team, leadID); err != nil {
+ t.Errorf("lead should be allowed: %v", err)
+ }
+}
+
+func TestRequireLead_NonLeadRejected(t *testing.T) {
+ leadID := uuid.New()
+ otherID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+ mgr := NewTeamToolManager(&mockTeamStore{}, &mockAgentStore{}, nil)
+
+ ctx := makeCtx(otherID, "user1", "", "telegram")
+ if err := mgr.requireLead(ctx, team, otherID); err == nil {
+ t.Error("non-lead should be rejected")
+ }
+}
+
+func TestRequireLead_SystemBypass(t *testing.T) {
+ leadID := uuid.New()
+ otherID := uuid.New()
+ team := makeTestTeam(leadID, nil)
+ mgr := NewTeamToolManager(&mockTeamStore{}, &mockAgentStore{}, nil)
+
+ for _, ch := range []string{"delegate", "system"} {
+ ctx := makeCtx(otherID, "user1", "", ch)
+ if err := mgr.requireLead(ctx, team, otherID); err != nil {
+ t.Errorf("channel %q should bypass requireLead: %v", ch, err)
+ }
+ }
+}
diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go
index c3c35a8eb..b4e625e53 100644
--- a/internal/upgrade/version.go
+++ b/internal/upgrade/version.go
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
-const RequiredSchemaVersion uint = 17
+const RequiredSchemaVersion uint = 18
diff --git a/migrations/000018_party_sessions.down.sql b/migrations/000018_party_sessions.down.sql
new file mode 100644
index 000000000..4adc042c7
--- /dev/null
+++ b/migrations/000018_party_sessions.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS party_sessions;
diff --git a/migrations/000018_party_sessions.up.sql b/migrations/000018_party_sessions.up.sql
new file mode 100644
index 000000000..17093083e
--- /dev/null
+++ b/migrations/000018_party_sessions.up.sql
@@ -0,0 +1,23 @@
+-- 000014_party_sessions.up.sql
+CREATE TABLE IF NOT EXISTS party_sessions (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
+ topic TEXT NOT NULL,
+ team_preset VARCHAR(50),
+ status VARCHAR(20) NOT NULL DEFAULT 'assembling',
+ mode VARCHAR(10) NOT NULL DEFAULT 'standard',
+ round INT NOT NULL DEFAULT 0,
+ max_rounds INT NOT NULL DEFAULT 10,
+ user_id VARCHAR(200) NOT NULL,
+ channel VARCHAR(255),
+ chat_id VARCHAR(200),
+ personas JSONB NOT NULL DEFAULT '[]',
+ context JSONB NOT NULL DEFAULT '{}',
+ history JSONB NOT NULL DEFAULT '[]',
+ summary JSONB,
+ artifacts JSONB NOT NULL DEFAULT '[]',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_party_sessions_user ON party_sessions(user_id, status);
+CREATE INDEX IF NOT EXISTS idx_party_sessions_channel ON party_sessions(channel, chat_id, status);
diff --git a/pkg/protocol/party.go b/pkg/protocol/party.go
new file mode 100644
index 000000000..b724e35fa
--- /dev/null
+++ b/pkg/protocol/party.go
@@ -0,0 +1,26 @@
+package protocol
+
+// Party Mode methods.
+const (
+ MethodPartyStart = "party.start"
+ MethodPartyRound = "party.round"
+ MethodPartyQuestion = "party.question"
+ MethodPartyAddContext = "party.add_context"
+ MethodPartySummary = "party.summary"
+ MethodPartyExit = "party.exit"
+ MethodPartyList = "party.list"
+)
+
+// Party Mode events.
+const (
+ EventPartyStarted = "party.started"
+ EventPartyPersonaIntro = "party.persona.intro"
+ EventPartyRoundStarted = "party.round.started"
+ EventPartyPersonaThinking = "party.persona.thinking"
+ EventPartyPersonaSpoke = "party.persona.spoke"
+ EventPartyRoundComplete = "party.round.complete"
+ EventPartyContextAdded = "party.context.added"
+ EventPartySummaryReady = "party.summary.ready"
+ EventPartyArtifact = "party.artifact"
+ EventPartyClosed = "party.closed"
+)
diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts
index 497dbf041..5053ffb08 100644
--- a/ui/web/src/api/protocol.ts
+++ b/ui/web/src/api/protocol.ts
@@ -143,6 +143,15 @@ export const Methods = {
DELEGATIONS_LIST: "delegations.list",
DELEGATIONS_GET: "delegations.get",
+ // Party mode
+ PARTY_START: "party.start",
+ PARTY_ROUND: "party.round",
+ PARTY_QUESTION: "party.question",
+ PARTY_ADD_CONTEXT: "party.add_context",
+ PARTY_SUMMARY: "party.summary",
+ PARTY_EXIT: "party.exit",
+ PARTY_LIST: "party.list",
+
// Phase 3+ - NICE TO HAVE
LOGS_TAIL: "logs.tail",
} as const;
@@ -202,6 +211,18 @@ export const Events = {
// Trace lifecycle
TRACE_UPDATED: "trace.updated",
+ // Party mode
+ PARTY_STARTED: "party.started",
+ PARTY_PERSONA_INTRO: "party.persona.intro",
+ PARTY_ROUND_STARTED: "party.round.started",
+ PARTY_PERSONA_THINKING: "party.persona.thinking",
+ PARTY_PERSONA_SPOKE: "party.persona.spoke",
+ PARTY_ROUND_COMPLETE: "party.round.complete",
+ PARTY_CONTEXT_ADDED: "party.context.added",
+ PARTY_SUMMARY_READY: "party.summary.ready",
+ PARTY_ARTIFACT: "party.artifact",
+ PARTY_CLOSED: "party.closed",
+
// Skill dependency check (realtime progress during startup/rescan)
SKILL_DEPS_CHECKED: "skill.deps.checked",
SKILL_DEPS_COMPLETE: "skill.deps.complete",
diff --git a/ui/web/src/components/layout/connection-status.tsx b/ui/web/src/components/layout/connection-status.tsx
index d54470146..bad04ced7 100644
--- a/ui/web/src/components/layout/connection-status.tsx
+++ b/ui/web/src/components/layout/connection-status.tsx
@@ -7,7 +7,7 @@ export function ConnectionStatus() {
const connected = useAuthStore((s) => s.connected);
return (
-
+
+
diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts
index 09c342d0b..b020fb8c3 100644
--- a/ui/web/src/i18n/index.ts
+++ b/ui/web/src/i18n/index.ts
@@ -30,6 +30,7 @@ import enSetup from "./locales/en/setup.json";
import enMemory from "./locales/en/memory.json";
import enStorage from "./locales/en/storage.json";
import enPendingMessages from "./locales/en/pending-messages.json";
+import enParty from "./locales/en/party.json";
import enContacts from "./locales/en/contacts.json";
import enActivity from "./locales/en/activity.json";
@@ -62,6 +63,7 @@ import viSetup from "./locales/vi/setup.json";
import viMemory from "./locales/vi/memory.json";
import viStorage from "./locales/vi/storage.json";
import viPendingMessages from "./locales/vi/pending-messages.json";
+import viParty from "./locales/vi/party.json";
import viContacts from "./locales/vi/contacts.json";
import viActivity from "./locales/vi/activity.json";
@@ -94,6 +96,7 @@ import zhSetup from "./locales/zh/setup.json";
import zhMemory from "./locales/zh/memory.json";
import zhStorage from "./locales/zh/storage.json";
import zhPendingMessages from "./locales/zh/pending-messages.json";
+import zhParty from "./locales/zh/party.json";
import zhContacts from "./locales/zh/contacts.json";
import zhActivity from "./locales/zh/activity.json";
@@ -113,7 +116,7 @@ const ns = [
"agents", "teams", "sessions", "skills", "cron", "config",
"channels", "providers", "traces", "events", "delegations",
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
- "setup", "memory", "storage", "pending-messages", "contacts", "activity",
+ "setup", "memory", "storage", "pending-messages", "party", "contacts", "activity",
] as const;
i18n.use(initReactI18next).init({
@@ -127,7 +130,7 @@ i18n.use(initReactI18next).init({
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
"pending-messages": enPendingMessages,
- contacts: enContacts, activity: enActivity,
+ party: enParty, contacts: enContacts, activity: enActivity,
},
vi: {
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
@@ -138,7 +141,7 @@ i18n.use(initReactI18next).init({
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
"pending-messages": viPendingMessages,
- contacts: viContacts, activity: viActivity,
+ party: viParty, contacts: viContacts, activity: viActivity,
},
zh: {
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
@@ -149,7 +152,7 @@ i18n.use(initReactI18next).init({
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
"pending-messages": zhPendingMessages,
- contacts: zhContacts, activity: zhActivity,
+ party: zhParty, contacts: zhContacts, activity: zhActivity,
},
},
ns: [...ns],
diff --git a/ui/web/src/i18n/locales/en/party.json b/ui/web/src/i18n/locales/en/party.json
new file mode 100644
index 000000000..b4c6e0aba
--- /dev/null
+++ b/ui/web/src/i18n/locales/en/party.json
@@ -0,0 +1,46 @@
+{
+ "title": "Party Mode",
+ "newParty": "New Party",
+ "topic": "Discussion Topic",
+ "selectTeam": "Select Team",
+ "customTeam": "Custom Team",
+ "start": "Start Discussion",
+ "presets": {
+ "payment_feature": "Payment Feature",
+ "security_review": "Security Review",
+ "sprint_planning": "Sprint Planning",
+ "architecture_decision": "Architecture Decision",
+ "ux_review": "UX Review",
+ "incident_response": "Incident Response"
+ },
+ "controls": {
+ "continue": "Continue",
+ "deepMode": "Deep Mode [P]",
+ "tokenRing": "Token Ring [R]",
+ "question": "Question [Q]",
+ "summary": "Summary [D]",
+ "exit": "Exit [E]"
+ },
+ "status": {
+ "thinking": "Thinking...",
+ "speaking": "Speaking",
+ "idle": "Idle"
+ },
+ "round": "Round {{n}}",
+ "mode": {
+ "standard": "Standard",
+ "deep": "Deep",
+ "token_ring": "Token Ring"
+ },
+ "noSessions": "No party sessions yet",
+ "description": "Multi-persona AI discussions with structured rounds",
+ "exitConfirm": "Exit this party session?",
+ "summary": {
+ "title": "Discussion Summary",
+ "agreements": "Points of Agreement",
+ "disagreements": "Points of Disagreement",
+ "decisions": "Decisions Made",
+ "actionItems": "Action Items",
+ "compliance": "Compliance Notes"
+ }
+}
diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json
index 4d4da4f4f..a79dd1d79 100644
--- a/ui/web/src/i18n/locales/en/sidebar.json
+++ b/ui/web/src/i18n/locales/en/sidebar.json
@@ -35,6 +35,7 @@
"approvals": "Approvals",
"nodes": "Nodes",
"tts": "TTS",
+ "party": "Party Mode",
"activity": "Activity"
}
}
diff --git a/ui/web/src/i18n/locales/vi/party.json b/ui/web/src/i18n/locales/vi/party.json
new file mode 100644
index 000000000..29de1f63d
--- /dev/null
+++ b/ui/web/src/i18n/locales/vi/party.json
@@ -0,0 +1,46 @@
+{
+ "title": "Ch\u1ebf \u0111\u1ed9 Party",
+ "newParty": "T\u1ea1o Party m\u1edbi",
+ "topic": "Ch\u1ee7 \u0111\u1ec1 th\u1ea3o lu\u1eadn",
+ "selectTeam": "Ch\u1ecdn \u0111\u1ed9i",
+ "customTeam": "\u0110\u1ed9i tu\u1ef3 ch\u1ec9nh",
+ "start": "B\u1eaft \u0111\u1ea7u th\u1ea3o lu\u1eadn",
+ "presets": {
+ "payment_feature": "T\u00ednh n\u0103ng Thanh to\u00e1n",
+ "security_review": "\u0110\u00e1nh gi\u00e1 B\u1ea3o m\u1eadt",
+ "sprint_planning": "L\u1eadp k\u1ebf ho\u1ea1ch Sprint",
+ "architecture_decision": "Quy\u1ebft \u0111\u1ecbnh Ki\u1ebfn tr\u00fac",
+ "ux_review": "\u0110\u00e1nh gi\u00e1 UX",
+ "incident_response": "X\u1eed l\u00fd s\u1ef1 c\u1ed1"
+ },
+ "controls": {
+ "continue": "Ti\u1ebfp t\u1ee5c",
+ "deepMode": "Ch\u1ebf \u0111\u1ed9 Deep [P]",
+ "tokenRing": "Token Ring [R]",
+ "question": "C\u00e2u h\u1ecfi [Q]",
+ "summary": "T\u00f3m t\u1eaft [D]",
+ "exit": "Tho\u00e1t [E]"
+ },
+ "status": {
+ "thinking": "\u0110ang suy ngh\u0129...",
+ "speaking": "\u0110ang n\u00f3i",
+ "idle": "Ch\u1edd"
+ },
+ "round": "V\u00f2ng {{n}}",
+ "mode": {
+ "standard": "Ti\u00eau chu\u1ea9n",
+ "deep": "Deep",
+ "token_ring": "Token Ring"
+ },
+ "noSessions": "Ch\u01b0a c\u00f3 phi\u00ean party n\u00e0o",
+ "description": "Th\u1ea3o lu\u1eadn AI \u0111a nh\u00e2n v\u1eadt v\u1edbi c\u00e1c v\u00f2ng c\u00f3 c\u1ea5u tr\u00fac",
+ "exitConfirm": "Tho\u00e1t phi\u00ean party n\u00e0y?",
+ "summary": {
+ "title": "T\u00f3m t\u1eaft th\u1ea3o lu\u1eadn",
+ "agreements": "\u0110i\u1ec3m \u0111\u1ed3ng thu\u1eadn",
+ "disagreements": "\u0110i\u1ec3m b\u1ea5t \u0111\u1ed3ng",
+ "decisions": "Quy\u1ebft \u0111\u1ecbnh",
+ "actionItems": "H\u1ea1ng m\u1ee5c h\u00e0nh \u0111\u1ed9ng",
+ "compliance": "Ghi ch\u00fa tu\u00e2n th\u1ee7"
+ }
+}
diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json
index 67640d1fd..bdde6adae 100644
--- a/ui/web/src/i18n/locales/vi/sidebar.json
+++ b/ui/web/src/i18n/locales/vi/sidebar.json
@@ -35,6 +35,7 @@
"approvals": "Phรช duyแปt",
"nodes": "Nodes",
"tts": "TTS",
+ "party": "Chแบฟ ฤแป Party",
"activity": "Hoแบกt ฤแปng"
}
}
diff --git a/ui/web/src/i18n/locales/zh/party.json b/ui/web/src/i18n/locales/zh/party.json
new file mode 100644
index 000000000..92ff4e516
--- /dev/null
+++ b/ui/web/src/i18n/locales/zh/party.json
@@ -0,0 +1,46 @@
+{
+ "title": "Party ๆจกๅผ",
+ "newParty": "ๆฐๅปบ Party",
+ "topic": "่ฎจ่ฎบไธป้ข",
+ "selectTeam": "้ๆฉๅข้",
+ "customTeam": "่ชๅฎไนๅข้",
+ "start": "ๅผๅง่ฎจ่ฎบ",
+ "presets": {
+ "payment_feature": "ๆฏไปๅ่ฝ",
+ "security_review": "ๅฎๅ
จๅฎกๆฅ",
+ "sprint_planning": "Sprint ่งๅ",
+ "architecture_decision": "ๆถๆๅณ็ญ",
+ "ux_review": "UX ๅฎกๆฅ",
+ "incident_response": "ไบไปถๅๅบ"
+ },
+ "controls": {
+ "continue": "็ปง็ปญ",
+ "deepMode": "ๆทฑๅบฆๆจกๅผ [P]",
+ "tokenRing": "ไปค็็ฏ [R]",
+ "question": "ๆ้ฎ [Q]",
+ "summary": "ๆป็ป [D]",
+ "exit": "้ๅบ [E]"
+ },
+ "status": {
+ "thinking": "ๆ่ไธญ...",
+ "speaking": "ๅ่จไธญ",
+ "idle": "็ฉบ้ฒ"
+ },
+ "round": "็ฌฌ {{n}} ่ฝฎ",
+ "mode": {
+ "standard": "ๆ ๅ",
+ "deep": "ๆทฑๅบฆ",
+ "token_ring": "ไปค็็ฏ"
+ },
+ "noSessions": "ๆๆ Party ไผ่ฏ",
+ "description": "ๅค่ง่ฒ AI ่ฎจ่ฎบ๏ผ็ปๆๅ่ฝฎๆฌก",
+ "exitConfirm": "้ๅบๆญค Party ไผ่ฏ๏ผ",
+ "summary": {
+ "title": "่ฎจ่ฎบๆป็ป",
+ "agreements": "ๅ
ฑ่ฏ่ฆ็น",
+ "disagreements": "ๅๆญง่ฆ็น",
+ "decisions": "ๅทฒๅๅณ็ญ",
+ "actionItems": "่กๅจ้กน",
+ "compliance": "ๅ่งๅคๆณจ"
+ }
+}
diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json
index f92249303..23d2d970f 100644
--- a/ui/web/src/i18n/locales/zh/sidebar.json
+++ b/ui/web/src/i18n/locales/zh/sidebar.json
@@ -35,6 +35,7 @@
"traces": "่ฟฝ่ธช",
"tts": "TTS",
"usage": "็จ้",
+ "party": "Party ๆจกๅผ",
"activity": "ๆดปๅจๆฅๅฟ"
}
}
diff --git a/ui/web/src/lib/constants.ts b/ui/web/src/lib/constants.ts
index 001b48ad4..422ec3dfa 100644
--- a/ui/web/src/lib/constants.ts
+++ b/ui/web/src/lib/constants.ts
@@ -26,6 +26,7 @@ export const ROUTES = {
PROVIDERS: "/providers",
TEAMS: "/teams",
TEAM_DETAIL: "/teams/:id",
+ PARTY: "/party",
CUSTOM_TOOLS: "/custom-tools",
BUILTIN_TOOLS: "/builtin-tools",
MCP: "/mcp",
diff --git a/ui/web/src/pages/party/hooks/use-party.ts b/ui/web/src/pages/party/hooks/use-party.ts
new file mode 100644
index 000000000..200f72cca
--- /dev/null
+++ b/ui/web/src/pages/party/hooks/use-party.ts
@@ -0,0 +1,453 @@
+import { useState, useCallback, useRef } from "react";
+import { useWs } from "@/hooks/use-ws";
+import { useWsEvent } from "@/hooks/use-ws-event";
+import { useAuthStore } from "@/stores/use-auth-store";
+import { Methods, Events } from "@/api/protocol";
+
+// --- Types ---
+
+export type PartyMode = "standard" | "deep" | "token_ring";
+
+export interface PersonaInfo {
+ key: string;
+ emoji: string;
+ name: string;
+ role: string;
+ color: string;
+}
+
+export interface PartyMessage {
+ id: string;
+ type: "intro" | "spoke" | "thinking" | "round_header" | "context" | "summary" | "artifact";
+ personaKey?: string;
+ personaEmoji?: string;
+ personaName?: string;
+ content: string;
+ round?: number;
+ mode?: PartyMode;
+ timestamp: number;
+}
+
+export interface PartySession {
+ id: string;
+ topic: string;
+ status: "active" | "closed";
+ personas: PersonaInfo[];
+ round: number;
+ mode: PartyMode;
+ createdAt: string;
+ // Preserved from backend for session restore
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _history?: any[];
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ _summary?: any;
+}
+
+export interface PartySummary {
+ agreements?: string[];
+ disagreements?: string[];
+ decisions?: string[];
+ actionItems?: string[];
+ compliance?: string[];
+ markdown?: string;
+}
+
+// Persona color palette for left-border styling
+const PERSONA_COLORS = [
+ "#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6",
+ "#ec4899", "#06b6d4", "#f97316", "#6366f1", "#14b8a6",
+ "#e11d48", "#84cc16", "#a855f7", "#0ea5e9",
+];
+
+// Map backend status to frontend display status
+function mapStatus(backendStatus: string): "active" | "closed" {
+ return backendStatus === "closed" ? "closed" : "active";
+}
+
+// Transform backend session (snake_case) to frontend PartySession
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function transformSession(raw: any): PartySession {
+ const personaKeys: string[] = Array.isArray(raw.personas)
+ ? raw.personas
+ : [];
+ return {
+ id: raw.id,
+ topic: raw.topic ?? "",
+ status: mapStatus(raw.status ?? ""),
+ personas: personaKeys.map((k: string) => ({
+ key: k,
+ emoji: "",
+ name: k,
+ role: "",
+ color: "",
+ })),
+ round: raw.round ?? 0,
+ mode: raw.mode ?? "standard",
+ createdAt: raw.created_at ?? raw.createdAt ?? "",
+ _history: Array.isArray(raw.history) ? raw.history : undefined,
+ _summary: raw.summary ?? undefined,
+ };
+}
+
+// Hydrate PartyMessage[] from backend history (RoundResult[]) and summary
+function hydrateMessages(
+ history: any[] | undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ summary: any | undefined, // eslint-disable-line @typescript-eslint/no-explicit-any
+ startId: number,
+): { msgs: PartyMessage[]; nextId: number } {
+ let id = startId;
+ const msgs: PartyMessage[] = [];
+ if (!history) return { msgs, nextId: id };
+
+ for (const round of history) {
+ // Round header
+ msgs.push({
+ id: `pm-${++id}`,
+ type: "round_header",
+ content: "",
+ round: round.round,
+ mode: round.mode as PartyMode,
+ timestamp: Date.now(),
+ });
+ // Persona messages
+ for (const m of round.messages ?? []) {
+ msgs.push({
+ id: `pm-${++id}`,
+ type: "spoke",
+ personaKey: m.persona_key,
+ personaEmoji: m.emoji ?? "",
+ personaName: m.display_name ?? m.persona_key,
+ content: m.content ?? "",
+ round: round.round,
+ timestamp: Date.now(),
+ });
+ }
+ }
+
+ // Summary (if exists and has markdown)
+ if (summary && typeof summary === "object" && summary.markdown) {
+ msgs.push({
+ id: `pm-${++id}`,
+ type: "summary",
+ content: summary.markdown,
+ timestamp: Date.now(),
+ });
+ }
+
+ return { msgs, nextId: id };
+}
+
+export function useParty() {
+ const ws = useWs();
+ const connected = useAuthStore((s) => s.connected);
+
+ const [sessions, setSessions] = useState([]);
+ const [activeSessionId, setActiveSessionId] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const [personas, setPersonas] = useState([]);
+ const [thinkingPersonas, setThinkingPersonas] = useState>(new Set());
+ const [round, setRound] = useState(0);
+ const [mode, setMode] = useState("standard");
+ const [status, setStatus] = useState<"idle" | "active" | "closed">("idle");
+ const [summary, setSummary] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const msgIdCounter = useRef(0);
+ const personaColorMap = useRef