Reading Balanov/uncut, a headless Go coding-agent harness that treats a truncated model response as a hard error, fsyncs every session line, and splits shell execution by build tag.
· 7 min read

How uncut Handles Truncated LLM Output and Crash-Safe Sessions in Go


uncut treats a finish_reason of length as an error instead of reporting a partial answer as success. That decision shapes the rest of the harness and makes the project a useful read if you want to see a small Go LLM tool loop with explicit failure modes.

uncut is a headless coding agent. No TUI, no daemon. A binary reads a prompt from args or stdin and talks to an OpenAI-compatible endpoint. The parts worth studying live in internal/harness.

Truncation is an error, not a result

The agent loop in internal/harness/run.go defines two sentinel errors:

var ErrProviderTruncated = errors.New("provider truncated the response")
var ErrMaxSteps = errors.New("maximum model steps reached")

Inside the step loop, the finish reason is checked before a response can become the successful result:

if choice.FinishReason == "length" || choice.FinishReason == "max_tokens" {
    return result, fmt.Errorf("%w (finish_reason=%s); no partial answer is accepted", ErrProviderTruncated, choice.FinishReason)
}
if choice.FinishReason == "content_filter" {
    return result, errors.New("provider stopped with finish_reason=content_filter")
}

The %w on the first error keeps the sentinel reachable through errors.Is, while the formatted message still carries the specific reason. A caller embedding the harness can branch on failure mode without string matching:

// Illustrative caller using the harness's exported sentinels.
result, err := runner.Run(ctx, prompt)
switch {
case errors.Is(err, harness.ErrProviderTruncated):
    // retry with a larger --max-tokens
case errors.Is(err, harness.ErrMaxSteps):
    // the loop ran out of budget, not out of work
case err != nil:
    return err
}

The loop is just as strict about the happy path. When the model returns no tool calls, uncut rejects any finish reason that isn’t empty or "stop", and it rejects an empty final message. Either the run produces text the model finished writing, or it returns an error rather than presenting a partial answer as success.

There is an important implementation detail for session users: the assistant message is appended to the in-memory history and optional JSONL session before the finish reason is checked. A truncated response is therefore not returned as successful output, but its message can still be present in the session log. Callers that resume failed runs should account for that distinction.

Provider errors abort, tool errors don’t

The loop deliberately handles provider and tool failures differently. A failed provider call returns immediately:

response, err := r.Provider.Complete(ctx, CompletionRequest{...})
if err != nil {
    return result, err
}

A tool execution error does not:

content, err := r.Executor.Execute(ctx, call)
if err != nil {
    content = "error: " + err.Error()
}
toolMessage := Message{Role: "tool", ToolCallID: call.ID, Name: call.Function.Name, Content: content}

The error text becomes the tool result and goes back into the conversation. Command execution is slightly different again: a non-zero command exit is returned as tool content containing the exit code and combined output, rather than as a Go error. A failing go test or a missing file is information the model needs; a broken HTTP call to the provider instead bubbles up to the caller.

One guard runs before execution: a tool call with an empty ID or an empty Function.Name aborts the run outright. Feed a malformed tool call back to an OpenAI-compatible API and you get a confusing error from the far side of the wire, so uncut rejects it at the boundary.

The step counter is a plain bounded loop, for step := 1; step <= r.MaxSteps; step++. Falling out the bottom returns ErrMaxSteps with a message telling you to raise --max-steps, which defaults to 32.

Defensive copies on every message

Every message entering or leaving the conversation passes through cloneMessage, in internal/harness/session.go:

func cloneMessage(message Message) Message {
    message.ToolCalls = append([]ToolCall(nil), message.ToolCalls...)
    return message
}

The Message parameter is already a copy, since Go passes structs by value. The only shared state left is the ToolCalls slice’s backing array, and appending the elements to a nil slice gives non-empty input its own array. Empty input becomes nil, which is harmless here because tool_calls uses omitempty.

Runner.Run hands cloneMessages(r.Messages) to the provider on every step and keeps its own conversation history isolated from a provider that retains or modifies the supplied messages.

Append-only sessions that refuse partial records

internal/harness/session.go backs the --session flag with a JSONL log. Each line is a versioned record:

type sessionRecord struct {
    Version int     `json:"v"`
    Message Message `json:"message"`
}

OpenSession opens the file with os.O_CREATE|os.O_RDWR|os.O_APPEND and mode 0o600, then replays it with a bufio.Reader loop. The order of the checks matters:

line, readErr := reader.ReadBytes('\n')
if len(line) > 0 {
    // parse it
}
if readErr != nil {
    if errors.Is(readErr, io.EOF) {
        break
    }
    ...
}

ReadBytes can return data and io.EOF from the same call when the file doesn’t end in a newline. Checking len(line) > 0 before the error means a trailing half-written line still gets parsed, which is the point. It fails json.Unmarshal, and OpenSession returns an error saying it is “refusing to ignore a partial or invalid record”, with the line number attached. A crashed run leaves a damaged log and you hear about it instead of quietly resuming from a truncated history. Lines that aren’t valid UTF-8 and records with an unknown v are rejected the same way.

For writes, Append marshals the record, writes it, calls s.file.Sync(), and only then updates the in-memory slice:

if _, err := s.file.Write(record); err != nil {
    return fmt.Errorf("append session: %w", err)
}
if err := s.file.Sync(); err != nil {
    return fmt.Errorf("sync session: %w", err)
}

An fsync per message would be expensive in a high-throughput database. Here its cost is usually small relative to a model call, and a successful Append means the new record was synced before the in-memory session was updated. As with any filesystem durability claim, the precise guarantee still depends on the operating system and storage stack.

Flags that default to environment variables

cmd/uncut/main.go uses the standard flag package with one trick: environment variables supply the flag default, so an explicit flag always wins.

model := flags.String("model", os.Getenv("UNCUT_MODEL"), "model id (or UNCUT_MODEL)")
baseURL := flags.String("base-url", envOr("UNCUT_BASE_URL", "OPENAI_BASE_URL", "https://api.openai.com/v1"), "OpenAI-compatible base URL")

envOr is called with a project-specific variable, a conventional fallback, and a literal default. The API key follows the same shape: envOr("UNCUT_API_KEY", "OPENAI_API_KEY", ""). Pointing the binary at a local model server is a different --base-url and nothing else, because the provider only needs a /chat/completions endpoint. For more on the package itself, see the Go flag package post.

The flag set is built with flag.ContinueOnError and a custom Usage, so a parse failure returns exit code 2 instead of letting the flag package call os.Exit. run() takes args, stdin, stdout, and stderr as parameters, and main does nothing but wrap it in os.Exit. That structure makes the CLI testable without spawning a process.

Validation after parsing is one condition: an empty model, a non-positive --max-steps, a negative --max-tokens, or a negative --request-timeout prints usage and exits 2. The prompt is additionally run through utf8.ValidString, and AGENTS.md, when it exists, through utf8.Valid before it’s appended to the system prompt. Invalid text is rejected instead of being passed into the context window.

Two shell files, one build tag each

uncut keeps its cross-platform shell execution in two files. internal/harness/shell_unix.go, behind //go:build !windows:

func newShellCommand(ctx context.Context, command string) *exec.Cmd {
    return exec.CommandContext(ctx, "/bin/sh", "-c", command)
}

And internal/harness/shell_windows.go, behind //go:build windows:

func newShellCommand(ctx context.Context, command string) *exec.Cmd {
    cmd := exec.CommandContext(ctx, "cmd.exe")
    cmd.SysProcAttr = &syscall.SysProcAttr{
        CmdLine: `cmd.exe /D /S /C "` + command + `"`,
    }
    return cmd
}

The Windows version sets SysProcAttr.CmdLine rather than passing the command through Args. The source comment explains why: /C receives the raw remainder of cmd.exe’s command line, not an argument quoted by the Windows C runtime. Letting os/exec build that line would apply its standard quoting rules. Writing CmdLine directly instead preserves the command’s original quoting, including quoted executable paths.

Both functions take a context.Context and use exec.CommandContext, so cancellation reaches the directly started shell process. The default behavior documented for exec.CommandContext calls Process.Kill when the context is done; it does not by itself promise to kill every descendant that shell may have started. (If you want more on deadlines and durations generally, there’s golang-time.)

The provider is one small interface

internal/harness/provider.go defines the entire model boundary:

type Provider interface {
    Complete(context.Context, CompletionRequest) (CompletionResponse, error)
}

OpenAIProvider is the only implementation. It builds the endpoint with strings.TrimRight(p.BaseURL, "/") + "/chat/completions", uses http.NewRequestWithContext, sets Authorization only when an API key is present, and falls back to http.DefaultClient when Client is nil.

A non-2xx status becomes a typed *ProviderError holding both the status code and the full body, so the caller can inspect it without parsing a formatted string. For a successful HTTP response, the body must pass utf8.Valid before JSON decoding. An empty Choices array is also an error rather than a zero value passed to the runner.

Because Runner.Provider is an interface field, testing the agent loop takes no HTTP at all. Implement Complete, return a canned CompletionResponse with a tool call, and assert on the messages the runner appends. The one-method interface keeps that test double small. If you’re building the other half of this, the tools rather than the loop, see our write-up on Model Context Protocol servers in Go.