feat: complete v0.3.1 agent workflow
This commit is contained in:
+257
-29
@@ -25,6 +25,7 @@ const (
|
||||
toolLogOutputPreviewBytes = 4096
|
||||
toolLogOutputMarker = "[output]"
|
||||
toolStreamLogLimit = tools.MaxToolOutputBytes
|
||||
continuationPrompt = "Continue with the current task."
|
||||
|
||||
summarizerSystemPrompt = "You are a conversation summarizer. Summarize the following conversation history into a concise but detailed summary. Preserve:\n" +
|
||||
"- Key facts, decisions, and conclusions\n" +
|
||||
@@ -41,6 +42,7 @@ type toolExecutionResult struct {
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
mu sync.RWMutex
|
||||
provider llm.Provider
|
||||
providerName string
|
||||
model string
|
||||
@@ -49,6 +51,8 @@ type Agent struct {
|
||||
thinkingEnabled bool
|
||||
systemPrompt string
|
||||
toolRegistry *tools.Registry
|
||||
authorizer ToolAuthorizer
|
||||
rollbackStore *tools.RollbackStore
|
||||
toolTimeout time.Duration
|
||||
maxToolRounds int
|
||||
maxContextTokens int
|
||||
@@ -65,6 +69,8 @@ type Options struct {
|
||||
ThinkingEnabled bool
|
||||
SystemPrompt string
|
||||
ToolRegistry *tools.Registry
|
||||
Authorizer ToolAuthorizer
|
||||
RollbackStore *tools.RollbackStore
|
||||
ToolTimeout time.Duration
|
||||
MaxToolIterations int
|
||||
MaxContextTokens int
|
||||
@@ -88,6 +94,8 @@ func New(opts Options) *Agent {
|
||||
thinkingEnabled: opts.ThinkingEnabled,
|
||||
systemPrompt: opts.SystemPrompt,
|
||||
toolRegistry: opts.ToolRegistry,
|
||||
authorizer: opts.Authorizer,
|
||||
rollbackStore: opts.RollbackStore,
|
||||
toolTimeout: toolTimeout,
|
||||
maxToolRounds: maxToolRounds,
|
||||
maxContextTokens: opts.MaxContextTokens,
|
||||
@@ -116,6 +124,8 @@ type RuntimeInfo struct {
|
||||
}
|
||||
|
||||
func (a *Agent) SetRuntime(opts RuntimeOptions) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if opts.Provider != nil {
|
||||
a.provider = opts.Provider
|
||||
}
|
||||
@@ -132,6 +142,8 @@ func (a *Agent) SetRuntime(opts RuntimeOptions) {
|
||||
}
|
||||
|
||||
func (a *Agent) RuntimeInfo() RuntimeInfo {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return RuntimeInfo{
|
||||
ProviderName: a.providerName,
|
||||
Model: a.model,
|
||||
@@ -143,6 +155,12 @@ func (a *Agent) RuntimeInfo() RuntimeInfo {
|
||||
}
|
||||
|
||||
func (a *Agent) Clear() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.clear()
|
||||
}
|
||||
|
||||
func (a *Agent) clear() {
|
||||
a.lastUsage = nil
|
||||
a.messages = nil
|
||||
if strings.TrimSpace(a.systemPrompt) != "" {
|
||||
@@ -153,8 +171,27 @@ func (a *Agent) Clear() {
|
||||
}
|
||||
}
|
||||
|
||||
// AddSystemInstructions makes newly installed instructions effective for the
|
||||
// current conversation and for future Clear calls.
|
||||
func (a *Agent) AddSystemInstructions(instructions string) {
|
||||
instructions = strings.TrimSpace(instructions)
|
||||
if instructions == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.systemPrompt = strings.TrimSpace(a.systemPrompt + "\n\n" + instructions)
|
||||
if len(a.messages) > 0 && a.messages[0].Role == llm.RoleSystem {
|
||||
a.messages[0].Content = a.systemPrompt
|
||||
return
|
||||
}
|
||||
a.messages = append([]llm.Message{{Role: llm.RoleSystem, Content: a.systemPrompt}}, a.messages...)
|
||||
}
|
||||
|
||||
// Messages returns a copy of the current conversation messages.
|
||||
func (a *Agent) Messages() []llm.Message {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
out := make([]llm.Message, len(a.messages))
|
||||
copy(out, a.messages)
|
||||
return out
|
||||
@@ -162,11 +199,23 @@ func (a *Agent) Messages() []llm.Message {
|
||||
|
||||
// SetMessages replaces the conversation messages (used for session restore).
|
||||
func (a *Agent) SetMessages(messages []llm.Message) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = append([]llm.Message(nil), messages...)
|
||||
if strings.TrimSpace(a.systemPrompt) == "" {
|
||||
return
|
||||
}
|
||||
if len(a.messages) > 0 && a.messages[0].Role == llm.RoleSystem {
|
||||
a.messages[0].Content = a.systemPrompt
|
||||
return
|
||||
}
|
||||
a.messages = append([]llm.Message{{Role: llm.RoleSystem, Content: a.systemPrompt}}, a.messages...)
|
||||
}
|
||||
|
||||
// LastUsage returns a copy of the most recent provider token usage data.
|
||||
func (a *Agent) LastUsage() *llm.Usage {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.lastUsage == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -176,6 +225,12 @@ func (a *Agent) LastUsage() *llm.Usage {
|
||||
|
||||
// SetLastUsage restores the most recent provider token usage data.
|
||||
func (a *Agent) SetLastUsage(usage *llm.Usage) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.setLastUsage(usage)
|
||||
}
|
||||
|
||||
func (a *Agent) setLastUsage(usage *llm.Usage) {
|
||||
if usage == nil {
|
||||
a.lastUsage = nil
|
||||
return
|
||||
@@ -242,27 +297,32 @@ func compactRecentStart(messages []llm.Message, prefixEnd int) int {
|
||||
// Compact summarizes older conversation messages while preserving the system
|
||||
// prompt and recent messages intact.
|
||||
func (a *Agent) Compact(ctx context.Context) error {
|
||||
if len(a.messages) < 4 {
|
||||
a.mu.RLock()
|
||||
messages := append([]llm.Message(nil), a.messages...)
|
||||
provider := a.provider
|
||||
model := a.model
|
||||
a.mu.RUnlock()
|
||||
if len(messages) < 4 {
|
||||
return nil
|
||||
}
|
||||
if a.provider == nil {
|
||||
if provider == nil {
|
||||
return fmt.Errorf("provider is not configured")
|
||||
}
|
||||
|
||||
prefixEnd := 0
|
||||
if a.messages[0].Role == llm.RoleSystem {
|
||||
if messages[0].Role == llm.RoleSystem {
|
||||
prefixEnd = 1
|
||||
}
|
||||
recentStart := compactRecentStart(a.messages, prefixEnd)
|
||||
if recentStart <= prefixEnd || recentStart >= len(a.messages) {
|
||||
recentStart := compactRecentStart(messages, prefixEnd)
|
||||
if recentStart <= prefixEnd || recentStart >= len(messages) {
|
||||
return nil
|
||||
}
|
||||
middle := append([]llm.Message(nil), a.messages[prefixEnd:recentStart]...)
|
||||
middle := append([]llm.Message(nil), messages[prefixEnd:recentStart]...)
|
||||
if len(middle) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
summary, usage, err := a.summarizeMessages(ctx, middle)
|
||||
summary, usage, err := summarizeMessages(ctx, provider, model, middle)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compact context: %w", err)
|
||||
}
|
||||
@@ -271,13 +331,15 @@ func (a *Agent) Compact(ctx context.Context) error {
|
||||
return fmt.Errorf("compact context: summary was empty")
|
||||
}
|
||||
|
||||
compressed := make([]llm.Message, 0, prefixEnd+1+len(a.messages[recentStart:]))
|
||||
compressed = append(compressed, a.messages[:prefixEnd]...)
|
||||
compressed := make([]llm.Message, 0, prefixEnd+1+len(messages[recentStart:]))
|
||||
compressed = append(compressed, messages[:prefixEnd]...)
|
||||
compressed = append(compressed, llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: contextSummaryPrefix + "\n" + summary,
|
||||
})
|
||||
compressed = append(compressed, a.messages[recentStart:]...)
|
||||
compressed = append(compressed, messages[recentStart:]...)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.messages = compressed
|
||||
if usage != nil {
|
||||
a.lastUsage = usage
|
||||
@@ -286,6 +348,8 @@ func (a *Agent) Compact(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *Agent) shouldCompact() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if a.maxContextTokens <= 0 {
|
||||
return false
|
||||
}
|
||||
@@ -295,9 +359,9 @@ func (a *Agent) shouldCompact() bool {
|
||||
return llm.EstimateTokens(a.messages, a.toolDefinitions()) >= a.maxContextTokens
|
||||
}
|
||||
|
||||
func (a *Agent) summarizeMessages(ctx context.Context, messages []llm.Message) (string, *llm.Usage, error) {
|
||||
func summarizeMessages(ctx context.Context, provider llm.Provider, model string, messages []llm.Message) (string, *llm.Usage, error) {
|
||||
req := llm.ChatRequest{
|
||||
Model: a.model,
|
||||
Model: model,
|
||||
Messages: []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: summarizerSystemPrompt},
|
||||
{Role: llm.RoleUser, Content: formatMessagesForSummary(messages)},
|
||||
@@ -306,7 +370,7 @@ func (a *Agent) summarizeMessages(ctx context.Context, messages []llm.Message) (
|
||||
|
||||
var summary strings.Builder
|
||||
var usage *llm.Usage
|
||||
if err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
||||
if err := provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
||||
if event.Content != "" {
|
||||
summary.WriteString(event.Content)
|
||||
}
|
||||
@@ -354,12 +418,93 @@ func roundSignature(calls []llm.ToolCall) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// BTW answers a temporary side question using a snapshot of the current
|
||||
// conversation. It intentionally exposes no tools and does not mutate the
|
||||
// conversation, usage, or compaction state.
|
||||
func (a *Agent) BTW(ctx context.Context, input string, out io.Writer) error {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return fmt.Errorf("btw question is required")
|
||||
}
|
||||
|
||||
a.mu.RLock()
|
||||
provider := a.provider
|
||||
model := a.model
|
||||
messages := append([]llm.Message(nil), a.messages...)
|
||||
thinking := a.thinking
|
||||
thinkingParam := a.thinkingParam
|
||||
thinkingEnabled := a.thinkingEnabled
|
||||
a.mu.RUnlock()
|
||||
if provider == nil {
|
||||
return fmt.Errorf("provider is not configured")
|
||||
}
|
||||
|
||||
// A snapshot taken while tools are running may end with unanswered tool
|
||||
// calls, which providers reject. Keep only the latest complete boundary.
|
||||
messages = completeConversationPrefix(messages)
|
||||
messages = append(messages, llm.Message{Role: llm.RoleUser, Content: input})
|
||||
messages = expandContinuationMessages(messages)
|
||||
req := llm.ChatRequest{Model: model, Messages: messages}
|
||||
if thinkingEnabled {
|
||||
if thinkingParam == "" {
|
||||
thinkingParam = "thinking"
|
||||
}
|
||||
req.Extra = map[string]any{thinkingParam: thinking}
|
||||
}
|
||||
|
||||
return provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
||||
if event.Content == "" || out == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := io.WriteString(out, event.Content)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func completeConversationPrefix(messages []llm.Message) []llm.Message {
|
||||
safeEnd := 0
|
||||
pending := make(map[string]struct{})
|
||||
for i, msg := range messages {
|
||||
if len(pending) > 0 && msg.Role != llm.RoleTool {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case msg.Role == llm.RoleAssistant && len(msg.ToolCalls) > 0:
|
||||
for _, call := range msg.ToolCalls {
|
||||
pending[call.ID] = struct{}{}
|
||||
}
|
||||
case msg.Role == llm.RoleTool:
|
||||
delete(pending, msg.ToolCallID)
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
safeEnd = i + 1
|
||||
}
|
||||
}
|
||||
return append([]llm.Message(nil), messages[:safeEnd]...)
|
||||
}
|
||||
|
||||
func expandContinuationMessages(messages []llm.Message) []llm.Message {
|
||||
expanded := append([]llm.Message(nil), messages...)
|
||||
for i := range expanded {
|
||||
if expanded[i].Role == llm.RoleUser && strings.TrimSpace(expanded[i].Content) == "." {
|
||||
expanded[i].Content = continuationPrompt
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func (a *Agent) appendMessage(message llm.Message) {
|
||||
a.mu.Lock()
|
||||
a.messages = append(a.messages, message)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
a.messages = append(a.messages, llm.Message{Role: llm.RoleUser, Content: input})
|
||||
a.appendMessage(llm.Message{Role: llm.RoleUser, Content: input})
|
||||
if a.shouldCompact() {
|
||||
if err := a.Compact(ctx); err != nil {
|
||||
if logs != nil {
|
||||
@@ -376,14 +521,14 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
|
||||
return err
|
||||
}
|
||||
if len(calls) == 0 {
|
||||
a.messages = append(a.messages, llm.Message{
|
||||
a.appendMessage(llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: content,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
a.messages = append(a.messages, llm.Message{
|
||||
a.appendMessage(llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: content,
|
||||
ToolCalls: calls,
|
||||
@@ -418,7 +563,7 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
|
||||
strings.Join(toolNames, ", "),
|
||||
doomLoopThreshold,
|
||||
)
|
||||
a.messages = append(a.messages, llm.Message{
|
||||
a.appendMessage(llm.Message{
|
||||
Role: llm.RoleAssistant,
|
||||
Content: msg,
|
||||
})
|
||||
@@ -428,7 +573,7 @@ func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs i
|
||||
|
||||
results := a.executeTools(ctx, calls, logs)
|
||||
for _, result := range results {
|
||||
a.messages = append(a.messages, llm.Message{
|
||||
a.appendMessage(llm.Message{
|
||||
Role: llm.RoleTool,
|
||||
ToolCallID: result.call.ID,
|
||||
Content: result.output,
|
||||
@@ -444,23 +589,33 @@ func (a *Agent) streamAssistant(ctx context.Context, out io.Writer) (string, []l
|
||||
var content strings.Builder
|
||||
calls := make(map[int]*toolCallBuilder)
|
||||
|
||||
a.mu.RLock()
|
||||
provider := a.provider
|
||||
model := a.model
|
||||
messages := append([]llm.Message(nil), a.messages...)
|
||||
thinking := a.thinking
|
||||
thinkingParam := a.thinkingParam
|
||||
thinkingEnabled := a.thinkingEnabled
|
||||
a.mu.RUnlock()
|
||||
if provider == nil {
|
||||
return "", nil, fmt.Errorf("provider is not configured")
|
||||
}
|
||||
req := llm.ChatRequest{
|
||||
Model: a.model,
|
||||
Messages: append([]llm.Message(nil), a.messages...),
|
||||
Model: model,
|
||||
Messages: expandContinuationMessages(messages),
|
||||
Tools: a.toolDefinitions(),
|
||||
}
|
||||
if a.thinkingEnabled {
|
||||
thinkingParam := a.thinkingParam
|
||||
if thinkingEnabled {
|
||||
if thinkingParam == "" {
|
||||
thinkingParam = "thinking"
|
||||
}
|
||||
req.Extra = map[string]any{thinkingParam: a.thinking}
|
||||
req.Extra = map[string]any{thinkingParam: thinking}
|
||||
}
|
||||
if len(req.Tools) > 0 {
|
||||
req.ToolChoice = "auto"
|
||||
}
|
||||
|
||||
err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
||||
err := provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
||||
if event.Usage != nil {
|
||||
a.SetLastUsage(event.Usage)
|
||||
}
|
||||
@@ -513,21 +668,90 @@ func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writ
|
||||
}
|
||||
raw := json.RawMessage(args)
|
||||
|
||||
rollbackNotes, err := a.prepareMutation(ctx, tool, call, raw, args)
|
||||
if err != nil {
|
||||
return tools.FormatError(err), false
|
||||
}
|
||||
|
||||
toolCtx, cancel := context.WithTimeout(ctx, a.toolTimeout)
|
||||
defer cancel()
|
||||
|
||||
if streaming, ok := tool.(tools.StreamingTool); ok {
|
||||
return a.executeStreamingTool(streaming, toolCtx, raw, name, args, logs)
|
||||
output, streamed := a.executeStreamingTool(streaming, toolCtx, raw, name, args, logs)
|
||||
return appendRollbackNotes(output, rollbackNotes), streamed
|
||||
}
|
||||
|
||||
output, err := tool.Execute(toolCtx, raw)
|
||||
if err != nil {
|
||||
if output != "" {
|
||||
return output + "\n" + tools.FormatError(err), false
|
||||
return appendRollbackNotes(output+"\n"+tools.FormatError(err), rollbackNotes), false
|
||||
}
|
||||
return tools.FormatError(err), false
|
||||
return appendRollbackNotes(tools.FormatError(err), rollbackNotes), false
|
||||
}
|
||||
return output, false
|
||||
return appendRollbackNotes(output, rollbackNotes), false
|
||||
}
|
||||
|
||||
func (a *Agent) prepareMutation(ctx context.Context, tool tools.Tool, call llm.ToolCall, raw json.RawMessage, args string) ([]string, error) {
|
||||
if !tools.IsMutatingCall(tool, raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var targets []string
|
||||
if targetTool, ok := tool.(tools.TargetPathTool); ok {
|
||||
var err error
|
||||
targets, err = targetTool.TargetPaths(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve mutation targets: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
preview := compact(args, toolLogArgumentLimit)
|
||||
if previewTool, ok := tool.(tools.PreviewableTool); ok {
|
||||
value, err := previewTool.Preview(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preview mutation: %w", err)
|
||||
}
|
||||
preview = value
|
||||
}
|
||||
|
||||
if a.authorizer != nil {
|
||||
decision, err := a.authorizer.AuthorizeToolCall(ctx, ToolAuthorizationRequest{
|
||||
ID: call.ID,
|
||||
ToolName: call.Function.Name,
|
||||
Arguments: args,
|
||||
Preview: preview,
|
||||
RollbackAvailable: a.rollbackStore != nil && len(targets) > 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorize %s: %w", call.Function.Name, err)
|
||||
}
|
||||
if decision != ToolAuthorizationAllow {
|
||||
return nil, fmt.Errorf("tool call denied by user: %s", call.Function.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if a.rollbackStore == nil {
|
||||
return nil, nil
|
||||
}
|
||||
notes := make([]string, 0, len(targets))
|
||||
for _, path := range targets {
|
||||
id, err := a.rollbackStore.Capture(call.Function.Name, path, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capture rollback snapshot for %s: %w", path, err)
|
||||
}
|
||||
notes = append(notes, fmt.Sprintf("[rollback] %s %s", id, path))
|
||||
}
|
||||
return notes, nil
|
||||
}
|
||||
|
||||
func appendRollbackNotes(output string, notes []string) string {
|
||||
if len(notes) == 0 {
|
||||
return output
|
||||
}
|
||||
if strings.TrimSpace(output) == "" {
|
||||
return strings.Join(notes, "\n")
|
||||
}
|
||||
return output + "\n" + strings.Join(notes, "\n")
|
||||
}
|
||||
|
||||
func (a *Agent) executeStreamingTool(streaming tools.StreamingTool, ctx context.Context, raw json.RawMessage, name, args string, logs io.Writer) (string, bool) {
|
||||
@@ -573,7 +797,11 @@ func (a *Agent) hasMutatingCall(calls []llm.ToolCall) bool {
|
||||
}
|
||||
for _, call := range calls {
|
||||
if tool, ok := a.toolRegistry.Get(call.Function.Name); ok {
|
||||
if tools.IsMutating(tool) {
|
||||
args := strings.TrimSpace(call.Function.Arguments)
|
||||
if args == "" {
|
||||
args = "{}"
|
||||
}
|
||||
if tools.IsMutatingCall(tool, json.RawMessage(args)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agentu/internal/tools"
|
||||
@@ -177,6 +182,173 @@ func (p *contentProvider) ChatStream(ctx context.Context, req llm.ChatRequest, e
|
||||
return emit(llm.StreamEvent{Content: p.text})
|
||||
}
|
||||
|
||||
type btwProvider struct {
|
||||
req llm.ChatRequest
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *btwProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
p.req = req
|
||||
if p.err != nil {
|
||||
return p.err
|
||||
}
|
||||
return emit(llm.StreamEvent{Content: "side answer", Usage: &llm.Usage{TotalTokens: 999}})
|
||||
}
|
||||
|
||||
func TestBTWUsesContextWithoutMutatingConversation(t *testing.T) {
|
||||
provider := &btwProvider{}
|
||||
a := New(Options{Provider: provider, Model: "test", Thinking: "high", ThinkingEnabled: true})
|
||||
a.SetMessages([]llm.Message{
|
||||
{Role: llm.RoleUser, Content: "main question"},
|
||||
{Role: llm.RoleAssistant, Content: "main answer"},
|
||||
})
|
||||
a.SetLastUsage(&llm.Usage{TotalTokens: 7})
|
||||
before := a.Messages()
|
||||
|
||||
var out strings.Builder
|
||||
if err := a.BTW(context.Background(), "what does that mean?", &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.String() != "side answer" {
|
||||
t.Fatalf("output = %q", out.String())
|
||||
}
|
||||
if !reflect.DeepEqual(a.Messages(), before) {
|
||||
t.Fatalf("conversation changed:\nbefore: %+v\nafter: %+v", before, a.Messages())
|
||||
}
|
||||
if usage := a.LastUsage(); usage == nil || usage.TotalTokens != 7 {
|
||||
t.Fatalf("usage changed: %+v", usage)
|
||||
}
|
||||
if len(provider.req.Tools) != 0 || provider.req.ToolChoice != "" {
|
||||
t.Fatalf("btw exposed tools: %+v / %q", provider.req.Tools, provider.req.ToolChoice)
|
||||
}
|
||||
last := provider.req.Messages[len(provider.req.Messages)-1]
|
||||
if last.Role != llm.RoleUser || last.Content != "what does that mean?" {
|
||||
t.Fatalf("last request message = %+v", last)
|
||||
}
|
||||
if provider.req.Extra["thinking"] != "high" {
|
||||
t.Fatalf("thinking extra = %+v", provider.req.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBTWRejectsEmptyQuestionAndReturnsProviderError(t *testing.T) {
|
||||
provider := &btwProvider{err: errors.New("boom")}
|
||||
a := New(Options{Provider: provider, Model: "test"})
|
||||
if err := a.BTW(context.Background(), " ", nil); err == nil {
|
||||
t.Fatal("empty question did not fail")
|
||||
}
|
||||
if err := a.BTW(context.Background(), "question", nil); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("provider error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDotContinuesCurrentTaskWithoutRewritingHistory(t *testing.T) {
|
||||
provider := &btwProvider{}
|
||||
a := New(Options{Provider: provider, Model: "test"})
|
||||
if err := a.RunTurn(context.Background(), " . ", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
requestMessage := provider.req.Messages[len(provider.req.Messages)-1]
|
||||
if requestMessage.Content != continuationPrompt {
|
||||
t.Fatalf("request content = %q, want %q", requestMessage.Content, continuationPrompt)
|
||||
}
|
||||
history := a.Messages()
|
||||
if history[0].Content != "." {
|
||||
t.Fatalf("stored input = %q, want literal dot", history[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandContinuationMessagesOnlyExpandsSingleDot(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "."},
|
||||
{Role: llm.RoleUser, Content: " . "},
|
||||
{Role: llm.RoleUser, Content: ".."},
|
||||
{Role: llm.RoleUser, Content: ". continue"},
|
||||
{Role: llm.RoleAssistant, Content: "."},
|
||||
}
|
||||
expanded := expandContinuationMessages(messages)
|
||||
if expanded[0].Content != continuationPrompt || expanded[1].Content != continuationPrompt {
|
||||
t.Fatalf("single dots were not expanded: %+v", expanded)
|
||||
}
|
||||
for _, index := range []int{2, 3, 4} {
|
||||
if expanded[index].Content != messages[index].Content {
|
||||
t.Fatalf("message %d changed from %q to %q", index, messages[index].Content, expanded[index].Content)
|
||||
}
|
||||
}
|
||||
if messages[0].Content != "." || messages[1].Content != " . " {
|
||||
t.Fatalf("source messages were mutated: %+v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteConversationPrefixDropsPendingToolRound(t *testing.T) {
|
||||
messages := []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "first"},
|
||||
{Role: llm.RoleAssistant, Content: "answer"},
|
||||
{Role: llm.RoleUser, Content: "main"},
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "call_1"}, {ID: "call_2"}}},
|
||||
{Role: llm.RoleTool, ToolCallID: "call_1", Content: "one"},
|
||||
}
|
||||
got := completeConversationPrefix(messages)
|
||||
if len(got) != 3 || got[len(got)-1].Content != "main" {
|
||||
t.Fatalf("complete prefix = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type concurrentBTWProvider struct {
|
||||
mainStarted chan struct{}
|
||||
releaseMain chan struct{}
|
||||
once sync.Once
|
||||
mu sync.Mutex
|
||||
btwRequest llm.ChatRequest
|
||||
}
|
||||
|
||||
func (p *concurrentBTWProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
last := req.Messages[len(req.Messages)-1].Content
|
||||
if last == "main task" {
|
||||
p.once.Do(func() { close(p.mainStarted) })
|
||||
select {
|
||||
case <-p.releaseMain:
|
||||
return emit(llm.StreamEvent{Content: "main answer"})
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.btwRequest = req
|
||||
p.mu.Unlock()
|
||||
return emit(llm.StreamEvent{Content: "side answer"})
|
||||
}
|
||||
|
||||
func TestBTWCanRunDuringMainTurn(t *testing.T) {
|
||||
provider := &concurrentBTWProvider{mainStarted: make(chan struct{}), releaseMain: make(chan struct{})}
|
||||
a := New(Options{Provider: provider, Model: "test"})
|
||||
mainDone := make(chan error, 1)
|
||||
go func() { mainDone <- a.RunTurn(context.Background(), "main task", nil, nil) }()
|
||||
<-provider.mainStarted
|
||||
|
||||
var side strings.Builder
|
||||
if err := a.BTW(context.Background(), "side question", &side); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if side.String() != "side answer" {
|
||||
t.Fatalf("side output = %q", side.String())
|
||||
}
|
||||
provider.mu.Lock()
|
||||
req := provider.btwRequest
|
||||
provider.mu.Unlock()
|
||||
if len(req.Messages) < 2 || req.Messages[len(req.Messages)-2].Content != "main task" {
|
||||
t.Fatalf("btw request did not include active main turn: %+v", req.Messages)
|
||||
}
|
||||
|
||||
close(provider.releaseMain)
|
||||
if err := <-mainDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Messages()) != 2 {
|
||||
t.Fatalf("main conversation contains btw messages: %+v", a.Messages())
|
||||
}
|
||||
}
|
||||
|
||||
type compactProvider struct {
|
||||
summaryCalls int
|
||||
answerCalls int
|
||||
@@ -629,6 +801,120 @@ type parallelProvider struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
type authorizationProvider struct {
|
||||
call llm.ToolCallDelta
|
||||
calls int
|
||||
toolOutput string
|
||||
}
|
||||
|
||||
func (p *authorizationProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
p.calls++
|
||||
if p.calls == 1 {
|
||||
return emit(llm.StreamEvent{ToolCalls: []llm.ToolCallDelta{p.call}})
|
||||
}
|
||||
p.toolOutput = req.Messages[len(req.Messages)-1].Content
|
||||
return emit(llm.StreamEvent{Content: "done"})
|
||||
}
|
||||
|
||||
type recordingAuthorizer struct {
|
||||
decision ToolAuthorizationDecision
|
||||
requests []ToolAuthorizationRequest
|
||||
}
|
||||
|
||||
func (a *recordingAuthorizer) AuthorizeToolCall(ctx context.Context, req ToolAuthorizationRequest) (ToolAuthorizationDecision, error) {
|
||||
a.requests = append(a.requests, req)
|
||||
return a.decision, ctx.Err()
|
||||
}
|
||||
|
||||
func TestAgentAuthorizesMutationAndCreatesRollbackSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
provider := &authorizationProvider{call: llm.ToolCallDelta{
|
||||
Index: 0, ID: "write_1", Type: "function", Name: "file_write",
|
||||
Arguments: `{"path":"new.txt","content":"hello"}`,
|
||||
}}
|
||||
authorizer := &recordingAuthorizer{decision: ToolAuthorizationAllow}
|
||||
rollback := tools.NewRollbackStore()
|
||||
a := New(Options{
|
||||
Provider: provider,
|
||||
Model: "test",
|
||||
ToolRegistry: tools.Builtins(dir),
|
||||
Authorizer: authorizer,
|
||||
RollbackStore: rollback,
|
||||
})
|
||||
|
||||
if err := a.RunTurn(context.Background(), "write it", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(authorizer.requests) != 1 || !authorizer.requests[0].RollbackAvailable {
|
||||
t.Fatalf("authorization requests = %+v", authorizer.requests)
|
||||
}
|
||||
if !strings.Contains(authorizer.requests[0].Preview, "write 5 bytes to new.txt") {
|
||||
t.Fatalf("preview = %q", authorizer.requests[0].Preview)
|
||||
}
|
||||
rollbackID := rollback.LastID()
|
||||
if rollbackID == "" || !strings.Contains(provider.toolOutput, rollbackID) {
|
||||
t.Fatalf("tool output %q missing rollback ID %q", provider.toolOutput, rollbackID)
|
||||
}
|
||||
if _, err := rollback.Restore(rollbackID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "new.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("new file still exists after rollback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDeniedMutationDoesNotExecute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
provider := &authorizationProvider{call: llm.ToolCallDelta{
|
||||
Index: 0, ID: "write_1", Type: "function", Name: "file_write",
|
||||
Arguments: `{"path":"denied.txt","content":"no"}`,
|
||||
}}
|
||||
authorizer := &recordingAuthorizer{decision: ToolAuthorizationDeny}
|
||||
a := New(Options{Provider: provider, Model: "test", ToolRegistry: tools.Builtins(dir), Authorizer: authorizer})
|
||||
|
||||
if err := a.RunTurn(context.Background(), "write it", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "denied.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("denied file exists: %v", err)
|
||||
}
|
||||
if !strings.Contains(provider.toolOutput, "denied by user") {
|
||||
t.Fatalf("tool output = %q", provider.toolOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentDoesNotAuthorizeApplyPatchDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
provider := &authorizationProvider{call: llm.ToolCallDelta{
|
||||
Index: 0, ID: "patch_1", Type: "function", Name: "apply_patch",
|
||||
Arguments: `{"patch":"*** Begin Patch\n*** Add File: dry.txt\n+dry\n*** End Patch","dry_run":true}`,
|
||||
}}
|
||||
authorizer := &recordingAuthorizer{decision: ToolAuthorizationDeny}
|
||||
a := New(Options{Provider: provider, Model: "test", ToolRegistry: tools.Builtins(dir), Authorizer: authorizer})
|
||||
|
||||
if err := a.RunTurn(context.Background(), "preview", nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(authorizer.requests) != 0 {
|
||||
t.Fatalf("dry run requested authorization: %+v", authorizer.requests)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dry.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry run created a file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetMessagesRefreshesCurrentSystemInstructions(t *testing.T) {
|
||||
a := New(Options{SystemPrompt: "current instructions"})
|
||||
a.SetMessages([]llm.Message{
|
||||
{Role: llm.RoleSystem, Content: "old instructions"},
|
||||
{Role: llm.RoleUser, Content: "hello"},
|
||||
})
|
||||
messages := a.Messages()
|
||||
if messages[0].Content != "current instructions" || messages[1].Content != "hello" {
|
||||
t.Fatalf("messages = %+v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parallelProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error {
|
||||
p.calls++
|
||||
if p.calls <= len(p.responses) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// ToolAuthorizationDecision is the result of an authorization check.
|
||||
type ToolAuthorizationDecision string
|
||||
|
||||
const (
|
||||
ToolAuthorizationAllow ToolAuthorizationDecision = "allow"
|
||||
ToolAuthorizationDeny ToolAuthorizationDecision = "deny"
|
||||
)
|
||||
|
||||
// ToolAuthorizationRequest is sent to an authorizer before executing a
|
||||
// mutating tool call.
|
||||
type ToolAuthorizationRequest struct {
|
||||
ID string
|
||||
ToolName string
|
||||
Arguments string
|
||||
Preview string
|
||||
RollbackAvailable bool
|
||||
}
|
||||
|
||||
// ToolAuthorizer decides whether a mutating tool call should proceed.
|
||||
type ToolAuthorizer interface {
|
||||
AuthorizeToolCall(ctx context.Context, req ToolAuthorizationRequest) (ToolAuthorizationDecision, error)
|
||||
}
|
||||
Reference in New Issue
Block a user