package main import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "sync" "syscall" "time" ) func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: nomos serve") os.Exit(1) } mcpURL := os.Getenv("NOMOS_MCP_URL") if mcpURL == "" { mcpURL = "http://localhost:8090/mcp" } agentSlug := os.Getenv("NOMOS_AGENT_SLUG") if agentSlug == "" { agentSlug = "agent:nomos" } databaseURL := os.Getenv("DATABASE_URL") if databaseURL == "" { databaseURL = os.Getenv("OIKOS_DATABASE_URL") } switch os.Args[1] { case "serve": ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer cancel() client, err := newMCPClient(mcpURL) if err != nil { slog.Error("nomos: mcp connect", "url", mcpURL, "error", err) os.Exit(1) } st, err := newStore(ctx, databaseURL) if err != nil { slog.Error("nomos: db connect", "error", err) os.Exit(1) } if st != nil { defer st.close() } nAgent, err := newAgent(ctx, client, st, agentSlug) if err != nil { slog.Error("nomos: agent init", "error", err) os.Exit(1) } // Event-driven auto-continuation: feed finished async executions back // into the agent so an approved plan runs to completion (and recovers // from failures) without the operator ticking it forward each step. go nAgent.runContinuationWorker(ctx) mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) w.Write([]byte("ok")) }) mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) { handleQuery(w, r, client, agentSlug, mcpURL) }) mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { handleChat(w, r, nAgent, st) }) mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) { handleSessionsList(w, r, st) }) mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) { handleSessionDetail(w, r, st, nAgent) }) addr := os.Getenv("NOMOS_LISTEN") if addr == "" { addr = ":8092" } srv := &http.Server{Addr: addr, Handler: mux} go func() { slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "") if err := srv.ListenAndServe(); err != http.ErrServerClosed { slog.Error("nomos: serve", "error", err) } }() <-ctx.Done() slog.Info("nomos: shutting down") srv.Shutdown(context.Background()) client.close() default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) os.Exit(1) } } func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) { data, _ := json.Marshal(event) fmt.Fprintf(w, "data: %s\n\n", data) flusher.Flush() } func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { SessionID string `json:"session_id"` Message string `json:"message"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request: "+err.Error(), 400) return } if req.Message == "" { http.Error(w, "message is required", 400) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", 500) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.WriteHeader(200) ctx := r.Context() sessionID := req.SessionID if sessionID == "" { title := truncate(req.Message, 80) sess, err := st.createSession(ctx, title) if err != nil { slog.Error("nomos: create session", "error", err) sessionID = "ephemeral" } else { sessionID = sess.ID } } else { st.touchSession(ctx, sessionID) } slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100)) userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) st.saveMessage(ctx, sessionID, "user", userMsg) // If this task has a pending operator question, the incoming message IS the // answer — close it so the panel clears. No separate resume needed: this // chat turn is the resume, and the agent sees the question + answer in its // replayed history. if qid := st.openQuestionID(ctx, sessionID); qid != "" { st.answerQuestion(ctx, sessionID, qid, req.Message) } sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) toolCalls := []map[string]any{} var finalText string a.chat(ctx, sessionID, req.Message, func(ev agentEvent) { if ev.Type == "tool_use" || ev.Type == "tool_result" { if m, ok := ev.Data.(map[string]any); ok { m["type"] = ev.Type toolCalls = append(toolCalls, m) } } if ev.Type == "text" { finalText, _ = ev.Data.(string) } sseEvent(w, flusher, ev) }) assistantMsg, _ := json.Marshal(map[string]any{ "role": "assistant", "text": finalText, "tool_calls": toolCalls, }) st.saveMessage(ctx, sessionID, "assistant", assistantMsg) // Generate a meaningful title from the assistant's first answer // instead of reusing the raw user message for every session. if finalText != "" && sessionID != "ephemeral" { title := truncate(finalText, 80) if title != "" { st.updateSessionTitle(ctx, sessionID, title) } } } func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { if st == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}}) return } if r.Method == http.MethodOptions { return } sessions, err := st.listSessions(r.Context()) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"sessions": sessions}) } func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) { if st == nil { http.Error(w, "not found", 404) return } rest := strings.TrimPrefix(r.URL.Path, "/sessions/") parts := strings.Split(rest, "/") id := parts[0] if id == "" { http.Error(w, "session id required", 400) return } // POST /sessions/{id}/questions/{qid}/answer — the operator answers a // pinned question from the context panel; resume the agent with the answer. if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } handleAnswerQuestion(w, r, st, a, id, parts[2]) return } // GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for // the context panel when it first opens a task; live events carry deltas // from there. if len(parts) == 2 && r.Method == http.MethodGet { switch parts[1] { case "plan": steps, err := st.getPlanSteps(r.Context(), id) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"steps": steps}) return case "questions": questions, err := st.getQuestions(r.Context(), id) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"questions": questions}) return } } switch r.Method { case http.MethodDelete: if err := st.deleteSession(r.Context(), id); err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(204) case http.MethodGet: messages, err := st.getMessages(r.Context(), id) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages}) default: http.Error(w, "method not allowed", 405) } } // handleAnswerQuestion records the operator's answer to a pinned question and // resumes the agent in the background with that answer injected. Returns 202 — // the agent's response lands via the normal message-polling path, not this POST. func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) { var req struct { Answer string `json:"answer"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" { http.Error(w, "answer is required", 400) return } prompt, _, _ := st.getQuestion(r.Context(), questionID) if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil { http.Error(w, err.Error(), 500) return } if a != nil { note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+ "Continue the task from here — do not re-ask.]", prompt, req.Answer) go a.resumeSession(context.Background(), sessionID, note) } w.WriteHeader(202) } func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Query string `json:"query"` Tool string `json:"tool"` Args map[string]any `json:"args"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad request: "+err.Error(), 400) return } start := time.Now() if req.Tool != "" { result, err := client.callTool(req.Tool, req.Args) duration := time.Since(start).Milliseconds() if err != nil { slog.Error("nomos: query failed", "tool", req.Tool, "error", err) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "error": err.Error(), "elapsed_ms": duration, "agent_slug": agentSlug, }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "result": result, "elapsed_ms": duration, "agent_slug": agentSlug, "mcp_url": mcpURL, }) return } if req.Query != "" { if strings.Contains(strings.ToLower(req.Query), "what can you do") || strings.Contains(strings.ToLower(req.Query), "help") { tools, err := client.listTools() duration := time.Since(start).Milliseconds() if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "error": err.Error(), "elapsed_ms": duration, }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", "tools": tools, "elapsed_ms": duration, }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", "elapsed_ms": time.Since(start).Milliseconds(), }) return } http.Error(w, "either 'tool' or 'query' required", 400) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // ─── MCP Streamable HTTP client ──────────────────────────────────────── type mcpClient struct { baseURL string sessionID string http *http.Client nextID int mu sync.Mutex // MCP is one stateful session; serialize concurrent calls } func newMCPClient(baseURL string) (*mcpClient, error) { c := &mcpClient{ baseURL: baseURL, http: &http.Client{Timeout: 30 * time.Second}, } resp, err := c.doRequest("initialize", map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "nomos", "version": "2.0"}, }) if err != nil { return nil, fmt.Errorf("initialize: %w", err) } if resp.sessionID == "" { return nil, fmt.Errorf("no session ID in initialize response") } c.sessionID = resp.sessionID c.doRequest("notifications/initialized", map[string]any{}) slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...") return c, nil } type mcpJSONRPCResponse struct { sessionID string Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` } // errStaleSession signals that the MCP server rejected our session id (e.g. // after an api/MCP restart), so the client should re-initialize and retry. var errStaleSession = fmt.Errorf("mcp session stale") // doRequest serializes MCP calls and transparently re-initializes the session // if the server has forgotten it (common after an api redeploy), retrying the // original call once. Without this, an api restart permanently breaks nomos // until it is itself restarted. func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) { c.mu.Lock() defer c.mu.Unlock() resp, err := c.send(method, params) if err != nil && method != "initialize" && errors.Is(err, errStaleSession) { slog.Warn("nomos: mcp session stale, reconnecting") if rerr := c.reconnectLocked(); rerr != nil { return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err) } return c.send(method, params) } return resp, err } // reconnectLocked re-initializes the MCP session. The caller must hold c.mu. func (c *mcpClient) reconnectLocked() error { c.sessionID = "" resp, err := c.send("initialize", map[string]any{ "protocolVersion": "2024-11-05", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "nomos", "version": "2.0"}, }) if err != nil { return err } if resp.sessionID == "" { return fmt.Errorf("no session ID on re-initialize") } c.sessionID = resp.sessionID _, _ = c.send("notifications/initialized", map[string]any{}) slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...") return nil } // send performs one MCP round-trip. It does not lock; callers hold c.mu. func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) { c.nextID++ body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "method": method, "params": params, "id": c.nextID, }) req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") if c.sessionID != "" { req.Header.Set("Mcp-Session-Id", c.sessionID) } resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() // A rejected/unknown session comes back as 4xx (commonly 400/404). if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest { return nil, errStaleSession } result := &mcpJSONRPCResponse{} result.sessionID = resp.Header.Get("Mcp-Session-Id") scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) gotData := false for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "data: ") { gotData = true data := line[6:] if err := json.Unmarshal([]byte(data), result); err != nil { return nil, fmt.Errorf("parse response: %w", err) } } } if result.Error != nil { return nil, fmt.Errorf("rpc error: %s", string(result.Error)) } // Empty body with no result and a session set: the server likely dropped // our session. Notifications legitimately return no data, so exempt them. if !gotData && result.Result == nil && method != "notifications/initialized" { return nil, errStaleSession } if result.sessionID != "" { c.sessionID = result.sessionID } return result, nil } func (c *mcpClient) callTool(name string, args map[string]any) (any, error) { resp, err := c.doRequest("tools/call", map[string]any{ "name": name, "arguments": args, }) if err != nil { return nil, err } var toolResult struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } if err := json.Unmarshal(resp.Result, &toolResult); err != nil { return string(resp.Result), nil } var texts []string for _, c := range toolResult.Content { if c.Type == "text" { var parsed any if json.Unmarshal([]byte(c.Text), &parsed) == nil { return parsed, nil } texts = append(texts, c.Text) } } if len(texts) == 1 { return texts[0], nil } return texts, nil } func (c *mcpClient) listTools() ([]string, error) { resp, err := c.doRequest("tools/list", map[string]any{}) if err != nil { return nil, err } var tr struct { Tools []struct { Name string `json:"name"` Description string `json:"description"` } `json:"tools"` } if err := json.Unmarshal(resp.Result, &tr); err != nil { return nil, err } var names []string for _, t := range tr.Tools { names = append(names, t.Name) } return names, nil } func (c *mcpClient) close() { }