Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ go.work.sum
# on disk and the compiler still reads it. `find . -name '* [0-9]*'` finds
# them when a build fails for no apparent reason.
* [0-9]*

/dist/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/mindsdb/yolocoder/main/install.ps1 | iex
```

## Muse + Jev build

See [the Muse + Jev guide](docs/jev-muse.md) for build instructions and model configuration.

## Develop

Requires Go 1.24 or newer.
Expand Down
25 changes: 25 additions & 0 deletions docs/jev-muse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Muse + Jev build

Use Jev to select initial context and route small UI edits to a bounded Muse conversation. Other coding tasks use the configured primary model, with explicit completion checks and bounded recovery from supported provider errors.

## Build and configure

Requires Go 1.24+, Node.js 22 for web projects, and a MindsHub account serving `muse-spark-1-3` and `jev-1.13.0`.

```sh
./scripts/build-jev-muse.sh
YOLOCODER_NO_AUTOUPDATE=1 ./dist/yolocoder-jev-muse config
YOLOCODER_NO_AUTOUPDATE=1 ./dist/yolocoder-jev-muse model
```

Configure MindsHub and select **muse-spark-1-3** as the primary coding model. The build selects **jev-1.13.0** for routing and **muse-spark-1-3** for the small-edit writer. The model picker changes the primary model; the small-edit model is set at build time.

From an empty app directory or an existing YoloCoder web project, launch the absolute path to the built binary with `--web`. The normal CLI also works. Keep `YOLOCODER_NO_AUTOUPDATE=1` when running this custom build so an update does not replace its compiled model configuration.

## Behaviour

Jev selects bounded initial context; uncertainty or an unavailable router lets the coding model obtain its own context. Small UI edits can use exact literal substitutions, checked for a unique match before the batch is written. The project's detected check still runs after edits.

Normal tasks finish only after an explicit completion signal, successful tool results, a complete response and the project's detected check. Rejected edits receive path and remaining-budget feedback. A bounded diagnostic check can help with an already-required continuation, and narrowly recognized provider errors have a finite recovery budget.

The Jev router takes precedence over `/preselect` in this build, avoiding two selectors per request. Plain builds still support `/preselect`; their normal completion and recovery improvements apply without the new router. Architecture notes, pattern-based reads, search pre-reading and rejected-patch logging remain available.
379 changes: 257 additions & 122 deletions internal/agent/agent.go

Large diffs are not rendered by default.

20 changes: 16 additions & 4 deletions internal/agent/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ func TestRunnerOverChatCompletions(t *testing.T) {
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
// Normal request timing follows the intact conversation in either
// dialect; inspect the original tool result immediately before it.
notes := timingNotes(t, body.Messages)
if len(notes) != 1 || body.Messages[len(body.Messages)-1].Content != notes[0] {
t.Fatal("missing or accumulated normal timing metadata")
}
body.Messages = body.Messages[:len(body.Messages)-1]
requests++
writer.Header().Set("Content-Type", "application/json")
reply := func(message chatMessage) {
Expand All @@ -214,16 +221,21 @@ func TestRunnerOverChatCompletions(t *testing.T) {
t.Fatalf("tool result did not carry the file: %q", content)
}
patch := "@index.html\n-<title>Old</title>\n+<title>New</title>\n"
arguments, _ := json.Marshal(map[string]string{"patch": patch})
arguments, _ := json.Marshal(map[string]any{"patch": patch, "response_comment_for_user": "", "task_complete": false})
reply(chatMessage{Role: "assistant", ToolCalls: []chatToolCall{{
ID: "call_2", Type: "function",
Function: chatCallFunction{Name: "apply_diff", Arguments: string(arguments)},
}}})
case 3:
// And the edit's own result comes back the same way.
// Preserve the edit's tool result before the diagnostic user message.
last := body.Messages[len(body.Messages)-1]
if last.Role != "tool" || last.ToolCallID != "call_2" {
t.Fatalf("last message = %+v, want the edit's result", last)
content, _ := last.Content.(string)
if last.Role != "user" || !strings.Contains(content, "Diagnostic checkpoint skipped") {
t.Fatalf("last message = %+v, want the honest skipped diagnostic", last)
}
tool := body.Messages[len(body.Messages)-2]
if tool.Role != "tool" || tool.ToolCallID != "call_2" {
t.Fatalf("preceding message = %+v, want the edit's result", tool)
}
reply(chatMessage{Role: "assistant", Content: "Retitle"})
default:
Expand Down
80 changes: 60 additions & 20 deletions internal/agent/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ func (client *Client) Dialect() string {
}

func (client *Client) create(ctx context.Context, request responseRequest) (responseEnvelope, error) {
return client.createWithRetry(ctx, request, nil)
}

// retry is a turn-owned, one-use permission, shared by compatibility fallbacks.
// Coding sessions supply it; routing and other callers stay unchanged.
func (client *Client) createWithRetry(ctx context.Context, request responseRequest, retry func(int) bool) (responseEnvelope, error) {
request.Model = client.model
body := any(request)
if client.chat {
Expand All @@ -176,23 +182,10 @@ func (client *Client) create(ctx context.Context, request responseRequest) (resp
// Authorization header), so it is safe to trace.
label := requestLabel(request)
debug.Log("REQUEST "+label, string(payload))
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.endpoint, bytes.NewReader(payload))
if err != nil {
return responseEnvelope{}, err
}
httpRequest.Header.Set("Authorization", "Bearer "+client.apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
response, err := client.http.Do(httpRequest)
if err != nil {
return responseEnvelope{}, fmt.Errorf("call LLM: %w", err)
}
defer response.Body.Close()
replyBody, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
response, replyBody, err := client.post(ctx, payload, label, retry)
if err != nil {
return responseEnvelope{}, err
}
debug.Log(fmt.Sprintf("RESPONSE %s (%s)", label, response.Status), string(replyBody))

// Check the status before parsing. Decoding first turned a 404 with
// an empty body into "decode LLM response: unexpected end of JSON
Expand All @@ -204,7 +197,7 @@ func (client *Client) create(ctx context.Context, request responseRequest) (resp
debug.Log("DIALECT", "the Responses API returned 404; switching to /v1/chat/completions")
client.chat = true
client.endpoint = chatEndpoint(client.baseURL)
return client.create(ctx, request)
return client.createWithRetry(ctx, request, retry)
}

// Some providers constrain generation to a tool call whenever tools
Expand All @@ -216,7 +209,7 @@ func (client *Client) create(ctx context.Context, request responseRequest) (resp
debug.Log("TOOLS", "the provider demanded a tool call the model would not make; asking again without tools")
request.Tools = nil
request.ToolChoice = ""
return client.create(ctx, request)
return client.createWithRetry(ctx, request, retry)
}

// Some providers won't take tool definitions and a JSON schema in the
Expand All @@ -225,7 +218,7 @@ func (client *Client) create(ctx context.Context, request responseRequest) (resp
if client.chat && !client.dropSchema && rejectsSchemaWithTools(replyBody) {
debug.Log("SCHEMA", "the provider rejects response_format alongside tools; describing the shape in the instructions instead")
client.dropSchema = true
return client.create(ctx, request)
return client.createWithRetry(ctx, request, retry)
}

envelope, parseErr := client.decode(replyBody)
Expand All @@ -238,6 +231,54 @@ func (client *Client) create(ctx context.Context, request responseRequest) (resp
return envelope, nil
}

// post reuses the exact encoded request and context after an eligible error.
// Transport/read errors and successful responses never grant a replay: delivery
// is ambiguous there, or the response may already contain usable tool output.
func (client *Client) post(ctx context.Context, payload []byte, label string, retry func(int) bool) (*http.Response, []byte, error) {
const responseLimit = 8 << 20
endpoint := client.endpoint
for {
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, nil, err
}
httpRequest.Header.Set("Authorization", "Bearer "+client.apiKey)
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("Accept", "application/json")
response, err := client.http.Do(httpRequest)
if err != nil {
return nil, nil, fmt.Errorf("call LLM: %w", err)
}
replyBody, err := io.ReadAll(io.LimitReader(response.Body, responseLimit))
response.Body.Close()
if err != nil {
return nil, nil, err
}
debug.Log(fmt.Sprintf("RESPONSE %s (%s)", label, response.Status), string(replyBody))
// Reaching the read cap may hide an incomplete body, so do not replay it.
eligible := response.StatusCode == http.StatusBadGateway || response.StatusCode == 524 ||
(response.StatusCode == http.StatusServiceUnavailable && client.model == "muse-spark-1-3" && musePolicyUnavailable(replyBody))
if eligible && len(replyBody) < responseLimit && ctx.Err() == nil && retry != nil && retry(response.StatusCode) {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("call LLM: %w", err)
}
continue
}
return response, replyBody, nil
}
}

func musePolicyUnavailable(body []byte) bool {
const message = "The model policy is temporarily unavailable. Please retry shortly."
if strings.TrimSpace(string(body)) == message {
return true
}
var reply struct {
Error *apiError `json:"error"`
}
return json.Unmarshal(body, &reply) == nil && reply.Error != nil && reply.Error.Message == message
}

// decode reads a reply in whichever dialect this client speaks, returning
// it in the Responses shape the rest of the agent works with.
func (client *Client) decode(body []byte) (responseEnvelope, error) {
Expand Down Expand Up @@ -316,9 +357,8 @@ func (response responseEnvelope) calls() []responseItem {
}

// cutOff reports whether the model ran out of output room before it
// finished, rather than deciding anything. The reply then carries no
// usable text — an empty message, or none at all — and the turn should
// ask again, shorter, instead of failing over it.
// finished, rather than deciding anything. An incomplete envelope may
// still contain text or tool calls; callers decide how to continue.
func (response responseEnvelope) cutOff() bool {
if response.IncompleteDetails == nil {
return false
Expand Down
20 changes: 9 additions & 11 deletions internal/agent/complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func applyWith(t *testing.T, session *changeSession, arguments map[string]any) s
if err != nil {
t.Fatal(err)
}
output, _ := session.applyDiff(responseItem{Name: "apply_diff", Arguments: string(encoded)})
output, _, _ := session.applyDiff(responseItem{Name: "apply_diff", Arguments: string(encoded)})
return output
}

Expand All @@ -34,34 +34,32 @@ func TestAFinishedEditEndsTheTurn(t *testing.T) {
session := sessionOn(t, "a.css", ".a {\n color: red;\n}\n")
applyWith(t, session, map[string]any{
"patch": "@a.css\n- color: red;\n+ color: blue;\n",
"turn_is_complete": true,
"task_complete": true,
"response_comment_for_user": "Made it blue.",
})
if session.closing != "Made it blue." {
t.Fatalf("closing = %q, so the turn would spend a round saying done", session.closing)
}
}

// A model that fills the reply but leaves the flag false is still
// finished — that was the only signal there was, and honouring it costs
// nothing.
func TestAReplyWithoutTheFlagStillEndsTheTurn(t *testing.T) {
// A closing note alone must not end an unfinished normal task.
func TestAReplyWithoutTheFlagDoesNotCompleteTheTask(t *testing.T) {
session := sessionOn(t, "a.css", ".a {\n color: red;\n}\n")
applyWith(t, session, map[string]any{
"patch": "@a.css\n- color: red;\n+ color: blue;\n",
"turn_is_complete": false,
"task_complete": false,
"response_comment_for_user": "Made it blue.",
})
if session.closing != "Made it blue." {
t.Fatalf("closing = %q", session.closing)
if session.complete {
t.Fatal("closing note completed an unfinished task")
}
}

func TestAnUnfinishedEditDoesNotEndTheTurn(t *testing.T) {
session := sessionOn(t, "a.css", ".a {\n color: red;\n}\n")
applyWith(t, session, map[string]any{
"patch": "@a.css\n- color: red;\n+ color: blue;\n",
"turn_is_complete": false,
"task_complete": false,
"response_comment_for_user": "",
})
if session.closing != "" {
Expand All @@ -74,7 +72,7 @@ func TestAFailedEditCannotEndTheTurn(t *testing.T) {
session := sessionOn(t, "a.css", ".a {\n color: red;\n}\n")
applyWith(t, session, map[string]any{
"patch": "@a.css\n- color: nowhere;\n+ color: blue;\n",
"turn_is_complete": true,
"task_complete": true,
"response_comment_for_user": "Made it blue.",
})
if session.closing != "" {
Expand Down
Loading
Loading