Download
224 lines 8.7 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
// Package agent runs the conversation: it sends the user's messages to the
// model, runs the tools the model calls, and loops until the model answers.
package agent

import (
	"context"
	"errors"
	"fmt"
	"runtime"
	"time"

	"blinker/internal/history"
	"blinker/internal/llm"
	"blinker/internal/policy"
	"blinker/internal/tools"
)

// Event is something that happened during a turn, for the interface.
type Event any

type (
	// Token is a piece of the model's reply or reasoning as it streams in.
	Token struct {
		Text      string
		Reasoning bool
	}
	// ReplyDone ends one reply of the model (a turn may have several,
	// with tool calls between them).
	ReplyDone struct{}
	// ToolStart is a tool call about to run.
	ToolStart struct {
		ID, Name, Args string
	}
	// ToolDone is a tool call's result.
	ToolDone struct {
		ID, Name string
		Result   tools.Result
	}
	// TurnDone ends the turn; Err is nil if the model answered.
	TurnDone struct{ Err error }
)

// maxSteps bounds the model calls in one turn, so a model stuck calling
// tools cannot run forever.
const maxSteps = 200

// Agent is one conversation.
type Agent struct {
	Client    *llm.Client
	Tools     *tools.Toolbox
	Emit      func(Event)
	StateDir  string // where the session is saved; "" to not save
	ConfigDir string // where the user's AGENTS.md is; "" for none

	// The instructions in the AGENTS.md files, read when the
	// conversation starts.
	userInstructions, projectInstructions string

	messages []llm.Message // as the model knows the conversation
	log      []llm.Message // everything said, for the history
	session  *history.Session

	// Compaction.
	window     int      // the model's context size in tokens; 0 if unknown
	sized      bool     // window is settled: configured, or the server asked
	elided     int      // messages before this have their tool output removed
	summarized bool     // messages[1] holds an earlier summary
	prompts    []string // the user's messages a summary replaced
	// used is how many tokens the server counted for the conversation up
	// to messages[usedAt]; 0 if it did not say.
	used, usedAt int
}

// New starts a conversation.
func New(client *llm.Client, tb *tools.Toolbox, configDir, stateDir string, emit func(Event)) *Agent {
	return &Agent{
		Client: client, Tools: tb, Emit: emit, ConfigDir: configDir, StateDir: stateDir,
		session: &history.Session{Project: tb.SB.Project(), Model: client.Model, Started: time.Now()},
		window:  client.ContextWindow, sized: client.ContextWindow > 0,
	}
}

// Reset starts a new conversation.
func (a *Agent) Reset() {
	a.Tools.Forget()
	*a = *New(a.Client, a.Tools, a.ConfigDir, a.StateDir, a.Emit)
}

// add adds messages to the conversation.
func (a *Agent) add(ms ...llm.Message) {
	a.messages = append(a.messages, ms...)
	a.log = append(a.log, ms...)
}

func (a *Agent) systemPrompt() string {
	_, l := a.Tools.SB.Policy()
	hidden := ""
	if a.Tools.SB.Hidden() {
		hidden = " It is the user's home directory or a shared temp dir, which no tool can read or write, whatever the policy says: the user wants to ask questions, so answer from what you know."
	}
	return fmt.Sprintf(`You are Blinker, a careful coding agent. You work in the project directory %s on %s, with the user watching.%s

You act only through your tools: Read, Write and Patch for files, and Safe_Bash and Unsafe_Bash for commands. All of them run inside a kernel-enforced sandbox (%s) governed by the access policies below.
- Safe_Bash, and the file tools where the safe policy's access suffices, run without asking the user. Anything the policy does not allow fails with a permission error; that is harmless and expected, not something to work around.
- Unsafe_Bash, and the file tools where only the unsafe policy's access suffices, need the user's approval each time. Use them only when needed, and say briefly why.
- Access neither gives is never possible. Do not try to get around the sandbox or the policy, and never try to read credentials.
- $TMPDIR (%s) is this session's own temp directory: readable and writable in both modes, kept between commands, and deleted when the session ends. Use it for scratch files. The system's shared temp dirs are off limits.
- Commands get $AI_AGENT (blinker) and $AI_MODEL (the model you are), for writing about yourself, such as crediting yourself in a commit.

Work in small, verifiable steps. Read code before changing it. After changing code, check it builds and its tests pass when you can. Keep answers short and concrete; the user sees your tool calls and their results.

The access policies: ordered rules, each with where it is from; for each path, host, tool or variable the last matching rule wins. Paths are globs relative to the project unless they start with / or ~/, and a rule matching a directory covers everything in it. A host no rule matches is denied; "ask" asks the user in the unsafe policy. A tool the safe policy allows runs without asking; one only the unsafe policy allows asks first. Credentials and shell startup files are blocked whatever the rules say.

The safe policy:
%s

The unsafe policy:
%s%s`, a.Tools.SB.Project(), runtime.GOOS, hidden, a.Tools.SB.Backend(), a.Tools.SB.TempDir(), l.Rules(policy.SafeMode), l.Rules(policy.UnsafeMode), a.instructionsPrompt())
}

// Turn sends the user's input and runs until the model answers, the
// context is canceled, or something fails. It emits events as it goes and
// TurnDone at the end.
func (a *Agent) Turn(ctx context.Context, input string) {
	err := a.turn(ctx, input)
	a.save()
	a.Emit(TurnDone{Err: err})
}

func (a *Agent) turn(ctx context.Context, input string) error {
	if len(a.messages) == 0 {
		a.loadInstructions(ctx)
	}
	sys := llm.Message{Role: "system", Content: a.systemPrompt()}
	if len(a.messages) == 0 {
		a.add(sys)
	} else {
		a.messages[0], a.log[0] = sys, sys // the policy may have changed
	}
	a.add(llm.Message{Role: "user", Content: input})
	if !a.sized {
		sctx, cancel := context.WithTimeout(ctx, 10*time.Second)
		n, err := a.Client.ContextSize(sctx)
		cancel()
		if ctx.Err() != nil {
			return ctx.Err()
		}
		a.window, a.sized = n, true
		why := "The server does not say how much context the model has"
		if err != nil {
			why = "Could not ask the server how much context the model has (" + err.Error() + ")"
		}
		if n == 0 {
			a.Emit(Notice{Text: why + ", so blinker will not compact the conversation on its own. /compact does it now, and /setup can set the size."})
		}
	}
	// Compacting can fail, or free too little; then the turn goes on
	// without it, rather than trying again at every step.
	auto := true
	for range maxSteps {
		if auto && a.full() {
			err := a.compact(ctx, true)
			if ctx.Err() != nil {
				return ctx.Err()
			}
			switch {
			case err != nil:
				auto = false
				a.Emit(Notice{Text: "Could not compact the conversation: " + err.Error() + ". Going on without; the model may run out of context. /compact tries again, and /clear starts over."})
			case a.full():
				auto = false
				a.Emit(Notice{Text: "The conversation still nearly fills the model's context after compacting, so it goes on without compacting again this turn."})
			}
		}
		res, err := a.Client.Complete(ctx, a.view(), a.Tools.Specs(), func(text string, reasoning bool) {
			a.Emit(Token{Text: text, Reasoning: reasoning})
		})
		a.Emit(ReplyDone{})
		if err != nil {
			if res.Reply != "" || res.Reasoning != "" {
				// Keep what the user saw, so the conversation can go on.
				a.add(llm.Message{Role: "assistant", Content: res.Reply, Reasoning: res.Reasoning})
			}
			return err
		}
		a.add(res.Message())
		if res.Usage.PromptTokens > 0 {
			a.used, a.usedAt = res.Usage.PromptTokens+res.Usage.CompletionTokens, len(a.messages)
		}
		if len(res.Calls) == 0 {
			return nil
		}
		for i, call := range res.Calls {
			if ctx.Err() != nil {
				// Every call needs a result for the conversation to stay
				// valid.
				for _, c := range res.Calls[i:] {
					a.add(llm.Message{Role: "tool", ToolCallID: c.ID, Content: "Not run: the user interrupted."})
				}
				return ctx.Err()
			}
			a.Emit(ToolStart{ID: call.ID, Name: call.Function.Name, Args: call.Function.Arguments})
			r := a.Tools.Run(ctx, call.Function.Name, call.Function.Arguments)
			a.Emit(ToolDone{ID: call.ID, Name: call.Function.Name, Result: r})
			a.add(llm.Message{Role: "tool", ToolCallID: call.ID, Content: r.Output})
		}
		a.save()
	}
	return errors.New("stopped after too many steps without an answer")
}

// save writes the conversation to the history.
func (a *Agent) save() {
	if a.StateDir == "" || len(a.log) == 0 {
		return
	}
	a.session.Messages = history.FromLLM(a.log)
	if err := a.session.Save(a.StateDir); err != nil {
		a.Emit(Notice{Text: "Could not save the history: " + err.Error()})
	}
}

// Notice is a message for the user from blinker itself.
type Notice struct{ Text string }