Details and log output for this agent run.
{"query": "DisallowUnknownFields"}
/project/internal/llm/streamer.go:70: decoder.DisallowUnknownFields() /project/internal/llm/types.go:210:// the streamer decodes with DisallowUnknownFields, so this must match
{"path": "."}
drwxrwxr-x 5 1000 1000 4096 Aug 24 19:10 . drwxr-xr-x 1 root root 4096 Aug 24 19:10 .. drwxrwxr-x 8 1000 1000 4096 Aug 24 19:10 .git -rw-rw-r-- 1 1000 1000 22 Aug 24 19:10 .gitignore drwxrwxr-x 3 1000 1000 4096 Aug 24 19:10 cmd -rw-rw-r-- 1 1000 1000 2628 Aug 24 19:10 go.mod -rw-rw-r-- 1 1000 1000 16578 Aug 24 19:10 go.sum drwxrwxr-x 11 1000 1000 4096 Aug 24 19:10 internal -rw-rw-r-- 1 1000 1000 1564 Aug 24 19:10 zoo.hcl.example
{"path": "internal/llm/streamer.go", "start_line": 1, "end_line": 120}
{"path":"internal/llm/streamer.go","content":"package llm\r\n\r\nimport (\r\n\t\"bufio\"\r\n\t\"bytes\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"net/http\"\r\n)\r\n\r\ntype Streamer struct {\r\n\tcompletion ChatCompletion\r\n\tresp *http.Response\r\n\tscanner *bufio.Scanner\r\n\teof bool\r\n}\r\n\r\nfunc NewStreamer(resp *http.Response) (*Streamer, error) {\r\n\tif resp.StatusCode != http.StatusOK {\r\n\t\tbody, _ := io.ReadAll(resp.Body)\r\n\t\tresp.Body.Close()\r\n\t\treturn nil, fmt.Errorf(\"status: %d: %s\", resp.StatusCode, body)\r\n\t}\r\n\r\n\treturn \u0026Streamer{\r\n\t\tresp: resp,\r\n\t\tscanner: bufio.NewScanner(resp.Body),\r\n\t}, nil\r\n}\r\n\r\n// Get returns the accumulated completion after merging in the next SSE\r\n// chunk. Callers should keep calling Get until it returns io.EOF.\r\nfunc (s *Streamer) Get() (*ChatCompletion, error) {\r\n\tif s.eof {\r\n\t\treturn nil, io.EOF\r\n\t}\r\n\r\n\tprefix := []byte(\"data: \")\r\n\r\n\tvar buf []byte\r\n\r\n\tfor s.scanner.Scan() {\r\n\t\tif err := s.scanner.Err(); err != nil {\r\n\t\t\treturn nil, err\r\n\t\t}\r\n\r\n\t\tbuf = bytes.TrimSpace(s.scanner.Bytes())\r\n\r\n\t\tif len(buf) == 0 {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif bytes.HasPrefix(buf, prefix) {\r\n\t\t\tbreak\r\n\t\t}\r\n\t}\r\n\r\n\tbuf = bytes.TrimPrefix(buf, prefix)\r\n\r\n\tif bytes.Equal(buf, []byte(\"[DONE]\")) {\r\n\t\ts.resp.Body.Close()\r\n\t\ts.eof = true\r\n\t\treturn nil, io.EOF\r\n\t}\r\n\r\n\tvar completion ChatCompletion\r\n\r\n\tdecoder := json.NewDecoder(bytes.NewReader(buf))\r\n\tdecoder.DisallowUnknownFields()\r\n\r\n\tif err := decoder.Decode(\u0026completion); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\r\n\t}\r\n\r\n\ts.completion.Merge(\u0026completion)\r\n\r\n\treturn \u0026s.completion, nil\r\n}\r","start_line":1,"end_line":79,"total_lines":79,"truncated":false}
{"path": "internal/llm/types.go", "start_line": 190, "end_line": 230}
{"path":"internal/llm/types.go","content":"}\r\n\r\nfunc (c *Choice) String() string {\r\n\treturn fmt.Sprintf(\"Index: %d, Role: %s, FinishReason: %v, Delta: %s, Message: %s\",\r\n\t\tc.Index, c.Role, c.FinishReason, c.Delta.String(), c.Message.String())\r\n}\r\n\r\nfunc (c *Choice) Merge(next *Choice) {\r\n\tc.Message.Merge(\u0026next.Delta)\r\n\r\n\tif next.FinishReason != \"\" {\r\n\t\tc.FinishReason = next.FinishReason\r\n\t}\r\n\r\n\tif next.Role != \"\" {\r\n\t\tc.Role = next.Role\r\n\t}\r\n}\r\n\r\n// Timings mirrors llama.cpp's /chat/completions timing block exactly;\r\n// the streamer decodes with DisallowUnknownFields, so this must match\r\n// the server's JSON shape field-for-field.\r\ntype Timings struct {\r\n\tCacheN int `json:\"cache_n\"`\r\n\tDraftN int `json:\"draft_n\"`\r\n\tDraftNAccepted int `json:\"draft_n_accepted\"`\r\n\tPromptN int `json:\"prompt_n\"`\r\n\tPromptMs float64 `json:\"prompt_ms\"`\r\n\tPromptPerTokenMs float64 `json:\"prompt_per_token_ms\"`\r\n\tPromptPerSecond float64 `json:\"prompt_per_second\"`\r\n\tPredictedN int `json:\"predicted_n\"`\r\n\tPredictedMs float64 `json:\"predicted_ms\"`\r\n\tPredictedPerTokenMs float64 `json:\"predicted_per_token_ms\"`\r\n\tPredictedPerSecond float64 `json:\"predicted_per_second\"`\r\n}\r\n\r\ntype ChatCompletion struct {\r\n\tChoices []Choice `json:\"choices\"`\r\n\tCreated int64 `json:\"created\"`\r\n\tId string `json:\"id\"`\r\n\tModel string `json:\"model\"`\r","start_line":190,"end_line":230,"total_lines":267,"truncated":true}
{"path": "internal/llm/types.go", "start_line": 1, "end_line": 190}
{"path":"internal/llm/types.go","content":"// Package llm is an OpenAI-compatible chat completions client with\r\n// streaming and tool-call support, ported from ../a's llamacpp package.\r\npackage llm\r\n\r\nimport \"fmt\"\r\n\r\ntype Message struct {\r\n\tContent string `json:\"content\"`\r\n\tRole string `json:\"role\"`\r\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\r\n\tToolCalls []ToolCall `json:\"tool_calls,omitempty\"`\r\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\r\n\tName string `json:\"name,omitempty\"`\r\n}\r\n\r\nfunc (m *Message) String() string {\r\n\tstr := fmt.Sprintf(\"Role: %s, Content: %s ReasoningContent: %s\", m.Role, m.Content, m.ReasoningContent)\r\n\r\n\tfor _, call := range m.ToolCalls {\r\n\t\tstr += fmt.Sprintf(\"\\n ToolCall: %s\", call.String())\r\n\t}\r\n\r\n\treturn str\r\n}\r\n\r\n// Merge folds a streamed delta into the accumulated message.\r\nfunc (m *Message) Merge(next *Message) {\r\n\tm.Content += next.Content\r\n\r\n\tif next.Role != \"\" {\r\n\t\tm.Role = next.Role\r\n\t}\r\n\r\n\tm.ReasoningContent += next.ReasoningContent\r\n\r\n\tminLength := 0\r\n\tfor _, choice := range next.ToolCalls {\r\n\t\tif choice.Index+1 \u003e minLength {\r\n\t\t\tminLength = choice.Index + 1\r\n\t\t}\r\n\t}\r\n\r\n\tif len(m.ToolCalls) \u003c minLength {\r\n\t\tnewToolCalls := make([]ToolCall, minLength)\r\n\t\tcopy(newToolCalls, m.ToolCalls)\r\n\t\tm.ToolCalls = newToolCalls\r\n\t}\r\n\r\n\tfor _, nextChoice := range next.ToolCalls {\r\n\t\tm.ToolCalls[nextChoice.Index].Merge(\u0026nextChoice)\r\n\t}\r\n}\r\n\r\ntype FunctionCall struct {\r\n\tName string `json:\"name\"`\r\n\tArguments string `json:\"arguments\"`\r\n}\r\n\r\ntype ToolCall struct {\r\n\tType string `json:\"type\"`\r\n\tId string `json:\"id\"`\r\n\tIndex int `json:\"index\"`\r\n\tName string `json:\"name\"`\r\n\tFunctionCall FunctionCall `json:\"function\"`\r\n}\r\n\r\nfunc (t *ToolCall) String() string {\r\n\treturn fmt.Sprintf(\"Type: %s, Id: %s, Index: %d, Name: %s, FunctionCall: {Name: %s, Arguments: %s}\",\r\n\t\tt.Type, t.Id, t.Index, t.Name, t.FunctionCall.Name, t.FunctionCall.Arguments)\r\n}\r\n\r\nfunc (t *ToolCall) Merge(next *ToolCall) {\r\n\tif next.Type != \"\" {\r\n\t\tt.Type = next.Type\r\n\t}\r\n\r\n\tif next.Id != \"\" {\r\n\t\tt.Id = next.Id\r\n\t}\r\n\r\n\tif next.Name != \"\" {\r\n\t\tt.Name = next.Name\r\n\t}\r\n\r\n\tt.Index = next.Index\r\n\r\n\tif next.FunctionCall.Name != \"\" {\r\n\t\tt.FunctionCall.Name = next.FunctionCall.Name\r\n\t}\r\n\r\n\tt.FunctionCall.Arguments += next.FunctionCall.Arguments\r\n}\r\n\r\ntype Property struct {\r\n\tType string `json:\"type\"`\r\n\tDescription string `json:\"description,omitempty\"`\r\n\tEnum []string `json:\"enum,omitempty\"`\r\n\tItems *Property `json:\"items,omitempty\"`\r\n}\r\n\r\ntype Parameters struct {\r\n\tType string `json:\"type\"` // object\r\n\tProperties map[string]Property `json:\"properties,omitempty\"`\r\n\tRequired []string `json:\"required,omitempty\"`\r\n}\r\n\r\ntype Function struct {\r\n\tName string `json:\"name\"`\r\n\tDescription string `json:\"description,omitempty\"`\r\n\tParameters Parameters `json:\"parameters,omitempty\"`\r\n\tStrict bool `json:\"strict\"`\r\n}\r\n\r\ntype Tool struct {\r\n\tType string `json:\"type\"`\r\n\tFunction Function `json:\"function\"`\r\n}\r\n\r\nfunc NewTool(name, description string) Tool {\r\n\treturn Tool{\r\n\t\tType: \"function\",\r\n\t\tFunction: Function{\r\n\t\t\tName: name,\r\n\t\t\tDescription: description,\r\n\t\t\tStrict: true,\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc (t *Tool) AddProperty(name string, property Property, required bool) *Tool {\r\n\tif t.Function.Parameters.Properties == nil {\r\n\t\tt.Function.Parameters.Properties = make(map[string]Property)\r\n\t} else if _, exists := t.Function.Parameters.Properties[name]; exists {\r\n\t\tpanic(\"property already exists: \" + name)\r\n\t}\r\n\r\n\tt.Function.Parameters.Properties[name] = property\r\n\r\n\tif required {\r\n\t\tt.Function.Parameters.Required = append(t.Function.Parameters.Required, name)\r\n\t}\r\n\r\n\treturn t\r\n}\r\n\r\nfunc (t *Tool) AddStringProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddBooleanProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"boolean\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddIntegerProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"integer\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddEnumProperty(name, description string, values []string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description, Enum: values}, required)\r\n}\r\n\r\nfunc (t *Tool) AddArrayProperty(name, itemType, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{\r\n\t\tType: \"array\",\r\n\t\tDescription: description,\r\n\t\tItems: \u0026Property{Type: itemType},\r\n\t}, required)\r\n}\r\n\r\nfunc (t *Tool) AddStringArrayProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddArrayProperty(name, \"string\", description, required)\r\n}\r\n\r\ntype ChatCompletionRequest struct {\r\n\tModel string `json:\"model\"`\r\n\tMessages []Message `json:\"messages,omitempty\"`\r\n\tStream bool `json:\"stream\"`\r\n\tTools []Tool `json:\"tools,omitempty\"`\r\n\tSeed int `json:\"seed,omitempty\"`\r\n\tTemperature float64 `json:\"temperature,omitempty\"`\r\n\tTopP float64 `json:\"top_p,omitempty\"`\r\n}\r\n\r\ntype Choice struct {\r\n\tDelta Message `json:\"delta\"`\r\n\tFinishReason string `json:\"finish_reason,omitempty\"`\r\n\tIndex int `json:\"index\"`\r\n\tMessage Message `json:\"message\"`\r\n\tRole string `json:\"role\"`\r\n}\r","start_line":1,"end_line":190,"total_lines":267,"truncated":true}
{"path": "internal/llm/types.go", "start_line": 230, "end_line": 267}
{"path":"internal/llm/types.go","content":"\tModel string `json:\"model\"`\r\n\tSystemFingerprint string `json:\"system_fingerprint\"`\r\n\tObject string `json:\"object\"`\r\n\tTimings Timings `json:\"timings\"`\r\n}\r\n\r\nfunc (c *ChatCompletion) String() string {\r\n\ts := fmt.Sprintf(\"Id: %s, Model: %s, Created: %d, SystemFingerprint: %s, Object: %s\\n\",\r\n\t\tc.Id, c.Model, c.Created, c.SystemFingerprint, c.Object)\r\n\r\n\tfor _, choice := range c.Choices {\r\n\t\ts += fmt.Sprintf(\" Choice: %s\\n\", choice.String())\r\n\t}\r\n\r\n\treturn s\r\n}\r\n\r\n// Merge folds a streamed chunk into the accumulated completion.\r\nfunc (c *ChatCompletion) Merge(next *ChatCompletion) {\r\n\tminLength := 0\r\n\tfor _, choice := range next.Choices {\r\n\t\tif choice.Index+1 \u003e minLength {\r\n\t\t\tminLength = choice.Index + 1\r\n\t\t}\r\n\t}\r\n\r\n\tif len(c.Choices) \u003c minLength {\r\n\t\tnewChoices := make([]Choice, minLength)\r\n\t\tcopy(newChoices, c.Choices)\r\n\t\tc.Choices = newChoices\r\n\t}\r\n\r\n\tfor _, nextChoice := range next.Choices {\r\n\t\tc.Choices[nextChoice.Index].Merge(\u0026nextChoice)\r\n\t}\r\n\r\n\tc.Timings = next.Timings\r\n}\r","start_line":230,"end_line":267,"total_lines":267,"truncated":false}
{"path": "internal", "recursive": true}
/project/internal/agentrun /project/internal/agentrun/clone.go /project/internal/agentrun/context.go /project/internal/agentrun/docker.go /project/internal/agentrun/docker_integration_test.go /project/internal/agentrun/forgejo.go /project/internal/agentrun/loop.go /project/internal/agentrun/roster.go /project/internal/agentrun/roster_test.go /project/internal/agentrun/run.go /project/internal/agentrun/seed_test.go /project/internal/agentrun/system.md /project/internal/config /project/internal/config/config.go /project/internal/config/config_test.go /project/internal/forgejo /project/internal/forgejo/client.go /project/internal/forgejo/client_test.go /project/internal/forgejo/event.go /project/internal/forgejo/poll.go /project/internal/forgejo/watcher.go /project/internal/forgejo/webhook.go /project/internal/forgejo/webhook_test.go /project/internal/livelog /project/internal/livelog/hub.go /project/internal/livelog/hub_test.go /project/internal/llm /project/internal/llm/client.go /project/internal/llm/streamer.go /project/internal/llm/streamer_test.go /project/internal/llm/types.go /project/internal/scheduler /project/internal/scheduler/scheduler.go /project/internal/scheduler/scheduler_test.go /project/internal/store /project/internal/store/schema.sql /project/internal/store/store.go /project/internal/store/store_test.go /project/internal/tools /project/internal/tools/bash.go /project/internal/tools/context.go /project/internal/tools/edit-file.go /project/internal/tools/forgejo.go /project/internal/tools/git.go /project/internal/tools/grep_search.go /project/internal/tools/list_files.go /project/internal/tools/move_file.go /project/internal/tools/read_file.go /project/internal/tools/registry.go /project/internal/tools/remove_file.go /project/internal/tools/shell.go /project/internal/tools/tools_test.go /project/internal/tools/write_file.go /project/internal/web /project/internal/web/templates.go /project/internal/web/web.go /project/internal/web/web_test.go
{"path": "internal/llm/streamer_test.go", "start_line": 1, "end_line": 200}
{"path":"internal/llm/streamer_test.go","content":"package llm\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"io\"\r\n\t\"net/http\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc sseChunk(t *testing.T, delta Message, finish string) string {\r\n\tt.Helper()\r\n\r\n\tbody, err := json.Marshal(ChatCompletion{\r\n\t\tChoices: []Choice{{Delta: delta, FinishReason: finish}},\r\n\t})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\treturn \"data: \" + string(body) + \"\\n\\n\"\r\n}\r\n\r\nfunc newTestResponse(body string) *http.Response {\r\n\treturn \u0026http.Response{\r\n\t\tStatusCode: http.StatusOK,\r\n\t\tBody: io.NopCloser(strings.NewReader(body)),\r\n\t}\r\n}\r\n\r\nfunc TestStreamerMergesChunksAndStops(t *testing.T) {\r\n\tsse := sseChunk(t, Message{Content: \"Hel\", Role: \"assistant\"}, \"\") +\r\n\t\tsseChunk(t, Message{Content: \"lo\"}, \"stop\") +\r\n\t\t\"data: [DONE]\\n\\n\"\r\n\r\n\ts, err := NewStreamer(newTestResponse(sse))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tvar last *ChatCompletion\r\n\tfor {\r\n\t\tc, err := s.Get()\r\n\t\tif err == io.EOF {\r\n\t\t\tbreak\r\n\t\t}\r\n\t\tif err != nil {\r\n\t\t\tt.Fatal(err)\r\n\t\t}\r\n\t\tlast = c\r\n\t}\r\n\r\n\tif last == nil {\r\n\t\tt.Fatal(\"expected at least one completion\")\r\n\t}\r\n\tif len(last.Choices) != 1 {\r\n\t\tt.Fatalf(\"expected 1 choice, got %d\", len(last.Choices))\r\n\t}\r\n\tif last.Choices[0].Message.Content != \"Hello\" {\r\n\t\tt.Fatalf(\"expected merged content %q, got %q\", \"Hello\", last.Choices[0].Message.Content)\r\n\t}\r\n\tif last.Choices[0].FinishReason != \"stop\" {\r\n\t\tt.Fatalf(\"expected finish_reason stop, got %q\", last.Choices[0].FinishReason)\r\n\t}\r\n\tif last.Choices[0].Message.Role != \"assistant\" {\r\n\t\tt.Fatalf(\"expected role to stick from first chunk, got %q\", last.Choices[0].Message.Role)\r\n\t}\r\n}\r\n\r\nfunc TestStreamerNonOKStatus(t *testing.T) {\r\n\tresp := newTestResponse(\"boom\")\r\n\tresp.StatusCode = http.StatusInternalServerError\r\n\r\n\t_, err := NewStreamer(resp)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for non-200 response\")\r\n\t}\r\n}\r\n\r\nfunc TestToolCallMerge(t *testing.T) {\r\n\tsse := sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, Id: \"call1\", FunctionCall: FunctionCall{Name: \"bash\", Arguments: `{\"command\":`}}}}, \"\") +\r\n\t\tsseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, FunctionCall: FunctionCall{Arguments: `\"ls\"}`}}}}, \"tool_calls\") +\r\n\t\t\"data: [DONE]\\n\\n\"\r\n\r\n\ts, err := NewStreamer(newTestResponse(sse))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tvar last *ChatCompletion\r\n\tfor {\r\n\t\tc, err := s.Get()\r\n\t\tif err == io.EOF {\r\n\t\t\tbreak\r\n\t\t}\r\n\t\tif err != nil {\r\n\t\t\tt.Fatal(err)\r\n\t\t}\r\n\t\tlast = c\r\n\t}\r\n\r\n\tcalls := last.Choices[0].Message.ToolCalls\r\n\tif len(calls) != 1 {\r\n\t\tt.Fatalf(\"expected 1 tool call, got %d\", len(calls))\r\n\t}\r\n\tif calls[0].Id != \"call1\" || calls[0].FunctionCall.Name != \"bash\" {\r\n\t\tt.Fatalf(\"unexpected tool call: %+v\", calls[0])\r\n\t}\r\n\tif calls[0].FunctionCall.Arguments != `{\"command\":\"ls\"}` {\r\n\t\tt.Fatalf(\"expected merged arguments, got %q\", calls[0].FunctionCall.Arguments)\r\n\t}\r\n}\r","start_line":1,"end_line":112,"total_lines":112,"truncated":false}
{"path": "internal/llm/client.go", "start_line": 1, "end_line": 100}
{"path":"internal/llm/client.go","content":"package llm\r\n\r\nimport (\r\n\t\"bytes\"\r\n\t\"context\"\r\n\t\"encoding/json\"\r\n\t\"net/http\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\ntype Client struct {\r\n\tendpoint string\r\n\ttoken string\r\n\tmodel string\r\n\r\n\thttpClient *http.Client\r\n}\r\n\r\n// NewClient builds a Client from a configured llm block.\r\nfunc NewClient(cfg config.LLM) *Client {\r\n\treturn \u0026Client{\r\n\t\tendpoint: cfg.OpenAI,\r\n\t\ttoken: cfg.Token,\r\n\t\tmodel: cfg.Model,\r\n\t\thttpClient: http.DefaultClient,\r\n\t}\r\n}\r\n\r\nfunc (c *Client) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*Streamer, error) {\r\n\treq2 := *req\r\n\r\n\tif req2.Model == \"\" {\r\n\t\treq2.Model = c.model\r\n\t}\r\n\r\n\treq2.Stream = true\r\n\treq2.Seed = 5\r\n\treq2.Temperature = 0.6\r\n\treq2.TopP = 0.95\r\n\r\n\tbody, err := json.Marshal(req2)\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\t// Bound to ctx so a stalled connection or silent server (headers or\r\n\t// stream body never arriving) is unblocked by the run's deadline\r\n\t// instead of hanging the goroutine forever — http.DefaultClient has\r\n\t// no timeout of its own.\r\n\tr, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+\"/chat/completions\", bytes.NewBuffer(body))\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tr.Header.Set(\"Authorization\", \"Bearer \"+c.token)\r\n\tr.Header.Set(\"Content-Type\", \"application/json\")\r\n\r\n\tresp, err := c.httpClient.Do(r)\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\treturn NewStreamer(resp)\r\n}\r","start_line":1,"end_line":65,"total_lines":65,"truncated":false}
{"path": "internal/llm/streamer.go", "find": "\tvar completion ChatCompletion\n\n\tdecoder := json.NewDecoder(bytes.NewReader(buf))\n\tdecoder.DisallowUnknownFields()\n\n\tif err := decoder.Decode(&completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}", "replace": "\tvar completion ChatCompletion\n\n\t// Unknown fields are tolerated on purpose: servers (e.g. newer\n\t// llama.cpp builds) may add fields we don't know about yet, and\n\t// failing on them would break compatibility.\n\tif err := json.Unmarshal(buf, &completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}"}
1 occurrences replaced
{"path": "internal/llm/types.go", "find": "// Timings mirrors llama.cpp's /chat/completions timing block exactly;\n// the streamer decodes with DisallowUnknownFields, so this must match\n// the server's JSON shape field-for-field.", "replace": "// Timings mirrors llama.cpp's /chat/completions timing block. The\n// streamer tolerates unknown fields, so extra fields from the server\n// are simply ignored."}
1 occurrences replaced
{"path": "internal/llm/streamer_test.go", "find": "func TestToolCallMerge(t *testing.T) {", "replace": "func TestStreamerToleratesUnknownFields(t *testing.T) {\n\t// Servers may add fields we don't know about; decoding must not fail.\n\tchunk := `data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\",\"new_field\":\"boom\"},\"finish_reason\":null,\"extra\":\"y\"}],\"timings\":{\"prompt_n\":1,\"brand_new_timing\":42},\"surprise\":true}\n\n`\n\n\ts, err := NewStreamer(newTestResponse(chunk))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tc, err := s.Get()\n\tif err != nil {\n\t\tt.Fatalf(\"expected unknown fields to be tolerated, got error: %v\", err)\n\t}\n\tif c.Choices[0].Message.Content != \"hi\" {\n\t\tt.Fatalf(\"expected content %q, got %q\", \"hi\", c.Choices[0].Message.Content)\n\t}\n}\n\nfunc TestToolCallMerge(t *testing.T) {"}
1 occurrences replaced
{"command": "cd /project && go build ./... && go test ./internal/llm/ -v -run TestStreamer 2>&1 | tail -30"}
go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading github.com/google/go-cmp v0.7.0 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec === RUN TestStreamerMergesChunksAndStops --- PASS: TestStreamerMergesChunksAndStops (0.00s) === RUN TestStreamerNonOKStatus --- PASS: TestStreamerNonOKStatus (0.00s) === RUN TestStreamerToleratesUnknownFields --- PASS: TestStreamerToleratesUnknownFields (0.00s) PASS ok github.com/abrander/zoo/internal/llm 0.003s
{"command": "cd /project && go vet ./internal/llm/ && go test ./... 2>&1 | tail -20 && grep -rn \"DisallowUnknownFields\" . --include=\"*.go\" || echo \"no DisallowUnknownFields references left\"", "timeout": 300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.004s ok github.com/abrander/zoo/internal/config 0.004s ok github.com/abrander/zoo/internal/forgejo 0.005s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.209s ok github.com/abrander/zoo/internal/store 1.869s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 0.810s no DisallowUnknownFields references left
{"subcommand": "add", "args": ["-A"]}
{"subcommand": "diff", "args": ["--cached"]}
diff --git a/internal/llm/streamer.go b/internal/llm/streamer.go
index b28b2eb..5bb1f7d 100644
--- a/internal/llm/streamer.go
+++ b/internal/llm/streamer.go
@@ -66,10 +66,10 @@ func (s *Streamer) Get() (*ChatCompletion, error) {
var completion ChatCompletion
- decoder := json.NewDecoder(bytes.NewReader(buf))
- decoder.DisallowUnknownFields()
-
- if err := decoder.Decode(&completion); err != nil {
+ // Unknown fields are tolerated on purpose: servers (e.g. newer
+ // llama.cpp builds) may add fields we don't know about yet, and
+ // failing on them would break compatibility.
+ if err := json.Unmarshal(buf, &completion); err != nil {
return nil, fmt.Errorf("decode completion chunk: %w: %s", err, buf)
}
diff --git a/internal/llm/streamer_test.go b/internal/llm/streamer_test.go
index f712a84..41c793e 100644
--- a/internal/llm/streamer_test.go
+++ b/internal/llm/streamer_test.go
@@ -77,6 +77,26 @@ func TestStreamerNonOKStatus(t *testing.T) {
}
}
+func TestStreamerToleratesUnknownFields(t *testing.T) {
+ // Servers may add fields we don't know about; decoding must not fail.
+ chunk := `data: {"id":"x","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hi","new_field":"boom"},"finish_reason":null,"extra":"y"}],"timings":{"prompt_n":1,"brand_new_timing":42},"surprise":true}
+
+`
+
+ s, err := NewStreamer(newTestResponse(chunk))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := s.Get()
+ if err != nil {
+ t.Fatalf("expected unknown fields to be tolerated, got error: %v", err)
+ }
+ if c.Choices[0].Message.Content != "hi" {
+ t.Fatalf("expected content %q, got %q", "hi", c.Choices[0].Message.Content)
+ }
+}
+
func TestToolCallMerge(t *testing.T) {
sse := sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, Id: "call1", FunctionCall: FunctionCall{Name: "bash", Arguments: `{"command":`}}}}, "") +
sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, FunctionCall: FunctionCall{Arguments: `"ls"}`}}}}, "tool_calls") +
diff --git a/internal/llm/types.go b/internal/llm/types.go
index 276fe90..5516978 100644
--- a/internal/llm/types.go
+++ b/internal/llm/types.go
@@ -206,9 +206,9 @@ func (c *Choice) Merge(next *Choice) {
}
}
-// Timings mirrors llama.cpp's /chat/completions timing block exactly;
-// the streamer decodes with DisallowUnknownFields, so this must match
-// the server's JSON shape field-for-field.
+// Timings mirrors llama.cpp's /chat/completions timing block. The
+// streamer tolerates unknown fields, so extra fields from the server
+// are simply ignored.
type Timings struct {
CacheN int `json:"cache_n"`
DraftN int `json:"draft_n"`
{"subcommand": "commit", "args": ["-m", "llm: stop using DisallowUnknownFields when decoding stream chunks"]}
[issue-45-greg dd4a669] llm: stop using DisallowUnknownFields when decoding stream chunks 3 files changed, 27 insertions(+), 7 deletions(-)
{"subcommand": "push", "args": ["origin", "issue-45-greg"]}
To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-45-greg -> issue-45-greg
{"head": "issue-45-greg", "base": "main", "title": "llm: remove DisallowUnknownFields usage", "body": "Closes #45.\n\nThe SSE streamer in `internal/llm` decoded each completion chunk with a strict JSON decoder (`DisallowUnknownFields`), so any field the server added that we didn't model would fail the whole stream. To be more compatible, unknown fields are now simply ignored.\n\nChanges:\n- `internal/llm/streamer.go`: drop `decoder.DisallowUnknownFields()`; decode with `json.Unmarshal` (equivalent, no strictness) and document why unknown fields are tolerated.\n- `internal/llm/types.go`: update the now-stale `Timings` comment that claimed the shape must match the server field-for-field.\n- `internal/llm/streamer_test.go`: add `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build ./...`, `go vet`, and the full test suite pass."}
Pull request opened
{"body": "Done — removed the `DisallowUnknownFields` usage so the client stays compatible with servers that add fields we don't model yet.\n\nWhat changed (branch `issue-45-greg`, PR opened):\n- `internal/llm/streamer.go` — the SSE streamer was decoding each chunk with a strict decoder, so any unknown field would fail the whole stream. It now uses `json.Unmarshal`, which ignores unknown fields.\n- `internal/llm/types.go` — updated the stale `Timings` comment that claimed the shape had to match the server field-for-field.\n- `internal/llm/streamer_test.go` — added a `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build`, `go vet`, and the full test suite all pass."}
Comment posted