diff --git a/SELF_HOSTING_AI.md b/SELF_HOSTING_AI.md index 4c1bfad8f3b..e1158569ffc 100644 --- a/SELF_HOSTING_AI.md +++ b/SELF_HOSTING_AI.md @@ -80,6 +80,29 @@ invoked directly is the reverse again — there the environment outranks `.env`. startup output is read back from Docker Compose, so the address it prints is the one the stack is actually published on. +## Optional: server-side LLM features + +Chat auto-titling and chat follow-up suggestions ("quick actions") are produced +by a small server-side model call, separate from the agent runtimes. They are +off until the backend has an upstream configured: + +```bash +# In .env — either the key or the base URL is enough to enable the layer. +MULTICA_LLM_API_KEY=sk-... +MULTICA_LLM_BASE_URL= # optional: any OpenAI-compatible gateway +MULTICA_LLM_DEFAULT_MODEL= # optional: defaults to a small built-in model +``` + +Both features degrade silently when this is unset — titles stay as the first +message and no follow-up suggestions appear — so confirm the state from the +backend's startup log rather than from the UI: + +```bash +docker compose -f docker-compose.selfhost.yml logs backend | grep "llm layer" +# "llm layer enabled" -> configured +# "llm layer disabled" -> unset; the two features above will not appear +``` + ## Troubleshooting - **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend` diff --git a/deploy/helm/multica/values.yaml b/deploy/helm/multica/values.yaml index a593b41e86d..c5713bd3632 100644 --- a/deploy/helm/multica/values.yaml +++ b/deploy/helm/multica/values.yaml @@ -43,6 +43,13 @@ images: # The chart references this Secret by name; it does not template it, so real # values never need to land in git. # ----------------------------------------------------------------------------- +# Name of a pre-created Secret mounted onto the backend with envFrom, so any +# key in it becomes an environment variable. Server-side LLM features (chat +# auto-titling, chat follow-up suggestions) are configured here rather than in +# this file, because they are credentials: add MULTICA_LLM_API_KEY (and +# optionally MULTICA_LLM_BASE_URL / MULTICA_LLM_DEFAULT_MODEL) to this Secret. +# Without them the backend logs "llm layer disabled" at startup and both +# features stay silently off. existingSecret: multica-secrets # ----------------------------------------------------------------------------- diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 53306e30a00..46ab9751e65 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -102,6 +102,14 @@ services: DISABLE_WORKSPACE_CREATION: ${DISABLE_WORKSPACE_CREATION:-} GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-} GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-} + # Server-internal LLM layer (MUL-4238). Backs chat auto-titling and chat + # follow-up suggestions. Setting either the key or the base URL enables + # it; leaving both empty disables those features silently. Without these + # three lines the vars in .env never reach the container, so the features + # cannot be turned on at all no matter what the operator configures. + MULTICA_LLM_API_KEY: ${MULTICA_LLM_API_KEY:-} + MULTICA_LLM_BASE_URL: ${MULTICA_LLM_BASE_URL:-} + MULTICA_LLM_DEFAULT_MODEL: ${MULTICA_LLM_DEFAULT_MODEL:-} # Public URL the API is reachable at from the open internet, no # trailing slash. Used to mint absolute webhook URLs for autopilot # webhook triggers. Leave unset behind a same-origin reverse proxy diff --git a/server/internal/handler/chat_input_ownership_test.go b/server/internal/handler/chat_input_ownership_test.go index 2f4b4bec1d5..38713d64ef4 100644 --- a/server/internal/handler/chat_input_ownership_test.go +++ b/server/internal/handler/chat_input_ownership_test.go @@ -6,6 +6,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/multica-ai/multica/server/internal/service" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -674,6 +675,48 @@ func TestChatQuickActions_ContextAnchorsOnTargetTurn(t *testing.T) { } } +// TestChatQuickActions_AutomaticPassEndToEnd exercises the path a real chat +// turn takes — CompleteTask decides eligibility, broadcasts, and starts the +// pass — rather than calling the generator directly. Everything between the +// completion callback and the pills landing on the row is only covered here. +func TestChatQuickActions_AutomaticPassEndToEnd(t *testing.T) { + if testHandler == nil { + t.Skip("database not available") + } + ctx := context.Background() + agentID, sessionID, _, _ := setupDirectChatSession(t, ctx, "quick-actions auto e2e") + + taskID := sendDirectChat(t, ctx, agentID, sessionID, "what should I do next?") + markTaskRunning(t, ctx, taskID) + + restore := installStubQuickActions() + defer restore() + + if _, err := testHandler.TaskService.CompleteTask( + ctx, parseUUID(taskID), completeResult(t, "Here is the plan."), "", "", false, ""); err != nil { + t.Fatalf("complete task: %v", err) + } + + // The pass runs on a detached goroutine; poll for its write. + var actions []protocol.ChatQuickAction + for i := 0; i < 100; i++ { + rows := assistantRows(t, ctx, sessionID) + if len(rows) == 1 { + actions = nil + if err := json.Unmarshal(rows[0].QuickActions, &actions); err != nil { + t.Fatalf("decode quick actions: %v", err) + } + if len(actions) > 0 { + break + } + } + time.Sleep(20 * time.Millisecond) + } + if len(actions) != 1 || actions[0].Label != "Next" { + t.Fatalf("automatic pass must attach suggestions to the completed turn, got %+v", actions) + } +} + // installStubQuickActions enables suggestion generation and returns the undo. // Scoped tightly around the synchronous RegenerateChatQuickActions calls rather // than installed for a whole test: CompleteTask starts a background pass when diff --git a/server/internal/handler/handler.go b/server/internal/handler/handler.go index ca5a27e141a..a1d7299e690 100644 --- a/server/internal/handler/handler.go +++ b/server/internal/handler/handler.go @@ -319,6 +319,17 @@ func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub, bus *event DefaultModel: cfg.LLMDefaultModel, }) + // One boot-time line so an operator can tell configured-and-working apart + // from silently-off. Every consumer of this layer degrades quietly by + // design (auto-titling keeps the original title, chat follow-up suggestions + // simply never appear), which without this log makes an unset key + // indistinguishable from a broken feature. + if llmClient.Enabled() { + slog.Info("llm layer enabled", "default_model", llmClient.DefaultModel()) + } else { + slog.Warn("llm layer disabled: set MULTICA_LLM_API_KEY or MULTICA_LLM_BASE_URL to enable chat auto-titling and chat follow-up suggestions") + } + taskSvc := service.NewTaskService(queries, txStarter, hub, bus, daemonHub) taskSvc.Analytics = analyticsClient // Chat follow-up suggestions run through the same internal LLM layer that