mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c169b6119e | ||
|
|
a555b584fe | ||
|
|
cfd065b565 | ||
|
|
fb3a0bff44 |
No files matched your search
@@ -82,6 +82,18 @@ If you are testing before the first public release, build from source:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
## First Run
|
||||
|
||||
@@ -108,6 +108,36 @@ go build -o zero ./cmd/zero
|
||||
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
|
||||
```powershell
|
||||
go build -o zero.exe ./cmd/zero
|
||||
go build -o zero-windows-command-runner.exe ./cmd/zero-windows-command-runner
|
||||
go build -o zero-windows-sandbox-setup.exe ./cmd/zero-windows-sandbox-setup
|
||||
```
|
||||
|
||||
## Release Archive Format
|
||||
|
||||
Release archives are named:
|
||||
|
||||
@@ -385,6 +385,14 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -445,11 +453,12 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,57 +1000,6 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1076,38 +1025,6 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,3 +1163,66 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
+21
-2
@@ -103,6 +103,25 @@ func errResult(format string, a ...any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: "Error: " + fmt.Sprintf(format, a...)}
|
||||
}
|
||||
|
||||
// agentTypeProperty builds the schema for an agent-type parameter, constraining it
|
||||
// to the roster's currently registered types (the built-ins plus any user-registered
|
||||
// agents) via an enum so a model can't spawn or hand off to an unknown type — the
|
||||
// schema teaches the valid set up front instead of the model learning it from a
|
||||
// failed call. Falls back to a free-form string if the roster is unavailable or
|
||||
// empty (so the tool never advertises an empty, un-satisfiable enum).
|
||||
func agentTypeProperty(sw *Swarm, description string) tools.PropertySchema {
|
||||
prop := tools.PropertySchema{Type: "string", Description: description}
|
||||
if sw == nil {
|
||||
return prop
|
||||
}
|
||||
if registry := sw.Registry(); registry != nil {
|
||||
if types := registry.AgentTypes(); len(types) > 0 {
|
||||
prop.Enum = types
|
||||
}
|
||||
}
|
||||
return prop
|
||||
}
|
||||
|
||||
// ---- swarm_spawn -----------------------------------------------------------
|
||||
|
||||
type spawnTool struct {
|
||||
@@ -118,7 +137,7 @@ func (t *spawnTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"agent_type": {Type: "string", Description: "Roster agent type to spawn (e.g. teammate, subagent)."},
|
||||
"agent_type": agentTypeProperty(t.sw, "Roster agent type to spawn."),
|
||||
"task": {Type: "string", Description: "The focused task/briefing for the member."},
|
||||
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
|
||||
},
|
||||
@@ -353,7 +372,7 @@ func (t *handoffTool) Parameters() tools.Schema {
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{
|
||||
"task_id": {Type: "string", Description: "Task id to hand off."},
|
||||
"to_agent_type": {Type: "string", Description: "Roster agent type to take over."},
|
||||
"to_agent_type": agentTypeProperty(t.sw, "Roster agent type to take over."),
|
||||
"note": {Type: "string", Description: "Optional handoff note delivered to the new member's inbox."},
|
||||
"team": {Type: "string", Description: "Team the task belongs to. Defaults to \"default\"."},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ package swarm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -10,6 +11,46 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestSpawnAndHandoffAgentTypeEnumReflectsRoster(t *testing.T) {
|
||||
sw := newSwarmFor(t, newLauncher(okFor))
|
||||
want := sw.Registry().AgentTypes() // sorted built-ins: subagent, teammate
|
||||
|
||||
spawnEnum := (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
|
||||
if !slices.Equal(spawnEnum, want) {
|
||||
t.Fatalf("spawn agent_type enum = %v, want roster %v", spawnEnum, want)
|
||||
}
|
||||
if !slices.Contains(spawnEnum, "subagent") || !slices.Contains(spawnEnum, "teammate") {
|
||||
t.Fatalf("enum missing built-in roster types: %v", spawnEnum)
|
||||
}
|
||||
handoffEnum := (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
|
||||
if !slices.Equal(handoffEnum, want) {
|
||||
t.Fatalf("handoff to_agent_type enum = %v, want roster %v", handoffEnum, want)
|
||||
}
|
||||
|
||||
// A user-registered custom type must extend the enum (not a hardcoded list).
|
||||
if err := sw.Registry().Register(Definition{AgentType: "researcher"}); err != nil {
|
||||
t.Fatalf("register custom agent: %v", err)
|
||||
}
|
||||
spawnEnum = (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
|
||||
if !slices.Contains(spawnEnum, "researcher") {
|
||||
t.Fatalf("custom agent type not reflected in spawn enum: %v", spawnEnum)
|
||||
}
|
||||
handoffEnum = (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
|
||||
if !slices.Contains(handoffEnum, "researcher") {
|
||||
t.Fatalf("custom agent type not reflected in handoff enum: %v", handoffEnum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentTypePropertyFallsBackWithoutRoster(t *testing.T) {
|
||||
prop := agentTypeProperty(nil, "desc")
|
||||
if prop.Type != "string" || prop.Description != "desc" {
|
||||
t.Fatalf("unexpected property: %+v", prop)
|
||||
}
|
||||
if prop.Enum != nil {
|
||||
t.Fatalf("nil swarm must not advertise an (empty) enum, got %v", prop.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseRuneSafeTruncation(t *testing.T) {
|
||||
// 300 multi-byte runes: truncation must not split a rune (no invalid UTF-8).
|
||||
got := collapse(strings.Repeat("🚀", 300))
|
||||
|
||||
Reference in new issue
Block a user