loop: shared bounded-turn core accepts history + knobs (ServeTurn)
Turn machinery refactored so the upcoming serve front door reuses it
instead of copying it: the round loop is now runTurn over arbitrary
message history with an optional tool palette and forwarded max_tokens/
temperature. The serve path (ServeTurn) is stateless chat over client
history with tools hard-off: a tool call the model produces anyway is
refused as a tool result, never executed. Also exposes the model router
(so serve can build its catalog) and a sorted class list; llm requests
can now carry temperature.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
This commit is contained in:
@@ -50,6 +50,9 @@ type ChatRequest struct {
|
|||||||
Messages []Message `json:"messages"`
|
Messages []Message `json:"messages"`
|
||||||
Tools []Tool `json:"tools,omitempty"`
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
MaxTokens int `json:"max_tokens,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 {
|
type ChatResponse struct {
|
||||||
|
|||||||
+89
-13
@@ -153,20 +153,51 @@ type TurnResult struct {
|
|||||||
StopReason string
|
StopReason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// turn runs the bounded LLM loop: request, execute tool calls, repeat until
|
// turn builds the single-task message shape and runs the shared bounded
|
||||||
// the model replies with plain content or the round bound is hit.
|
// 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) {
|
func (c *Conductor) turn(ctx context.Context, client *llm.Client, t task.Task, model string) (*TurnResult, error) {
|
||||||
msgs := []llm.Message{
|
return c.runTurn(ctx, client, turnParams{
|
||||||
{Role: "system", Content: c.systemPrompt()},
|
msgs: []llm.Message{
|
||||||
{Role: "user", Content: t.Prompt},
|
{Role: "system", Content: c.systemPrompt()},
|
||||||
}
|
{Role: "user", Content: t.Prompt},
|
||||||
var palette []llm.Tool
|
},
|
||||||
if c.bash != nil {
|
model: model,
|
||||||
palette = append(palette, c.bash.Definition())
|
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{}
|
res := &TurnResult{}
|
||||||
for round := 1; round <= c.cfg.Loop.MaxRounds; round++ {
|
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 {
|
if err != nil {
|
||||||
res.StopReason = "llm_error"
|
res.StopReason = "llm_error"
|
||||||
return res, err
|
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)
|
msgs = append(msgs, m)
|
||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
out, derr := c.execTool(ctx, tc)
|
var out string
|
||||||
res.ToolCalls++
|
if len(p.palette) == 0 {
|
||||||
if derr != nil {
|
out = "error: tools are disabled on this endpoint"
|
||||||
res.Denied++
|
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})
|
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
|
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
|
// execTool dispatches one tool call; the returned string is the model-facing
|
||||||
// result. The returned error is non-nil only for gate denials (counted).
|
// result. The returned error is non-nil only for gate denials (counted).
|
||||||
func (c *Conductor) execTool(ctx context.Context, call llm.ToolCall) (string, error) {
|
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)
|
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
|
// DispatchEvent is the events -> conductor wiring point: the `harness
|
||||||
// events` receiver hands every stored, actionable webhook to the conductor
|
// events` receiver hands every stored, actionable webhook to the conductor
|
||||||
// here. Phase 2b stub: it records what a real dispatch would do; the
|
// here. Phase 2b stub: it records what a real dispatch would do; the
|
||||||
|
|||||||
@@ -317,3 +317,132 @@ func TestOnceUnknownClassIsRoutingError(t *testing.T) {
|
|||||||
t.Fatalf("err = %v, want unknown-class error", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -66,3 +66,14 @@ func (r *Router) Tiers() []string {
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,3 +76,13 @@ func TestNewRouterValidation(t *testing.T) {
|
|||||||
t.Errorf("empty tiers must error")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user