diff --git a/internal/llm/client.go b/internal/llm/client.go index c33c817..2dffcb3 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -50,6 +50,9 @@ type ChatRequest struct { Messages []Message `json:"messages"` Tools []Tool `json:"tools,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` + // Temperature is optional sampling heat; nil = provider default. + // Forwarded as-is (the serve front door passes the client's value). + Temperature *float64 `json:"temperature,omitempty"` } type ChatResponse struct { diff --git a/internal/loop/loop.go b/internal/loop/loop.go index 045e651..72d95c5 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -153,20 +153,51 @@ type TurnResult struct { StopReason string } -// turn runs the bounded LLM loop: request, execute tool calls, repeat until -// the model replies with plain content or the round bound is hit. +// turn builds the single-task message shape and runs the shared bounded +// turn (the `once`/`loop` path: system prompt + task prompt, tools on). func (c *Conductor) turn(ctx context.Context, client *llm.Client, t task.Task, model string) (*TurnResult, error) { - msgs := []llm.Message{ - {Role: "system", Content: c.systemPrompt()}, - {Role: "user", Content: t.Prompt}, - } - var palette []llm.Tool - if c.bash != nil { - palette = append(palette, c.bash.Definition()) + return c.runTurn(ctx, client, turnParams{ + msgs: []llm.Message{ + {Role: "system", Content: c.systemPrompt()}, + {Role: "user", Content: t.Prompt}, + }, + model: model, + palette: c.toolPalette(), + }) +} + +// turnParams is one bounded-turn invocation, shared by the task path +// (`once`/`loop`) and the serve front door. +type turnParams struct { + msgs []llm.Message + model string + palette []llm.Tool // tools offered to the model; nil = tools OFF + maxTokens int + temperature *float64 +} + +func (c *Conductor) toolPalette() []llm.Tool { + if c.bash == nil { + return nil } + return []llm.Tool{c.bash.Definition()} +} + +// runTurn is the bounded LLM loop: request, execute tool calls, repeat until +// the model replies with plain content or the round bound is hit. With a nil +// palette the turn is pure chat: a tool call the model produces anyway is +// refused with a tool result, never executed. +func (c *Conductor) runTurn(ctx context.Context, client *llm.Client, p turnParams) (*TurnResult, error) { + msgs := p.msgs res := &TurnResult{} for round := 1; round <= c.cfg.Loop.MaxRounds; round++ { - resp, err := client.Chat(ctx, llm.ChatRequest{Model: model, Messages: msgs, Tools: palette}) + resp, err := client.Chat(ctx, llm.ChatRequest{ + Model: p.model, + Messages: msgs, + Tools: p.palette, + MaxTokens: p.maxTokens, + Temperature: p.temperature, + }) if err != nil { res.StopReason = "llm_error" return res, err @@ -186,11 +217,18 @@ func (c *Conductor) turn(ctx context.Context, client *llm.Client, t task.Task, m } msgs = append(msgs, m) for _, tc := range m.ToolCalls { - out, derr := c.execTool(ctx, tc) - res.ToolCalls++ - if derr != nil { + var out string + if len(p.palette) == 0 { + out = "error: tools are disabled on this endpoint" res.Denied++ + } else { + var derr error + out, derr = c.execTool(ctx, tc) + if derr != nil { + res.Denied++ + } } + res.ToolCalls++ msgs = append(msgs, llm.Message{Role: "tool", ToolCallID: tc.ID, Content: out}) } } @@ -198,6 +236,34 @@ func (c *Conductor) turn(ctx context.Context, client *llm.Client, t task.Task, m return res, nil } +// ServeTurnOpts carries the OpenAI-compatible request knobs the serve front +// door forwards to the upstream model. +type ServeTurnOpts struct { + MaxTokens int + Temperature *float64 +} + +// ServeTurn runs one bounded conductor turn over a pre-assembled conversation +// history — the `harness serve` path (stateless: the client sends the whole +// conversation each call, tools are OFF). If the history carries no leading +// system message, a minimal one identifying the vertical is prepended; the +// client's own system message always wins. +func (c *Conductor) ServeTurn(ctx context.Context, msgs []llm.Message, model string, opts ServeTurnOpts) (*TurnResult, error) { + if len(msgs) == 0 || msgs[0].Role != "system" { + msgs = append([]llm.Message{{Role: "system", Content: c.serveSystemPrompt()}}, msgs...) + } + client, err := c.llmClient() + if err != nil { + return nil, err + } + return c.runTurn(ctx, client, turnParams{ + msgs: msgs, + model: model, + maxTokens: opts.MaxTokens, + temperature: opts.Temperature, + }) +} + // execTool dispatches one tool call; the returned string is the model-facing // result. The returned error is non-nil only for gate denials (counted). func (c *Conductor) execTool(ctx context.Context, call llm.ToolCall) (string, error) { @@ -315,6 +381,16 @@ match the configured allow-list (unmatched or denied commands fail).`, c.cfg.Vertical, c.cfg.WorkRoot) } +func (c *Conductor) serveSystemPrompt() string { + return fmt.Sprintf(`You are %s, the interactive front door of the MOPAC harness (served to +OpenWebUI, stateless turns, tools off). Answer directly and completely; your +reply is returned verbatim to the user.`, c.cfg.Vertical) +} + +// Router exposes the model router so `harness serve` can build its servable +// model catalog from the same class -> tier -> model mapping `once` uses. +func (c *Conductor) Router() *models.Router { return c.router } + // DispatchEvent is the events -> conductor wiring point: the `harness // events` receiver hands every stored, actionable webhook to the conductor // here. Phase 2b stub: it records what a real dispatch would do; the diff --git a/internal/loop/loop_test.go b/internal/loop/loop_test.go index 79cc7eb..85fa8b0 100644 --- a/internal/loop/loop_test.go +++ b/internal/loop/loop_test.go @@ -317,3 +317,132 @@ func TestOnceUnknownClassIsRoutingError(t *testing.T) { t.Fatalf("err = %v, want unknown-class error", err) } } + +func TestServeTurnPrependsSystemPromptWhenMissing(t *testing.T) { + f := newFakeLLM(t, textMsg("front door reply")) + cfg := testConfig(t, f.srv.URL) + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + turn, err := cond.ServeTurn(context.Background(), + []llm.Message{{Role: "user", Content: "hello there"}}, "glm-5.3", ServeTurnOpts{}) + if err != nil { + t.Fatalf("ServeTurn: %v", err) + } + if turn.Content != "front door reply" || turn.StopReason != "complete" { + t.Errorf("turn = %+v", turn) + } + req := f.request(0) + if req.Model != "glm-5.3" { + t.Errorf("request model = %q, want glm-5.3", req.Model) + } + if len(req.Messages) != 2 || req.Messages[0].Role != "system" { + t.Fatalf("messages = %+v, want [system, user]", req.Messages) + } + if !strings.Contains(req.Messages[0].Content, "teststack") || !strings.Contains(req.Messages[0].Content, "OpenWebUI") { + t.Errorf("prepended system prompt = %q, want vertical identity", req.Messages[0].Content) + } + if req.Messages[1].Role != "user" || req.Messages[1].Content != "hello there" { + t.Errorf("user message = %+v", req.Messages[1]) + } +} + +func TestServeTurnPreservesClientSystemMessage(t *testing.T) { + f := newFakeLLM(t, textMsg("aye")) + cfg := testConfig(t, f.srv.URL) + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + history := []llm.Message{ + {Role: "system", Content: "You are a pirate."}, + {Role: "user", Content: "greet me"}, + {Role: "assistant", Content: "ahoy"}, + {Role: "user", Content: "again"}, + } + if _, err := cond.ServeTurn(context.Background(), history, "glm-5.3", ServeTurnOpts{}); err != nil { + t.Fatalf("ServeTurn: %v", err) + } + got := f.request(0).Messages + if len(got) != len(history) { + t.Fatalf("history length = %d, want %d (client system message wins, no prepend)", len(got), len(history)) + } + for i, m := range history { + if got[i].Role != m.Role || got[i].Content != m.Content { + t.Errorf("message[%d] = %+v, want %+v", i, got[i], m) + } + } +} + +func TestServeTurnToolsOffNeverExecutes(t *testing.T) { + // pwd IS on the allow list: if the palette leaked into the serve path + // the command would run and its output would come back as the tool + // result. Tools are off, so the model must instead see the disabled + // refusal and the turn must count a denial. + f := newFakeLLM(t, + toolCallMsg("call-1", "bash", `{"command":"pwd"}`), + textMsg("No tools then, here is the answer."), + ) + cfg := testConfig(t, f.srv.URL) + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + turn, err := cond.ServeTurn(context.Background(), + []llm.Message{{Role: "user", Content: "run pwd"}}, "glm-5.3", ServeTurnOpts{}) + if err != nil { + t.Fatalf("ServeTurn: %v", err) + } + if f.requestCount() != 2 { + t.Fatalf("LLM calls = %d, want 2 (refused tool call fed back)", f.requestCount()) + } + if turn.ToolCalls != 1 || turn.Denied != 1 { + t.Errorf("turn = %+v, want 1 refused tool call", turn) + } + toolMsg := f.request(1).Messages[len(f.request(1).Messages)-1] + if toolMsg.Role != "tool" || !strings.Contains(toolMsg.Content, "tools are disabled") { + t.Errorf("tool result = %+v, want the disabled refusal", toolMsg) + } + if turn.Content != "No tools then, here is the answer." { + t.Errorf("turn content = %q", turn.Content) + } +} + +func TestServeTurnForwardsKnobs(t *testing.T) { + f := newFakeLLM(t, textMsg("ok")) + cfg := testConfig(t, f.srv.URL) + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + temp := 0.7 + if _, err := cond.ServeTurn(context.Background(), + []llm.Message{{Role: "user", Content: "hi"}}, "glm-5.3", + ServeTurnOpts{MaxTokens: 512, Temperature: &temp}); err != nil { + t.Fatalf("ServeTurn: %v", err) + } + req := f.request(0) + if req.MaxTokens != 512 { + t.Errorf("max_tokens = %d, want 512 forwarded", req.MaxTokens) + } + if req.Temperature == nil || *req.Temperature != 0.7 { + t.Errorf("temperature = %v, want 0.7 forwarded", req.Temperature) + } + if len(req.Tools) != 0 { + t.Errorf("tools = %+v, want none on the serve path", req.Tools) + } +} + +func TestRouterAccessor(t *testing.T) { + cfg := testConfig(t, "http://unused") + cond, err := New(cfg, os.Stdout) + if err != nil { + t.Fatal(err) + } + classes := cond.Router().Classes() + want := []string{"code", "primary", "study"} + if fmt.Sprint(classes) != fmt.Sprint(want) { + t.Errorf("Router().Classes() = %v, want %v", classes, want) + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 70068e5..d39af13 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -66,3 +66,14 @@ func (r *Router) Tiers() []string { sort.Strings(out) return out } + +// Classes returns the sorted task classes known to the router (the +// `harness serve` model catalog is derived from it: one model per class). +func (r *Router) Classes() []string { + out := make([]string, 0, len(r.classes)) + for k := range r.classes { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 2517de8..54ceea5 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -76,3 +76,13 @@ func TestNewRouterValidation(t *testing.T) { t.Errorf("empty tiers must error") } } + +func TestClasses(t *testing.T) { + // The serve front door builds its catalog from the sorted class list; + // it must be complete and ordered for stable /v1/models output. + got := testRouter(t).Classes() + want := "code primary read review study" + if strings.Join(got, " ") != want { + t.Errorf("Classes() = %v, want %v", got, want) + } +}