agent: clean up semantics, UX, DX, and procedural code (#17212)
This commit is contained in:
+39
-50
@@ -11,12 +11,12 @@ type ApprovalRequest struct {
|
||||
Calls []ApprovalToolCall
|
||||
}
|
||||
|
||||
func (r *ApprovalRequest) AddToolCall(id, name string, args map[string]any) {
|
||||
func (r *ApprovalRequest) AddToolCall(id, name, scope string, args map[string]any) {
|
||||
r.Calls = append(r.Calls, ApprovalToolCall{
|
||||
ToolCallID: id,
|
||||
ToolName: name,
|
||||
Args: args,
|
||||
ApprovalScope: toolApprovalScope(name, args),
|
||||
ApprovalScope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,16 +54,18 @@ func (s *ApprovalState) Set(allowAll bool, scopes map[string]bool) {
|
||||
s.scopes = cloneApprovalScopes(scopes)
|
||||
}
|
||||
|
||||
func (s *ApprovalState) SetAllowAll(allowAll bool) {
|
||||
// GrantAll grants blanket approval for all future tool calls.
|
||||
func (s *ApprovalState) GrantAll() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowAll = allowAll
|
||||
s.allowAll = true
|
||||
}
|
||||
|
||||
func (s *ApprovalState) AllowAll() bool {
|
||||
// AllGranted reports whether blanket approval has been granted.
|
||||
func (s *ApprovalState) AllGranted() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
@@ -81,36 +83,41 @@ func (s *ApprovalState) Allows(scope string) bool {
|
||||
return s.allowAll || s.scopes[scope]
|
||||
}
|
||||
|
||||
func (s *ApprovalState) Apply(result *Approval) {
|
||||
// Apply merges an approval's scopes and allow-all flag into the state. It
|
||||
// returns true if the approval grants permission (allow-all or at least one
|
||||
// scope). It does not mutate the approval; the caller sets Allow based on the
|
||||
// returned value.
|
||||
func (s *ApprovalState) Apply(result *Approval) bool {
|
||||
if s == nil || result == nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
granted := false
|
||||
if result.AllowAll {
|
||||
result.Allow = true
|
||||
s.allowAll = true
|
||||
granted = true
|
||||
}
|
||||
if len(result.AllowScopes) > 0 {
|
||||
result.Allow = true
|
||||
if s.scopes == nil {
|
||||
s.scopes = make(map[string]bool, len(result.AllowScopes))
|
||||
}
|
||||
for _, scope := range result.AllowScopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
s.scopes[scope] = true
|
||||
}
|
||||
}
|
||||
granted = true
|
||||
s.grantScopesLocked(result.AllowScopes)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
func (s *ApprovalState) AllowScopes(scopes []string) {
|
||||
// GrantScopes merges the given scopes into the state.
|
||||
func (s *ApprovalState) GrantScopes(scopes []string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.grantScopesLocked(scopes)
|
||||
}
|
||||
|
||||
// grantScopesLocked adds trimmed, non-empty scopes to the state. Caller must
|
||||
// hold s.mu.
|
||||
func (s *ApprovalState) grantScopesLocked(scopes []string) {
|
||||
if s.scopes == nil {
|
||||
s.scopes = make(map[string]bool, len(scopes))
|
||||
}
|
||||
@@ -136,7 +143,7 @@ func cloneApprovalScopes(src map[string]bool) map[string]bool {
|
||||
}
|
||||
|
||||
func (s *Session) needsApproval(tool Tool, name string, args map[string]any) bool {
|
||||
return ToolRequiresApproval(tool, args) && !s.allows(toolApprovalScope(name, args))
|
||||
return ToolRequiresApproval(tool, args) && !s.allows(toolApprovalScope(tool, name, args))
|
||||
}
|
||||
|
||||
// allows reports whether scope is permitted by the session's accumulated approval state.
|
||||
@@ -148,8 +155,7 @@ func (s *Session) allows(scope string) bool {
|
||||
}
|
||||
|
||||
// applyApproval merges an approval result into the session's state and marks
|
||||
// the result as allowed when scopes or allow-all were granted. It mutates
|
||||
// result.Allow so the caller can branch on the effective decision.
|
||||
// the result as allowed when scopes or allow-all were granted.
|
||||
func (s *Session) applyApproval(result *Approval) {
|
||||
if s == nil || result == nil {
|
||||
return
|
||||
@@ -157,11 +163,13 @@ func (s *Session) applyApproval(result *Approval) {
|
||||
if s.ApprovalState == nil {
|
||||
s.ApprovalState = &ApprovalState{}
|
||||
}
|
||||
s.ApprovalState.Apply(result)
|
||||
if s.ApprovalState.Apply(result) {
|
||||
result.Allow = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (Approval, error) {
|
||||
if s == nil || len(req.Calls) == 0 || (s.ApprovalState != nil && s.ApprovalState.AllowAll()) {
|
||||
if s == nil || len(req.Calls) == 0 || (s.ApprovalState != nil && s.ApprovalState.AllGranted()) {
|
||||
return Approval{Allow: true}, nil
|
||||
}
|
||||
if s.ApprovalPrompter == nil {
|
||||
@@ -179,31 +187,12 @@ func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (
|
||||
}
|
||||
|
||||
// toolApprovalScope returns the approval scope key for a tool invocation.
|
||||
//
|
||||
// For shell tools (bash/powershell) the scope is "<tool>\x00<command>": the
|
||||
// exact, trimmed command byte string. "Always allow this command" therefore
|
||||
// matches ONLY that precise string — any whitespace, quoting, or casing
|
||||
// variant, or any command that is a superset of the approved one, will
|
||||
// re-prompt. The NUL separator is safe because a shell command string cannot
|
||||
// contain a literal NUL. For all other tools the scope is the tool name.
|
||||
func toolApprovalScope(toolName string, args map[string]any) string {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if isShellApprovalTool(toolName) {
|
||||
if command, ok := stringArg(args, "command"); ok {
|
||||
command = strings.TrimSpace(command)
|
||||
if command != "" {
|
||||
return toolName + "\x00" + command
|
||||
}
|
||||
}
|
||||
// If the tool implements ScopedTool, its ApprovalScope method determines the
|
||||
// scope (e.g. shell tools scope to "<tool>\x00<command>"). Otherwise the scope
|
||||
// is the trimmed tool name.
|
||||
func toolApprovalScope(tool Tool, toolName string, args map[string]any) string {
|
||||
if scoped, ok := tool.(ScopedTool); ok {
|
||||
return scoped.ApprovalScope(args)
|
||||
}
|
||||
return toolName
|
||||
}
|
||||
|
||||
func isShellApprovalTool(name string) bool {
|
||||
return name == "bash" || name == "powershell"
|
||||
}
|
||||
|
||||
func stringArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
return value, ok
|
||||
return strings.TrimSpace(toolName)
|
||||
}
|
||||
|
||||
+55
-15
@@ -1,28 +1,68 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
func TestApprovalRequestScopesShellCommandsToExactCommand(t *testing.T) {
|
||||
req := ApprovalRequest{}
|
||||
req.AddToolCall("call-1", "bash", map[string]any{"command": " pwd "})
|
||||
req.AddToolCall("call-2", "powershell", map[string]any{"command": "Get-ChildItem"})
|
||||
req.AddToolCall("call-3", "edit", map[string]any{"path": "README.md"})
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type mockTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (m mockTool) Name() string { return m.name }
|
||||
func (m mockTool) Description() string { return "" }
|
||||
func (m mockTool) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: m.name}
|
||||
}
|
||||
|
||||
func (m mockTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
|
||||
return ToolResult{}, nil
|
||||
}
|
||||
|
||||
func TestToolApprovalScopeUsesScopedTool(t *testing.T) {
|
||||
shellTool := mockScopedTool{
|
||||
mockTool: mockTool{name: "bash"},
|
||||
scope: func(args map[string]any) string {
|
||||
if cmd, ok := args["command"].(string); ok {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" {
|
||||
return "bash\x00" + cmd
|
||||
}
|
||||
}
|
||||
return "bash"
|
||||
},
|
||||
}
|
||||
plainTool := mockTool{name: "edit"}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
want string
|
||||
tool Tool
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{index: 0, want: "bash\x00pwd"},
|
||||
{index: 1, want: "powershell\x00Get-ChildItem"},
|
||||
{index: 2, want: "edit"},
|
||||
{shellTool, "bash", map[string]any{"command": " pwd "}, "bash\x00pwd"},
|
||||
{shellTool, "bash", map[string]any{"command": "Get-ChildItem"}, "bash\x00Get-ChildItem"},
|
||||
{plainTool, "edit", map[string]any{"path": "README.md"}, "edit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := req.Calls[tt.index].ApprovalScope; got != tt.want {
|
||||
t.Fatalf("call %d approval scope = %q, want %q", tt.index, got, tt.want)
|
||||
if got := toolApprovalScope(tt.tool, tt.name, tt.args); got != tt.want {
|
||||
t.Fatalf("toolApprovalScope(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mockScopedTool struct {
|
||||
mockTool
|
||||
scope func(args map[string]any) string
|
||||
}
|
||||
|
||||
func (m mockScopedTool) ApprovalScope(args map[string]any) string {
|
||||
return m.scope(args)
|
||||
}
|
||||
|
||||
func TestSessionApplyApprovalScopes(t *testing.T) {
|
||||
session := &Session{}
|
||||
result := Approval{AllowScopes: []string{"edit", "bash\x00pwd", " "}}
|
||||
@@ -38,7 +78,7 @@ func TestSessionApplyApprovalScopes(t *testing.T) {
|
||||
if session.allows("bash") || session.allows("bash\x00ls") {
|
||||
t.Fatal("shell approval was too broad")
|
||||
}
|
||||
if session.ApprovalState.AllowAll() {
|
||||
if session.ApprovalState.AllGranted() {
|
||||
t.Fatal("allow all = true, want false for scoped approval")
|
||||
}
|
||||
}
|
||||
@@ -50,6 +90,6 @@ func TestSessionApplyApprovalAllowAll(t *testing.T) {
|
||||
session.applyApproval(&result)
|
||||
|
||||
if !result.Allow || !session.allows("anything") {
|
||||
t.Fatalf("allow all = %v result = %#v, want allow all", session.ApprovalState.AllowAll(), result)
|
||||
t.Fatalf("allow all = %v result = %#v, want allow all", session.ApprovalState.AllGranted(), result)
|
||||
}
|
||||
}
|
||||
|
||||
+60
-22
@@ -26,14 +26,25 @@ const (
|
||||
defaultCompactionThreshold = 0.8
|
||||
compactOnlySummaryContextTokens = 16000
|
||||
|
||||
maxCompactionSummaryBytes = 16 * 1024
|
||||
compactionSummaryTruncated = "\n\n[summary truncated]"
|
||||
maxCompactionSummaryRunes = 16 * 1024
|
||||
|
||||
compactionSystemPrompt = "Summarize the archived part of an Ollama agent conversation. Preserve user goals, decisions, files, commands, tool results, and unresolved tasks needed to continue. Omit private reasoning and return only the summary."
|
||||
)
|
||||
|
||||
type Compactor interface {
|
||||
MaybeCompact(context.Context, CompactionRequest) (CompactionResult, error)
|
||||
|
||||
// ContextWindowTokens returns the effective context window size in
|
||||
// tokens, resolving runtime options against configured defaults.
|
||||
ContextWindowTokens(options map[string]any) int
|
||||
|
||||
// Threshold returns the compaction threshold as a fraction of the
|
||||
// context window (e.g. 0.8 means compact at 80% capacity).
|
||||
Threshold() float64
|
||||
|
||||
// ShouldCompact reports whether a compaction should run and returns the
|
||||
// trigger reason. An empty trigger means compaction is not needed.
|
||||
ShouldCompact(req CompactionRequest) (trigger string, should bool)
|
||||
}
|
||||
|
||||
type CompactionOptions struct {
|
||||
@@ -146,6 +157,48 @@ func (c *SimpleCompactor) contextWindowTokens(options map[string]any) int {
|
||||
return ResolveContextWindowTokens(options, c.Options.ContextWindowTokens)
|
||||
}
|
||||
|
||||
// ContextWindowTokens resolves the effective context window from runtime
|
||||
// options or configured defaults. Satisfies the Compactor interface.
|
||||
func (c *SimpleCompactor) ContextWindowTokens(options map[string]any) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.contextWindowTokens(options)
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) threshold() float64 {
|
||||
return ResolveCompactionThreshold(c.Options.Threshold)
|
||||
}
|
||||
|
||||
// Threshold returns the configured compaction threshold fraction. Satisfies
|
||||
// the Compactor interface.
|
||||
func (c *SimpleCompactor) Threshold() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.threshold()
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether compaction is due and the trigger reason.
|
||||
// Satisfies the Compactor interface.
|
||||
func (c *SimpleCompactor) ShouldCompact(req CompactionRequest) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
if req.Force {
|
||||
return "force", true
|
||||
}
|
||||
if c.shouldCompact(req) {
|
||||
contextWindow := c.contextWindowTokens(req.Options)
|
||||
threshold := int(float64(contextWindow) * c.threshold())
|
||||
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
||||
return "prompt_eval", true
|
||||
}
|
||||
return "estimate", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
||||
contextWindow := c.contextWindowTokens(options)
|
||||
if contextWindow > 0 && contextWindow < compactOnlySummaryContextTokens {
|
||||
@@ -157,10 +210,6 @@ func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
||||
return defaultCompactionKeepUserTurns
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) threshold() float64 {
|
||||
return ResolveCompactionThreshold(c.Options.Threshold)
|
||||
}
|
||||
|
||||
func ResolveContextWindowTokens(options map[string]any, configured int) int {
|
||||
if n := intOption(options, "num_ctx"); n > 0 {
|
||||
return n
|
||||
@@ -301,21 +350,10 @@ func (c *SimpleCompactor) compactionPromptBodyBudgetTokens(options map[string]an
|
||||
}
|
||||
|
||||
func truncateCompactionSummary(summary string) string {
|
||||
if len(summary) <= maxCompactionSummaryBytes {
|
||||
return summary
|
||||
}
|
||||
limit := maxCompactionSummaryBytes - len(compactionSummaryTruncated)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range summary {
|
||||
if b.Len()+len(string(r)) > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + compactionSummaryTruncated
|
||||
return Truncate(summary, TruncateConfig{
|
||||
MaxRunes: maxCompactionSummaryRunes,
|
||||
Label: "summary",
|
||||
})
|
||||
}
|
||||
|
||||
func estimateCompactionTokens(text string) int {
|
||||
@@ -323,7 +361,7 @@ func estimateCompactionTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return max(1, (len([]rune(text))+3)/4)
|
||||
return ApproximateTokens(len([]rune(text)))
|
||||
}
|
||||
|
||||
func estimateMessagesTokens(messages []api.Message) int {
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestSimpleCompactorAddsContinueTaskInstructionOnlyToToolResult(t *testing.T
|
||||
}
|
||||
|
||||
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
||||
longSummary := strings.Repeat("x", maxCompactionSummaryBytes+1024)
|
||||
longSummary := strings.Repeat("x", maxCompactionSummaryRunes+1024)
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: longSummary}},
|
||||
@@ -214,13 +214,13 @@ func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if len(result.Summary) > maxCompactionSummaryBytes {
|
||||
t.Fatalf("summary bytes = %d, want <= %d", len(result.Summary), maxCompactionSummaryBytes)
|
||||
if runeCount := len([]rune(result.Summary)); runeCount > maxCompactionSummaryRunes+200 {
|
||||
t.Fatalf("summary runes = %d, want <= %d (plus marker)", runeCount, maxCompactionSummaryRunes)
|
||||
}
|
||||
if !strings.HasSuffix(result.Summary, compactionSummaryTruncated) {
|
||||
t.Fatalf("summary missing truncation marker")
|
||||
if !strings.Contains(result.Summary, "[summary truncated:") {
|
||||
t.Fatalf("summary missing truncation marker: %q", result.Summary)
|
||||
}
|
||||
if !strings.Contains(result.Messages[1].Content, compactionSummaryTruncated) {
|
||||
if !strings.Contains(result.Messages[1].Content, "[summary truncated:") {
|
||||
t.Fatalf("compacted message missing truncation marker: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-4
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
@@ -53,20 +54,79 @@ func (fn EventSinkFunc) Emit(event Event) error {
|
||||
return fn(event)
|
||||
}
|
||||
|
||||
// eventMetadata carries the run identification fields shared by all events.
|
||||
type eventMetadata struct {
|
||||
runID string
|
||||
chatID string
|
||||
model string
|
||||
}
|
||||
|
||||
func newEventMetadata(runID string, opts RunOptions) eventMetadata {
|
||||
return eventMetadata{runID: runID, chatID: opts.ChatID, model: opts.Model}
|
||||
}
|
||||
|
||||
func newMessageDelta(m eventMetadata, content string) Event {
|
||||
return Event{Type: EventMessageDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Content: content}
|
||||
}
|
||||
|
||||
func newThinkingDelta(m eventMetadata, thinking string) Event {
|
||||
return Event{Type: EventThinkingDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Thinking: thinking}
|
||||
}
|
||||
|
||||
func newToolCallDetected(m eventMetadata, calls []api.ToolCall) Event {
|
||||
return Event{Type: EventToolCallDetected, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolCalls: calls}
|
||||
}
|
||||
|
||||
func newToolStarted(m eventMetadata, callID, toolName, workingDir string, args map[string]any) Event {
|
||||
return Event{Type: EventToolStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: "running", ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args}
|
||||
}
|
||||
|
||||
func newToolFinished(m eventMetadata, status, callID, toolName, workingDir string, args map[string]any, content, errMsg string) Event {
|
||||
ev := Event{Type: EventToolFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args, Content: content}
|
||||
if errMsg != "" {
|
||||
ev.Error = errMsg
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func newRunFinished(m eventMetadata, status string) Event {
|
||||
return Event{Type: EventRunFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
|
||||
}
|
||||
|
||||
func newErrorEvent(m eventMetadata, errMsg string) Event {
|
||||
return Event{Type: EventError, RunID: m.runID, ChatID: m.chatID, Model: m.model, Error: errMsg}
|
||||
}
|
||||
|
||||
func newCompactionProgress(m eventMetadata, tokens int) Event {
|
||||
return Event{Type: EventCompactionProgress, RunID: m.runID, ChatID: m.chatID, Model: m.model, Tokens: tokens}
|
||||
}
|
||||
|
||||
func newCompactionStarted(m eventMetadata, status string) Event {
|
||||
return Event{Type: EventCompactionStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
|
||||
}
|
||||
|
||||
func newCompactionSkipped(m eventMetadata, status, content string) Event {
|
||||
return Event{Type: EventCompactionSkipped, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, Content: content}
|
||||
}
|
||||
|
||||
func newCompacted(m eventMetadata, messages []api.Message, status, content string) Event {
|
||||
return Event{Type: EventCompacted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, Content: content, Messages: messages}
|
||||
}
|
||||
|
||||
func (s *Session) emit(event Event) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
var errs []error
|
||||
for _, sink := range s.EventSinks {
|
||||
if sink == nil {
|
||||
continue
|
||||
}
|
||||
if err := sink.Emit(event); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
if err := sink.Emit(event); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *Session) emitIgnoringCanceled(ctx context.Context, event Event) error {
|
||||
|
||||
@@ -28,6 +28,13 @@ type ApprovalRequired interface {
|
||||
RequiresApproval(map[string]any) bool
|
||||
}
|
||||
|
||||
// ScopedTool is implemented by tools that need per-invocation approval
|
||||
// scoping beyond the tool name (e.g. shell commands scoped to the exact
|
||||
// command string). Tools that don't implement this are scoped by name only.
|
||||
type ScopedTool interface {
|
||||
ApprovalScope(args map[string]any) string
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
}
|
||||
|
||||
+208
-153
@@ -74,6 +74,10 @@ type toolBatchResult struct {
|
||||
overflows []toolOutputOverflow
|
||||
}
|
||||
|
||||
// toolExecutionStop is the batch-level outcome for a group of tool calls,
|
||||
// distinct from per-call Event.Status values. The values overlap with
|
||||
// runFinish.status ("denied", "canceled") because a denied or canceled
|
||||
// batch also terminates the run with the matching status.
|
||||
type toolExecutionStop string
|
||||
|
||||
const (
|
||||
@@ -142,30 +146,15 @@ func (st *runState) finishError(err error) {
|
||||
}
|
||||
|
||||
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("nil session")
|
||||
}
|
||||
if s.Client == nil {
|
||||
return nil, errors.New("agent session requires a chat client")
|
||||
}
|
||||
if opts.Model == "" {
|
||||
return nil, errors.New("agent session requires a model")
|
||||
if err := s.validateRun(opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.ApprovalState == nil {
|
||||
s.ApprovalState = &ApprovalState{}
|
||||
}
|
||||
runID := uuid.NewString()
|
||||
messages := make([]api.Message, 0, len(opts.Messages)+len(opts.NewMessages))
|
||||
for _, msg := range opts.Messages {
|
||||
messages = append(messages, sanitizeMessageForRun(msg))
|
||||
}
|
||||
for _, msg := range opts.NewMessages {
|
||||
msg = sanitizeMessageForRun(msg)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
||||
s.emit(Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
||||
messages, err := s.buildRunMessages(ctx, runID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -188,7 +177,7 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
||||
}
|
||||
case runPhaseCompact:
|
||||
if err := s.runCompactionStep(ctx, &st); err != nil {
|
||||
return &RunResult{Messages: st.messages, Latest: st.latest, WorkingDir: s.WorkingDir}, err
|
||||
return nil, err
|
||||
}
|
||||
case runPhaseDone:
|
||||
return s.finishRun(ctx, &st)
|
||||
@@ -196,8 +185,43 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
||||
}
|
||||
}
|
||||
|
||||
// validateRun checks the preconditions for a run.
|
||||
func (s *Session) validateRun(opts RunOptions) error {
|
||||
if s == nil {
|
||||
return errors.New("nil session")
|
||||
}
|
||||
if s.Client == nil {
|
||||
return errors.New("agent session requires a chat client")
|
||||
}
|
||||
if opts.Model == "" {
|
||||
return errors.New("agent session requires a model")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRunMessages sanitizes the provided message history, runs the preflight
|
||||
// prompt-budget check, and returns the initial message list for the run. It
|
||||
// emits an EventError and returns it if the preflight check fails.
|
||||
func (s *Session) buildRunMessages(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
|
||||
messages := make([]api.Message, 0, len(opts.Messages)+len(opts.NewMessages))
|
||||
for _, msg := range opts.Messages {
|
||||
messages = append(messages, sanitizeMessageForRun(msg))
|
||||
}
|
||||
for _, msg := range opts.NewMessages {
|
||||
msg = sanitizeMessageForRun(msg)
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
||||
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
opts := st.opts
|
||||
meta := newEventMetadata(st.runID, opts)
|
||||
|
||||
assistant, pendingToolCalls, canceled, err := s.chatRound(ctx, st.runID, opts, st.messages, &st.latest)
|
||||
if err != nil {
|
||||
@@ -210,7 +234,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
||||
s.emit(newErrorEvent(meta, err.Error()))
|
||||
return err
|
||||
}
|
||||
st.consecutiveModelErrors = 0
|
||||
@@ -231,7 +255,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
if canceled {
|
||||
skipped, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, "Tool execution skipped because the run was canceled.")
|
||||
if skipErr != nil {
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
||||
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||
return skipErr
|
||||
}
|
||||
st.messages = append(st.messages, skipped...)
|
||||
@@ -242,7 +266,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
if s.DisableTools {
|
||||
batch, skipErr := s.disabledToolCalls(ctx, st.runID, opts, st.messages, pendingToolCalls)
|
||||
if skipErr != nil {
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
||||
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||
return skipErr
|
||||
}
|
||||
st.messages = append(st.messages, batch.messages...)
|
||||
@@ -260,12 +284,12 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
content := fmt.Sprintf("Tool execution skipped because the max tool-round limit of %d was reached. Send another message to continue.", st.maxToolRounds)
|
||||
toolMessages, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, content)
|
||||
if skipErr != nil {
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
||||
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||
return skipErr
|
||||
}
|
||||
st.messages = append(st.messages, toolMessages...)
|
||||
err := fmt.Errorf("tool round limit reached after %d rounds; send another message to continue", st.maxToolRounds)
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
||||
s.emit(newErrorEvent(meta, err.Error()))
|
||||
st.finishError(err)
|
||||
return nil
|
||||
}
|
||||
@@ -277,7 +301,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||
func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
||||
batch, err := s.executeToolCalls(ctx, st.runID, st.opts, st.messages, st.pendingToolCalls)
|
||||
if err != nil {
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: st.opts.ChatID, Model: st.opts.Model, Error: err.Error()})
|
||||
s.emit(newErrorEvent(newEventMetadata(st.runID, st.opts), err.Error()))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -289,6 +313,7 @@ func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
||||
|
||||
func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
||||
opts := st.opts
|
||||
meta := newEventMetadata(st.runID, opts)
|
||||
var err error
|
||||
if st.toolBatch != nil && len(st.toolBatch.overflows) > 0 {
|
||||
st.messages, st.compactionSkipNotified, err = s.compactForToolOutputOverflow(ctx, st.runID, opts, st.messages, st.latest, st.assistant, st.toolBatch.messages, st.toolBatch.overflows, st.compactionSkipNotified)
|
||||
@@ -296,8 +321,9 @@ func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
||||
st.messages, st.compactionSkipNotified, err = s.maybeCompact(ctx, st.runID, opts, st.messages, st.latest, st.compactionSkipNotified)
|
||||
}
|
||||
if err != nil {
|
||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
||||
return err
|
||||
s.emit(newErrorEvent(meta, err.Error()))
|
||||
st.finishError(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if st.toolBatch == nil {
|
||||
@@ -326,7 +352,7 @@ func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
||||
|
||||
func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, error) {
|
||||
if st.finish.status != "" {
|
||||
event := Event{Type: EventRunFinished, RunID: st.runID, ChatID: st.opts.ChatID, Model: st.opts.Model, Status: st.finish.status}
|
||||
event := newRunFinished(newEventMetadata(st.runID, st.opts), st.finish.status)
|
||||
var err error
|
||||
if st.finish.ignoreCanceled {
|
||||
err = s.emitIgnoringCanceled(ctx, event)
|
||||
@@ -341,6 +367,7 @@ func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, erro
|
||||
}
|
||||
|
||||
func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest *api.ChatResponse) (api.Message, []api.ToolCall, bool, error) {
|
||||
meta := newEventMetadata(runID, opts)
|
||||
var tools api.Tools
|
||||
if !s.DisableTools {
|
||||
tools = s.availableTools()
|
||||
@@ -355,21 +382,21 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
|
||||
assistant.Role = response.Message.Role
|
||||
}
|
||||
|
||||
if response.Message.Content == "" && response.Message.Thinking == "" && len(response.Message.ToolCalls) == 0 {
|
||||
if messageEmpty(response.Message) {
|
||||
*latest = response
|
||||
return nil
|
||||
}
|
||||
|
||||
if response.Message.Thinking != "" {
|
||||
assistant.Thinking += response.Message.Thinking
|
||||
if err := s.emit(Event{Type: EventThinkingDelta, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Thinking: response.Message.Thinking}); err != nil {
|
||||
if err := s.emit(newThinkingDelta(meta, response.Message.Thinking)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if response.Message.Content != "" {
|
||||
assistant.Content += response.Message.Content
|
||||
if err := s.emit(Event{Type: EventMessageDelta, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Content: response.Message.Content}); err != nil {
|
||||
if err := s.emit(newMessageDelta(meta, response.Message.Content)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -377,7 +404,7 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
|
||||
if len(response.Message.ToolCalls) > 0 {
|
||||
assistant.ToolCalls = append(assistant.ToolCalls, response.Message.ToolCalls...)
|
||||
pendingToolCalls = append(pendingToolCalls, response.Message.ToolCalls...)
|
||||
if err := s.emit(Event{Type: EventToolCallDetected, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, ToolCalls: response.Message.ToolCalls}); err != nil {
|
||||
if err := s.emit(newToolCallDetected(meta, response.Message.ToolCalls)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -425,10 +452,17 @@ func buildChatRequest(opts RunOptions, messages []api.Message, tools api.Tools)
|
||||
}
|
||||
|
||||
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
||||
meta := newEventMetadata(runID, opts)
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
// Pre-compute the full-history token estimate once per batch instead of
|
||||
// re-marshaling the entire history for each tool call. Per-call deltas
|
||||
// (tool messages already appended this batch) are tracked in batchTokens
|
||||
// and added to historyTokens for a lightweight running total.
|
||||
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
batchTokens := 0
|
||||
|
||||
type plannedToolCall struct {
|
||||
call api.ToolCall
|
||||
@@ -452,7 +486,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
workingDir: batchWorkingDir,
|
||||
})
|
||||
if ok && s.needsApproval(tool, toolName, args) {
|
||||
approvalReq.AddToolCall(call.ID, toolName, args)
|
||||
approvalReq.AddToolCall(call.ID, toolName, toolApprovalScope(tool, toolName, args), args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,11 +510,12 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
content = "Tool execution denied."
|
||||
}
|
||||
for _, plan := range plans {
|
||||
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, projectedMessages)
|
||||
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
deniedContent := msg.Content
|
||||
if emitErr := s.emit(Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", ToolCallID: plan.call.ID, ToolName: plan.toolName, Args: plan.args, Content: deniedContent, Error: deniedContent}); emitErr != nil {
|
||||
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
}
|
||||
}
|
||||
@@ -504,34 +539,36 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
}
|
||||
if plan.tool == nil {
|
||||
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content = msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
|
||||
}
|
||||
if emitErr := s.emit(Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: fmt.Sprintf("unknown tool: %s", toolName)}); emitErr != nil {
|
||||
if emitErr := s.emit(newToolFinished(meta, "failed", call.ID, toolName, "", args, content, fmt.Sprintf("unknown tool: %s", toolName))); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.emit(Event{Type: EventToolStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "running", ToolCallID: call.ID, ToolName: toolName, WorkingDir: plan.workingDir, Args: args}); err != nil {
|
||||
if err := s.emit(newToolStarted(meta, call.ID, toolName, plan.workingDir, args)); err != nil {
|
||||
return toolBatchResult{}, err
|
||||
}
|
||||
|
||||
result, err := s.Tools.Execute(ctx, ToolContext{WorkingDir: plan.workingDir}, call)
|
||||
if err != nil {
|
||||
rawContent := fmt.Sprintf("Error: %v", err)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
if toolOutputFullyOmitted(content) {
|
||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||
}
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: err.Error()}); emitErr != nil {
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "failed", call.ID, toolName, "", args, content, err.Error())); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@@ -552,15 +589,16 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
}
|
||||
rawContent := result.Content
|
||||
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
content := msg.Content
|
||||
|
||||
if toolOutputFullyOmitted(content) {
|
||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||
}
|
||||
if err := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "done", ToolCallID: call.ID, ToolName: toolName, WorkingDir: eventWorkingDir, Args: args, Content: content}); err != nil {
|
||||
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "done", call.ID, toolName, eventWorkingDir, args, content, "")); err != nil {
|
||||
return toolBatchResult{}, err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
@@ -577,17 +615,21 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
||||
}
|
||||
|
||||
func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
||||
meta := newEventMetadata(runID, opts)
|
||||
batch := toolBatchResult{
|
||||
messages: make([]api.Message, 0, len(calls)),
|
||||
}
|
||||
projectedMessages := append([]api.Message(nil), messages...)
|
||||
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
batchTokens := 0
|
||||
for _, call := range calls {
|
||||
toolName := call.Function.Name
|
||||
args := call.Function.Arguments.ToMap()
|
||||
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, projectedMessages)
|
||||
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
|
||||
batch.messages = append(batch.messages, msg)
|
||||
projectedMessages = append(projectedMessages, msg)
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "disabled", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: msg.Content, Error: msg.Content}); emitErr != nil {
|
||||
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||
return toolBatchResult{}, emitErr
|
||||
}
|
||||
}
|
||||
@@ -595,13 +637,14 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
||||
}
|
||||
|
||||
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
|
||||
meta := newEventMetadata(runID, opts)
|
||||
toolMessages := make([]api.Message, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
toolName := call.Function.Name
|
||||
args := call.Function.Arguments.ToMap()
|
||||
msg := toolMessage(toolName, call.ID, content)
|
||||
toolMessages = append(toolMessages, msg)
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "skipped", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: msg.Content, Error: msg.Content}); emitErr != nil {
|
||||
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "skipped", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||
return nil, emitErr
|
||||
}
|
||||
}
|
||||
@@ -733,6 +776,8 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
||||
compacted = append(compacted, assistant)
|
||||
}
|
||||
|
||||
historyTokens := s.estimateRunPromptTokens(opts, compacted)
|
||||
batchTokens := 0
|
||||
for _, msg := range toolMessages {
|
||||
content := msg.Content
|
||||
toolName := msg.ToolName
|
||||
@@ -742,8 +787,9 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
||||
toolName = overflow.toolName
|
||||
}
|
||||
}
|
||||
refit := s.toolMessageForPostCompactionContext(toolName, msg.ToolCallID, content, opts, compacted)
|
||||
refit := s.toolMessageForPostCompactionContext(toolName, msg.ToolCallID, content, opts, historyTokens+batchTokens)
|
||||
compacted = append(compacted, refit)
|
||||
batchTokens += estimateMessagesTokens([]api.Message{refit})
|
||||
}
|
||||
|
||||
s.emitCompacted(runID, opts, compacted, "tool_output", result.Summary)
|
||||
@@ -754,6 +800,7 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
||||
}
|
||||
|
||||
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
|
||||
meta := newEventMetadata(runID, opts)
|
||||
return CompactionRequest{
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
@@ -767,60 +814,30 @@ func (s *Session) compactionRequest(runID string, opts RunOptions, messages []ap
|
||||
Think: opts.Think,
|
||||
ContinueTask: true,
|
||||
Progress: func(progress CompactionProgress) {
|
||||
_ = s.emit(Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
|
||||
_ = s.emit(newCompactionProgress(meta, progress.Tokens))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, status string) {
|
||||
_ = s.emit(Event{
|
||||
Type: EventCompactionStarted,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
})
|
||||
_ = s.emit(newCompactionStarted(newEventMetadata(runID, opts), status))
|
||||
}
|
||||
|
||||
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, status, reason string) {
|
||||
_ = s.emit(Event{
|
||||
Type: EventCompactionSkipped,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
Content: CompactionSkippedMessage(reason),
|
||||
})
|
||||
_ = s.emit(newCompactionSkipped(newEventMetadata(runID, opts), status, CompactionSkippedMessage(reason)))
|
||||
}
|
||||
|
||||
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, status, summary string) {
|
||||
_ = s.emit(Event{
|
||||
Type: EventCompacted,
|
||||
RunID: runID,
|
||||
ChatID: opts.ChatID,
|
||||
Model: opts.Model,
|
||||
Status: status,
|
||||
Content: summary,
|
||||
Messages: messages,
|
||||
})
|
||||
_ = s.emit(newCompacted(newEventMetadata(runID, opts), messages, status, summary))
|
||||
}
|
||||
|
||||
func (s *Session) autoCompactionTrigger(req CompactionRequest) string {
|
||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
||||
if req.Force {
|
||||
return "force"
|
||||
}
|
||||
contextWindow := compactor.contextWindowTokens(req.Options)
|
||||
threshold := int(float64(contextWindow) * compactor.threshold())
|
||||
if threshold <= 0 {
|
||||
return ""
|
||||
}
|
||||
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
||||
return "prompt_eval"
|
||||
}
|
||||
if estimateCompactionRequestTokens(req) >= threshold {
|
||||
return "estimate"
|
||||
}
|
||||
if s.Compactor == nil {
|
||||
return ""
|
||||
}
|
||||
trigger, should := s.Compactor.ShouldCompact(req)
|
||||
if should {
|
||||
return trigger
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -840,60 +857,48 @@ func resolvedMaxToolRounds(value int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
func (s *Session) toolMessageForContext(toolName, toolCallID, content string, opts RunOptions, messages []api.Message) api.Message {
|
||||
// toolMessageWithBudget sizes a tool result message to fit within a token
|
||||
// budget (compaction threshold or context window). baseTokens is the
|
||||
// pre-computed estimate of everything before this message; budgetTokens is
|
||||
// the ceiling. If the message already fits, it is returned with only the
|
||||
// small-context rune cap applied.
|
||||
func (s *Session) toolMessageWithBudget(toolName, toolCallID, content string, opts RunOptions, baseTokens, budgetTokens int) api.Message {
|
||||
maxRunes := maxToolResultRunes
|
||||
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
||||
maxRunes = min(maxRunes, limit)
|
||||
}
|
||||
|
||||
if budgetTokens <= 0 {
|
||||
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||
}
|
||||
|
||||
msg := toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||
threshold := s.compactionThresholdTokens(opts)
|
||||
if threshold <= 0 {
|
||||
projectedTokens := baseTokens + estimateMessagesTokens([]api.Message{msg})
|
||||
if projectedTokens < budgetTokens {
|
||||
return msg
|
||||
}
|
||||
|
||||
projected := append(append([]api.Message(nil), messages...), msg)
|
||||
projectedTokens := s.estimateRunPromptTokens(opts, projected)
|
||||
if projectedTokens < threshold {
|
||||
return msg
|
||||
}
|
||||
|
||||
baseTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
overheadTokens := estimateMessagesTokens([]api.Message{{
|
||||
Role: "tool",
|
||||
ToolName: toolName,
|
||||
ToolCallID: toolCallID,
|
||||
}})
|
||||
// Keep oversized tool output below the compaction threshold before it is
|
||||
// appended to history. This is especially important for <=8k contexts: the
|
||||
// next step must still have enough room to compact and continue the same
|
||||
// user request instead of asking the user to prompt again.
|
||||
availableRunes := (threshold - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
||||
// Keep oversized tool output below the budget before it is appended to
|
||||
// history. This is especially important for <=8k contexts: the next step
|
||||
// must still have enough room to compact and continue the same user
|
||||
// request instead of asking the user to prompt again.
|
||||
availableRunes := (budgetTokens - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
||||
maxRunes = min(maxRunes, max(0, availableRunes))
|
||||
msg.Content = truncateToolResultContentTo(content, maxRunes)
|
||||
return msg
|
||||
}
|
||||
|
||||
func (s *Session) toolMessageForPostCompactionContext(toolName, toolCallID, content string, opts RunOptions, messages []api.Message) api.Message {
|
||||
maxRunes := maxToolResultRunes
|
||||
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
||||
maxRunes = min(maxRunes, limit)
|
||||
}
|
||||
func (s *Session) toolMessageForContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
||||
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.compactionThresholdTokens(opts))
|
||||
}
|
||||
|
||||
contextWindow := s.contextWindowTokens(opts)
|
||||
if contextWindow <= 0 {
|
||||
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||
}
|
||||
|
||||
baseTokens := s.estimateRunPromptTokens(opts, messages)
|
||||
overheadTokens := estimateMessagesTokens([]api.Message{{
|
||||
Role: "tool",
|
||||
ToolName: toolName,
|
||||
ToolCallID: toolCallID,
|
||||
}})
|
||||
availableRunes := (contextWindow - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
||||
maxRunes = min(maxRunes, max(0, availableRunes))
|
||||
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||
func (s *Session) toolMessageForPostCompactionContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
||||
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.contextWindowTokens(opts))
|
||||
}
|
||||
|
||||
func toolMessageWithLimit(toolName, toolCallID, content string, maxRunes int) api.Message {
|
||||
@@ -930,8 +935,8 @@ func (s *Session) compactionThresholdTokens(opts RunOptions) int {
|
||||
}
|
||||
|
||||
configuredThreshold := 0.0
|
||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
||||
configuredThreshold = compactor.Options.Threshold
|
||||
if s.Compactor != nil {
|
||||
configuredThreshold = s.Compactor.Threshold()
|
||||
}
|
||||
|
||||
threshold := int(float64(contextWindow) * ResolveCompactionThreshold(configuredThreshold))
|
||||
@@ -945,13 +950,7 @@ func (s *Session) contextWindowTokens(opts RunOptions) int {
|
||||
if s.Compactor == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
configuredWindow := 0
|
||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
||||
configuredWindow = compactor.Options.ContextWindowTokens
|
||||
}
|
||||
|
||||
return ResolveContextWindowTokens(opts.Options, configuredWindow)
|
||||
return s.Compactor.ContextWindowTokens(opts.Options)
|
||||
}
|
||||
|
||||
func toolMessage(toolName, toolCallID, content string) api.Message {
|
||||
@@ -971,47 +970,103 @@ func sanitizeMessagesForRequest(messages []api.Message) []api.Message {
|
||||
}
|
||||
sanitized := make([]api.Message, len(messages))
|
||||
for i, msg := range messages {
|
||||
sanitized[i] = sanitizeMessageForRequest(msg)
|
||||
sanitized[i] = sanitizeMessageForRun(msg)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeMessageForRequest(msg api.Message) api.Message {
|
||||
return sanitizeMessageForRun(msg)
|
||||
}
|
||||
|
||||
func truncateToolResultContent(content string) string {
|
||||
return truncateToolResultContentTo(content, maxToolResultRunes)
|
||||
}
|
||||
|
||||
func truncateToolResultContentTo(content string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return fmt.Sprintf("%s omitted ~%d tokens. Use a narrower command, line range, or search query if more detail is needed.]", toolOutputFullOmissionPrefix, approximateTokensFromRunes(len([]rune(content))))
|
||||
}
|
||||
if len(content) <= maxRunes {
|
||||
return content
|
||||
}
|
||||
return Truncate(content, TruncateConfig{
|
||||
MaxRunes: maxRunes,
|
||||
HeadTail: true,
|
||||
HeadPct: 75,
|
||||
Label: "tool output",
|
||||
Hint: "Use a narrower command, line range, or search query if more detail is needed.",
|
||||
FullOmissionPrefix: toolOutputFullOmissionPrefix,
|
||||
})
|
||||
}
|
||||
|
||||
// TruncateConfig configures content truncation via Truncate.
|
||||
type TruncateConfig struct {
|
||||
MaxRunes int // rune limit; <= 0 means full omission
|
||||
HeadTail bool // true = head + tail split; false = head only
|
||||
HeadPct int // percentage of MaxRunes for head (e.g. 75); tail gets the rest
|
||||
Label string // e.g. "tool output", "summary", "stdout"
|
||||
Hint string // guidance text appended to marker (optional)
|
||||
FullOmissionPrefix string // marker prefix when MaxRunes <= 0
|
||||
}
|
||||
|
||||
// Truncate truncates content to at most cfg.MaxRunes runes. When HeadTail is
|
||||
// true, it preserves the first HeadPct% and last (100-HeadPct)% of the budget
|
||||
// with a marker between; otherwise it keeps only the head. MaxRunes <= 0
|
||||
// triggers full omission using FullOmissionPrefix. All token counts in
|
||||
// markers use ApproximateTokens.
|
||||
func Truncate(content string, cfg TruncateConfig) string {
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxRunes {
|
||||
total := len(runes)
|
||||
|
||||
if cfg.MaxRunes <= 0 {
|
||||
return fmt.Sprintf("%s omitted ~%d tokens.%s]", cfg.FullOmissionPrefix, ApproximateTokens(total), truncHint(cfg.Hint))
|
||||
}
|
||||
if total <= cfg.MaxRunes {
|
||||
return content
|
||||
}
|
||||
head := maxRunes * 3 / 4
|
||||
tail := maxRunes - head
|
||||
omitted := len(runes) - head - tail
|
||||
marker := fmt.Sprintf(
|
||||
"\n\n[tool output truncated: showing first ~%d tokens and last ~%d tokens; omitted ~%d tokens. Use a narrower command, line range, or search query if more detail is needed.]\n\n",
|
||||
approximateTokensFromRunes(head),
|
||||
approximateTokensFromRunes(tail),
|
||||
approximateTokensFromRunes(omitted),
|
||||
)
|
||||
return string(runes[:head]) + marker + string(runes[len(runes)-tail:])
|
||||
|
||||
if !cfg.HeadTail {
|
||||
head := cfg.MaxRunes
|
||||
omitted := total - head
|
||||
return string(runes[:head]) + TruncMarker(cfg.Label, head, 0, omitted, false, cfg.Hint)
|
||||
}
|
||||
|
||||
head := cfg.MaxRunes * cfg.HeadPct / 100
|
||||
tail := cfg.MaxRunes - head
|
||||
omitted := total - head - tail
|
||||
return string(runes[:head]) + TruncMarker(cfg.Label, head, tail, omitted, true, cfg.Hint) + string(runes[len(runes)-tail:])
|
||||
}
|
||||
|
||||
func truncHint(hint string) string {
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(hint, ".") {
|
||||
hint += "."
|
||||
}
|
||||
return " " + hint
|
||||
}
|
||||
|
||||
// TruncMarker formats a truncation marker with consistent wording. head and
|
||||
// tail are rune counts; omitted is the count of runes removed. headTail
|
||||
// selects the head+tail vs head-only format. hint is optional guidance text.
|
||||
func TruncMarker(label string, head, tail, omitted int, headTail bool, hint string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n[")
|
||||
b.WriteString(label)
|
||||
b.WriteString(" truncated: ")
|
||||
if headTail {
|
||||
fmt.Fprintf(&b, "showing first ~%d tokens and last ~%d tokens; ", ApproximateTokens(head), ApproximateTokens(tail))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "showing first ~%d tokens; ", ApproximateTokens(head))
|
||||
}
|
||||
fmt.Fprintf(&b, "omitted ~%d tokens.%s]", ApproximateTokens(omitted), truncHint(hint))
|
||||
if headTail {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func toolOutputFullyOmitted(content string) bool {
|
||||
return strings.HasPrefix(content, toolOutputFullOmissionPrefix)
|
||||
}
|
||||
|
||||
func approximateTokensFromRunes(n int) int {
|
||||
// ApproximateTokens estimates token count from a character/byte count using
|
||||
// the standard ~4 chars-per-token heuristic. It is intentionally rough; all
|
||||
// callers use it only for sizing/truncation decisions, not billing.
|
||||
func ApproximateTokens(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
+33
-3
@@ -136,6 +136,14 @@ func (c *recordingCompactor) MaybeCompact(_ context.Context, req CompactionReque
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *recordingCompactor) ContextWindowTokens(options map[string]any) int {
|
||||
return ResolveContextWindowTokens(options, 0)
|
||||
}
|
||||
func (c *recordingCompactor) Threshold() float64 { return 0 }
|
||||
func (c *recordingCompactor) ShouldCompact(_ CompactionRequest) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (c *oversizedCompactor) MaybeCompact(_ context.Context, req CompactionRequest) (CompactionResult, error) {
|
||||
c.requests = append(c.requests, req)
|
||||
summary := strings.Repeat("oversized summary ", 300)
|
||||
@@ -147,6 +155,14 @@ func (c *oversizedCompactor) MaybeCompact(_ context.Context, req CompactionReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *oversizedCompactor) ContextWindowTokens(options map[string]any) int {
|
||||
return ResolveContextWindowTokens(options, 0)
|
||||
}
|
||||
func (c *oversizedCompactor) Threshold() float64 { return 0 }
|
||||
func (c *oversizedCompactor) ShouldCompact(_ CompactionRequest) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
type recordingApprovalPrompter struct {
|
||||
requests []ApprovalRequest
|
||||
results []Approval
|
||||
@@ -309,6 +325,20 @@ func (t namedApprovalTestTool) Execute(context.Context, ToolContext, map[string]
|
||||
return ToolResult{Content: "approved"}, nil
|
||||
}
|
||||
|
||||
// ApprovalScope mimics the Bash tool's command-scoping behavior so tests can
|
||||
// exercise the shell approval flow without importing the tools package.
|
||||
func (t namedApprovalTestTool) ApprovalScope(args map[string]any) string {
|
||||
if t.name == "bash" || t.name == "powershell" {
|
||||
if cmd, ok := args["command"].(string); ok {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" {
|
||||
return t.name + "\x00" + cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
return t.name
|
||||
}
|
||||
|
||||
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, req ApprovalRequest) (Approval, error) {
|
||||
p.requests = append(p.requests, req)
|
||||
if len(p.results) == 0 {
|
||||
@@ -1993,7 +2023,7 @@ func TestSessionAllowAllApprovalSkipsFuturePrompts(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !session.ApprovalState.AllowAll() {
|
||||
if !session.ApprovalState.AllGranted() {
|
||||
t.Fatal("session did not remember allow all")
|
||||
}
|
||||
if len(prompter.requests) != 1 {
|
||||
@@ -2050,7 +2080,7 @@ func TestSessionAllowToolApprovalSkipsFuturePromptForSameTool(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if session.ApprovalState.AllowAll() {
|
||||
if session.ApprovalState.AllGranted() {
|
||||
t.Fatal("allowing one tool enabled full access")
|
||||
}
|
||||
if !session.ApprovalState.Allows("approval_tool") {
|
||||
@@ -2110,7 +2140,7 @@ func TestSessionAllowShellApprovalScopesToExactCommand(t *testing.T) {
|
||||
registry.Register(namedApprovalTestTool{name: "bash"})
|
||||
prompter := &recordingApprovalPrompter{
|
||||
results: []Approval{
|
||||
{AllowScopes: []string{toolApprovalScope("bash", map[string]any{"command": "pwd"})}},
|
||||
{AllowScopes: []string{toolApprovalScope(namedApprovalTestTool{name: "bash"}, "bash", map[string]any{"command": "pwd"})}},
|
||||
{Allow: true},
|
||||
},
|
||||
}
|
||||
|
||||
+18
-8
@@ -53,7 +53,24 @@ func (b *Bash) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ApprovalScope scopes shell approval to the exact, trimmed command string
|
||||
// using a NUL separator: "<tool>\x00<command>". "Always allow this command"
|
||||
// matches ONLY that precise string — any whitespace, quoting, or casing
|
||||
// variant re-prompts. The NUL separator is safe because a shell command
|
||||
// string cannot contain a literal NUL.
|
||||
func (b *Bash) ApprovalScope(args map[string]any) string {
|
||||
name := b.Name()
|
||||
if command, ok := args["command"].(string); ok {
|
||||
command = strings.TrimSpace(command)
|
||||
if command != "" {
|
||||
return name + "\x00" + command
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (b *Bash) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "command" parameter (see agent package cleanup plan).
|
||||
command, ok := args["command"].(string)
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("command parameter is required")
|
||||
@@ -415,7 +432,7 @@ func (b *boundedOutput) String(label string) string {
|
||||
if omitted == 0 {
|
||||
return content
|
||||
}
|
||||
return content + fmt.Sprintf("\n\n[%s truncated: omitted ~%d tokens]", label, approximateTokensFromBytes(omitted))
|
||||
return content + agent.TruncMarker(label, safeLen, 0, omitted, false, "")
|
||||
}
|
||||
|
||||
func utf8SafePrefixLen(p []byte) int {
|
||||
@@ -431,10 +448,3 @@ func utf8SafePrefixLen(p []byte) int {
|
||||
}
|
||||
return len(p)
|
||||
}
|
||||
|
||||
func approximateTokensFromBytes(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (n+3)/4)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestBashBoundsOutputWhileRunning(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "[stdout truncated: omitted ~") || !strings.Contains(result.Content, " tokens]") {
|
||||
if !strings.Contains(result.Content, "[stdout truncated: showing first ~") || !strings.Contains(result.Content, "omitted ~") || !strings.Contains(result.Content, " tokens.]") {
|
||||
t.Fatalf("content = %q, want stdout truncation marker", result.Content)
|
||||
}
|
||||
if count, want := strings.Count(result.Content, "x"), shellTestCapturedXCount(); count != want {
|
||||
|
||||
@@ -58,6 +58,7 @@ func (r *Read) RequiresApproval(map[string]any) bool {
|
||||
}
|
||||
|
||||
func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg / agent.OptionalIntArg for args (see agent package cleanup plan).
|
||||
path, ok := args["path"].(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
@@ -141,6 +142,7 @@ func (e *Edit) RequiresApproval(map[string]any) bool {
|
||||
}
|
||||
|
||||
func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg / agent.OptionalBoolArg for args (see agent package cleanup plan).
|
||||
path, ok := args["path"].(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
|
||||
+7
-17
@@ -53,6 +53,7 @@ func (w *WebSearch) RequiresApproval(map[string]any) bool {
|
||||
}
|
||||
|
||||
func (w *WebSearch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "query" parameter (see agent package cleanup plan).
|
||||
if internalcloud.Disabled() {
|
||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web search is unavailable"))
|
||||
}
|
||||
@@ -130,6 +131,7 @@ func (w *WebFetch) RequiresApproval(map[string]any) bool {
|
||||
}
|
||||
|
||||
func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "url" parameter (see agent package cleanup plan).
|
||||
if internalcloud.Disabled() {
|
||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web fetch is unavailable"))
|
||||
}
|
||||
@@ -176,21 +178,9 @@ func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[st
|
||||
}
|
||||
|
||||
func truncateWebFetchContent(content string) string {
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxWebFetchContentRunes {
|
||||
return content
|
||||
}
|
||||
omitted := len(runes) - maxWebFetchContentRunes
|
||||
return string(runes[:maxWebFetchContentRunes]) + fmt.Sprintf(
|
||||
"\n\n[tool output truncated: showing first ~%d tokens; omitted ~%d tokens. Use a narrower request or search query if more detail is needed.]",
|
||||
approximateToolTokensFromRunes(maxWebFetchContentRunes),
|
||||
approximateToolTokensFromRunes(omitted),
|
||||
)
|
||||
}
|
||||
|
||||
func approximateToolTokensFromRunes(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (n+3)/4)
|
||||
return agent.Truncate(content, agent.TruncateConfig{
|
||||
MaxRunes: maxWebFetchContentRunes,
|
||||
Label: "tool output",
|
||||
Hint: "Use a narrower request or search query if more detail is needed.",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,11 +57,15 @@ func (m chatModel) allowAllToolsEnabled() bool {
|
||||
if m.approvalState == nil {
|
||||
return m.defaultAllowAll
|
||||
}
|
||||
return m.approvalState.AllowAll()
|
||||
return m.approvalState.AllGranted()
|
||||
}
|
||||
|
||||
func (m *chatModel) setAllowAllTools(allowAll bool) {
|
||||
m.ensureApprovalState().SetAllowAll(allowAll)
|
||||
if allowAll {
|
||||
m.ensureApprovalState().GrantAll()
|
||||
} else {
|
||||
m.ensureApprovalState().Set(false, nil)
|
||||
}
|
||||
m.opts.AllowAllTools = allowAll
|
||||
}
|
||||
|
||||
@@ -176,7 +180,7 @@ func (m chatModel) resolveApprovalPrompt(choice chatApprovalChoice) (tea.Model,
|
||||
}
|
||||
allowScopes := approvalScopes(prompt.request)
|
||||
if choice.allowTools {
|
||||
m.ensureApprovalState().AllowScopes(allowScopes)
|
||||
m.ensureApprovalState().GrantScopes(allowScopes)
|
||||
}
|
||||
for _, call := range prompt.request.Calls {
|
||||
if idx := m.findToolEntry(call.ToolCallID); idx >= 0 && m.entries[idx].status == "approval" {
|
||||
@@ -386,7 +390,7 @@ func (c *chatApprovalController) preapproved(request coreagent.ApprovalRequest)
|
||||
if c == nil {
|
||||
return coreagent.Approval{}, false
|
||||
}
|
||||
if c.state.AllowAll() {
|
||||
if c.state.AllGranted() {
|
||||
return coreagent.Approval{Allow: true, AllowAll: true}, true
|
||||
}
|
||||
scopes := approvalScopes(request)
|
||||
|
||||
@@ -392,7 +392,7 @@ func TestChatApprovalControllerAutoApprovesAfterFullAccessToggle(t *testing.T) {
|
||||
events := make(chan tea.Msg, 1)
|
||||
state := testApprovalState(false, nil)
|
||||
controller := newChatApprovalController(events, state)
|
||||
state.SetAllowAll(true)
|
||||
state.GrantAll()
|
||||
|
||||
result, err := controller.PromptApproval(context.Background(), testApprovalRequest())
|
||||
if err != nil {
|
||||
|
||||
@@ -75,8 +75,8 @@ func TestChatNewCommandRepaintsFromTop(t *testing.T) {
|
||||
if m.flowPrintedLines != 0 {
|
||||
t.Fatalf("flowPrintedLines = %d, want 0", m.flowPrintedLines)
|
||||
}
|
||||
if m.approvalState.AllowAll() || m.opts.AllowAllTools || m.approvalState.Allows("edit") || m.permissionNotice != "" {
|
||||
t.Fatalf("permissions were not reset: allowAll=%v opts=%v editAllowed=%v notice=%q", m.approvalState.AllowAll(), m.opts.AllowAllTools, m.approvalState.Allows("edit"), m.permissionNotice)
|
||||
if m.approvalState.AllGranted() || m.opts.AllowAllTools || m.approvalState.Allows("edit") || m.permissionNotice != "" {
|
||||
t.Fatalf("permissions were not reset: allowAll=%v opts=%v editAllowed=%v notice=%q", m.approvalState.AllGranted(), m.opts.AllowAllTools, m.approvalState.Allows("edit"), m.permissionNotice)
|
||||
}
|
||||
if msg := cmd(); msg == nil {
|
||||
t.Fatal("repaint command returned nil")
|
||||
@@ -92,10 +92,10 @@ func TestChatNewCommandPreservesLaunchFullAccessDefault(t *testing.T) {
|
||||
|
||||
updated, _ := m.handleSubmit()
|
||||
fm := updated.(chatModel)
|
||||
if !fm.approvalState.AllowAll() || !fm.opts.AllowAllTools {
|
||||
t.Fatalf("full access default was not restored: allowAll=%v opts=%v", fm.approvalState.AllowAll(), fm.opts.AllowAllTools)
|
||||
if !fm.approvalState.AllGranted() || !fm.opts.AllowAllTools {
|
||||
t.Fatalf("full access default was not restored: allowAll=%v opts=%v", fm.approvalState.AllGranted(), fm.opts.AllowAllTools)
|
||||
}
|
||||
fm.approvalState.SetAllowAll(false)
|
||||
fm.approvalState.Set(false, nil)
|
||||
if fm.approvalState.Allows("edit") {
|
||||
t.Fatal("edit scope should be cleared")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user