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 }
|