mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
feat: Improve agent context management with new compaction settings, session-level context window tracking, and calibrated token estimation for adaptive throttling.
This commit is contained in:
1 parent
86d58e1021
commit
a4325e2abe
18 files changed
+284
-77
No files matched your search
+9
-2
@@ -822,10 +822,17 @@ func runGateway() {
|
||||
|
||||
// Adaptive throttle: reduce per-session concurrency when nearing the summary threshold.
|
||||
// This prevents concurrent runs from racing with summarization.
|
||||
// Uses calibrated token estimation (actual prompt tokens from last LLM call)
|
||||
// and the agent's real context window (cached on session by the Loop).
|
||||
sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) {
|
||||
history := sessStore.GetHistory(sessionKey)
|
||||
tokens := agent.EstimateTokens(history)
|
||||
return tokens, 200000 // default context window
|
||||
lastPT, lastMC := sessStore.GetLastPromptTokens(sessionKey)
|
||||
tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC)
|
||||
cw := sessStore.GetContextWindow(sessionKey)
|
||||
if cw <= 0 {
|
||||
cw = 200000 // fallback for sessions not yet processed
|
||||
}
|
||||
return tokens, cw
|
||||
})
|
||||
|
||||
// Subscribe to agent events for channel streaming/reaction forwarding.
|
||||
|
||||
@@ -451,6 +451,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
)
|
||||
}
|
||||
|
||||
// 0. Cache agent's context window on the session (first run only).
|
||||
// Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K.
|
||||
if l.sessions.GetContextWindow(req.SessionKey) <= 0 {
|
||||
l.sessions.SetContextWindow(req.SessionKey, l.contextWindow)
|
||||
}
|
||||
|
||||
// 1. Build messages from session history
|
||||
history := l.sessions.GetHistory(req.SessionKey)
|
||||
summary := l.sessions.GetSummary(req.SessionKey)
|
||||
@@ -757,6 +763,15 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
// Write session metadata (matching TS session entry updates)
|
||||
l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel)
|
||||
l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens))
|
||||
|
||||
// Calibrate token estimation: store actual prompt tokens + message count.
|
||||
// Next time EstimateTokensWithCalibration() is called, it uses this as a base
|
||||
// instead of the chars/3 heuristic (more accurate for multilingual content).
|
||||
if totalUsage.PromptTokens > 0 {
|
||||
msgCount := len(history) + len(pendingMsgs)
|
||||
l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount)
|
||||
}
|
||||
|
||||
l.sessions.Save(req.SessionKey)
|
||||
|
||||
// Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md
|
||||
|
||||
@@ -263,10 +263,23 @@ func sanitizeHistory(msgs []providers.Message) []providers.Message {
|
||||
|
||||
func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
history := l.sessions.GetHistory(sessionKey)
|
||||
tokenEstimate := EstimateTokens(history)
|
||||
threshold := l.contextWindow * 75 / 100
|
||||
|
||||
if len(history) <= 50 && tokenEstimate <= threshold {
|
||||
// Use calibrated token estimation when available.
|
||||
lastPT, lastMC := l.sessions.GetLastPromptTokens(sessionKey)
|
||||
tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC)
|
||||
|
||||
// Resolve compaction thresholds from config with sensible defaults.
|
||||
historyShare := 0.75
|
||||
if l.compactionCfg != nil && l.compactionCfg.MaxHistoryShare > 0 {
|
||||
historyShare = l.compactionCfg.MaxHistoryShare
|
||||
}
|
||||
minMessages := 50
|
||||
if l.compactionCfg != nil && l.compactionCfg.MinMessages > 0 {
|
||||
minMessages = l.compactionCfg.MinMessages
|
||||
}
|
||||
|
||||
threshold := int(float64(l.contextWindow) * historyShare)
|
||||
if len(history) <= minMessages && tokenEstimate <= threshold {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -287,6 +300,12 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
l.runMemoryFlush(ctx, sessionKey, flushSettings)
|
||||
}
|
||||
|
||||
// Resolve keepLast before spawning goroutine (reads config under caller's scope).
|
||||
keepLast := 4
|
||||
if l.compactionCfg != nil && l.compactionCfg.KeepLastMessages > 0 {
|
||||
keepLast = l.compactionCfg.KeepLastMessages
|
||||
}
|
||||
|
||||
// Summarize in background (holds the per-session lock until done)
|
||||
go func() {
|
||||
defer sessionMu.Unlock()
|
||||
@@ -294,7 +313,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
// Re-check: history may have been truncated by a concurrent summarize
|
||||
// that finished between our threshold check and acquiring the lock.
|
||||
history := l.sessions.GetHistory(sessionKey)
|
||||
if len(history) <= 4 {
|
||||
if len(history) <= keepLast {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -302,7 +321,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
defer cancel()
|
||||
|
||||
summary := l.sessions.GetSummary(sessionKey)
|
||||
toSummarize := history[:len(history)-4]
|
||||
toSummarize := history[:len(history)-keepLast]
|
||||
|
||||
var sb string
|
||||
for _, m := range toSummarize {
|
||||
@@ -330,7 +349,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
}
|
||||
|
||||
l.sessions.SetSummary(sessionKey, SanitizeAssistantContent(resp.Content))
|
||||
l.sessions.TruncateHistory(sessionKey, 4)
|
||||
l.sessions.TruncateHistory(sessionKey, keepLast)
|
||||
l.sessions.IncrementCompaction(sessionKey)
|
||||
l.sessions.Save(sessionKey)
|
||||
}()
|
||||
|
||||
@@ -246,3 +246,27 @@ func EstimateTokens(messages []providers.Message) int {
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// EstimateTokensWithCalibration uses actual prompt tokens from the last LLM
|
||||
// response as a calibration base, then estimates only new messages on top.
|
||||
// Falls back to EstimateTokens() when no calibration data is available.
|
||||
func EstimateTokensWithCalibration(messages []providers.Message, lastPromptTokens, lastMsgCount int) int {
|
||||
if lastPromptTokens <= 0 || lastMsgCount <= 0 {
|
||||
return EstimateTokens(messages)
|
||||
}
|
||||
|
||||
currentCount := len(messages)
|
||||
newMsgs := currentCount - lastMsgCount
|
||||
if newMsgs <= 0 {
|
||||
// No new messages since last calibration (or history was truncated).
|
||||
// Use calibration value as-is; it's the best estimate we have.
|
||||
return lastPromptTokens
|
||||
}
|
||||
|
||||
// Estimate only the new messages with the heuristic and add to base.
|
||||
delta := 0
|
||||
for _, m := range messages[lastMsgCount:] {
|
||||
delta += utf8.RuneCountInString(m.Content) / 3
|
||||
}
|
||||
return lastPromptTokens + delta
|
||||
}
|
||||
@@ -137,6 +137,8 @@ type AgentDefaults struct {
|
||||
type CompactionConfig struct {
|
||||
ReserveTokensFloor int `json:"reserveTokensFloor,omitempty"` // min reserve tokens (default 20000)
|
||||
MaxHistoryShare float64 `json:"maxHistoryShare,omitempty"` // max share of context for history (default 0.75)
|
||||
MinMessages int `json:"minMessages,omitempty"` // min messages before compaction triggers (default 50)
|
||||
KeepLastMessages int `json:"keepLastMessages,omitempty"` // messages to keep after compaction (default 4)
|
||||
MemoryFlush *MemoryFlushConfig `json:"memoryFlush,omitempty"` // pre-compaction flush
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ type Session struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
SpawnedBy string `json:"spawnedBy,omitempty"`
|
||||
SpawnDepth int `json:"spawnDepth,omitempty"`
|
||||
|
||||
ContextWindow int `json:"contextWindow,omitempty"`
|
||||
LastPromptTokens int `json:"lastPromptTokens,omitempty"`
|
||||
LastMessageCount int `json:"lastMessageCount,omitempty"`
|
||||
}
|
||||
|
||||
// Manager handles session lifecycle, persistence, and lookup.
|
||||
@@ -217,6 +221,45 @@ func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetContextWindow caches the agent's context window on the session.
|
||||
func (m *Manager) SetContextWindow(key string, cw int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
s.ContextWindow = cw
|
||||
}
|
||||
}
|
||||
|
||||
// GetContextWindow returns the cached context window for a session (0 if unset).
|
||||
func (m *Manager) GetContextWindow(key string) int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
return s.ContextWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SetLastPromptTokens records actual prompt tokens from the last LLM response.
|
||||
func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
s.LastPromptTokens = tokens
|
||||
s.LastMessageCount = msgCount
|
||||
}
|
||||
}
|
||||
|
||||
// GetLastPromptTokens returns the last known prompt tokens and message count.
|
||||
func (m *Manager) GetLastPromptTokens(key string) (int, int) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
return s.LastPromptTokens, s.LastMessageCount
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// TruncateHistory keeps only the last N messages.
|
||||
func (m *Manager) TruncateHistory(key string, keepLast int) {
|
||||
m.mu.Lock()
|
||||
@@ -364,6 +407,9 @@ func (m *Manager) Save(key string) error {
|
||||
Label: s.Label,
|
||||
SpawnedBy: s.SpawnedBy,
|
||||
SpawnDepth: s.SpawnDepth,
|
||||
ContextWindow: s.ContextWindow,
|
||||
LastPromptTokens: s.LastPromptTokens,
|
||||
LastMessageCount: s.LastMessageCount,
|
||||
}
|
||||
if len(s.Messages) > 0 {
|
||||
snapshot.Messages = make([]providers.Message, len(s.Messages))
|
||||
|
||||
@@ -74,6 +74,22 @@ func (f *FileSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) {
|
||||
f.mgr.SetSpawnInfo(key, spawnedBy, depth)
|
||||
}
|
||||
|
||||
func (f *FileSessionStore) SetContextWindow(key string, cw int) {
|
||||
f.mgr.SetContextWindow(key, cw)
|
||||
}
|
||||
|
||||
func (f *FileSessionStore) GetContextWindow(key string) int {
|
||||
return f.mgr.GetContextWindow(key)
|
||||
}
|
||||
|
||||
func (f *FileSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) {
|
||||
f.mgr.SetLastPromptTokens(key, tokens, msgCount)
|
||||
}
|
||||
|
||||
func (f *FileSessionStore) GetLastPromptTokens(key string) (int, int) {
|
||||
return f.mgr.GetLastPromptTokens(key)
|
||||
}
|
||||
|
||||
func (f *FileSessionStore) TruncateHistory(key string, keepLast int) {
|
||||
f.mgr.TruncateHistory(key, keepLast)
|
||||
}
|
||||
@@ -154,5 +170,8 @@ func sessionToData(s *sessions.Session) *store.SessionData {
|
||||
Label: s.Label,
|
||||
SpawnedBy: s.SpawnedBy,
|
||||
SpawnDepth: s.SpawnDepth,
|
||||
ContextWindow: s.ContextWindow,
|
||||
LastPromptTokens: s.LastPromptTokens,
|
||||
LastMessageCount: s.LastMessageCount,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,41 @@ func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) SetContextWindow(key string, cw int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if data, ok := s.cache[key]; ok {
|
||||
data.ContextWindow = cw
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) GetContextWindow(key string) int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if data, ok := s.cache[key]; ok {
|
||||
return data.ContextWindow
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if data, ok := s.cache[key]; ok {
|
||||
data.LastPromptTokens = tokens
|
||||
data.LastMessageCount = msgCount
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if data, ok := s.cache[key]; ok {
|
||||
return data.LastPromptTokens, data.LastMessageCount
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func (s *PGSessionStore) TruncateHistory(key string, keepLast int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -29,6 +29,11 @@ type SessionData struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
SpawnedBy string `json:"spawnedBy,omitempty"`
|
||||
SpawnDepth int `json:"spawnDepth,omitempty"`
|
||||
|
||||
// Adaptive throttle: cached per-session so scheduler reads without DB lookup.
|
||||
ContextWindow int `json:"contextWindow,omitempty"` // agent's context window (set on first run)
|
||||
LastPromptTokens int `json:"lastPromptTokens,omitempty"` // actual prompt tokens from last LLM response
|
||||
LastMessageCount int `json:"lastMessageCount,omitempty"` // message count at time of last LLM call
|
||||
}
|
||||
|
||||
// SessionInfo is lightweight session metadata for listing.
|
||||
@@ -68,6 +73,10 @@ type SessionStore interface {
|
||||
GetMemoryFlushCompactionCount(key string) int
|
||||
SetMemoryFlushDone(key string)
|
||||
SetSpawnInfo(key, spawnedBy string, depth int)
|
||||
SetContextWindow(key string, cw int)
|
||||
GetContextWindow(key string) int
|
||||
SetLastPromptTokens(key string, tokens, msgCount int)
|
||||
GetLastPromptTokens(key string) (tokens, msgCount int)
|
||||
TruncateHistory(key string, keepLast int)
|
||||
Reset(key string)
|
||||
Delete(key string) error
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { CompactionConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface CompactionSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -21,7 +20,7 @@ export function CompactionSection({ enabled, value, onToggle, onChange }: Compac
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Reserve Tokens Floor</Label>
|
||||
<InfoLabel tip="Minimum tokens reserved for the LLM response. Higher values give more room for output but reduce available context for history.">Reserve Tokens Floor</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="20000"
|
||||
@@ -30,7 +29,7 @@ export function CompactionSection({ enabled, value, onToggle, onChange }: Compac
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max History Share (0-1)</Label>
|
||||
<InfoLabel tip="Maximum fraction of the context window used for conversation history before compaction triggers (e.g. 0.75 = 75%).">Max History Share (0-1)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
@@ -40,6 +39,26 @@ export function CompactionSection({ enabled, value, onToggle, onChange }: Compac
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Minimum number of messages in history before compaction can trigger, regardless of token usage.">Min Messages</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="50"
|
||||
value={value.minMessages ?? ""}
|
||||
onChange={(e) => onChange({ ...value, minMessages: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip="Number of recent messages to preserve after compaction. The rest is replaced by a summary.">Keep Last Messages</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4"
|
||||
value={value.keepLastMessages ?? ""}
|
||||
onChange={(e) => onChange({ ...value, keepLastMessages: numOrUndef(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@@ -48,11 +67,11 @@ export function CompactionSection({ enabled, value, onToggle, onChange }: Compac
|
||||
onChange({ ...value, memoryFlush: { ...value.memoryFlush, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<Label>Memory Flush</Label>
|
||||
<InfoLabel tip="When enabled, compacted history is also saved to long-term memory for future retrieval.">Memory Flush</InfoLabel>
|
||||
</div>
|
||||
{value.memoryFlush?.enabled && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label>Soft Threshold (tokens)</Label>
|
||||
<InfoLabel tip="Token count threshold that triggers memory flush. When summary exceeds this, older memories are flushed to storage.">Soft Threshold (tokens)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4000"
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
import { Info } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
/** Label with an (i) tooltip icon for field descriptions. */
|
||||
export function InfoLabel({ children, tip }: { children: React.ReactNode; tip: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label>{children}</Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-3.5 w-3.5 shrink-0 cursor-help text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
{tip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfigSectionProps {
|
||||
title: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ContextPruningConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface ContextPruningSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -28,7 +27,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode</Label>
|
||||
<InfoLabel tip="Pruning strategy. 'cache-ttl' trims old tool results based on their position in the conversation.">Mode</InfoLabel>
|
||||
<Select
|
||||
value={value.mode ?? ""}
|
||||
onValueChange={(v) =>
|
||||
@@ -43,7 +42,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Keep Last Assistants</Label>
|
||||
<InfoLabel tip="Number of recent assistant turns whose tool results are always kept intact, never pruned.">Keep Last Assistants</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
@@ -56,7 +55,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Soft Trim Ratio (0-1)</Label>
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which soft trimming begins. E.g. 0.3 means trimming starts when context is 30% full.">Soft Trim Ratio (0-1)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
@@ -66,7 +65,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Hard Clear Ratio (0-1)</Label>
|
||||
<InfoLabel tip="Context usage ratio (0-1) at which hard clearing kicks in. E.g. 0.5 means full clearing at 50% context usage.">Hard Clear Ratio (0-1)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
@@ -77,7 +76,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Min Prunable Tool Chars</Label>
|
||||
<InfoLabel tip="Only tool results with at least this many characters are eligible for pruning. Shorter results are left untouched.">Min Prunable Tool Chars</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="50000"
|
||||
@@ -93,7 +92,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
<h4 className="text-xs font-medium text-muted-foreground">Soft Trim</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Max Chars</Label>
|
||||
<InfoLabel tip="Tool results longer than this will be soft-trimmed, keeping only head and tail portions.">Max Chars</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4000"
|
||||
@@ -104,7 +103,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Head Chars</Label>
|
||||
<InfoLabel tip="Number of characters to keep from the beginning of a trimmed tool result.">Head Chars</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
@@ -115,7 +114,7 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Tail Chars</Label>
|
||||
<InfoLabel tip="Number of characters to keep from the end of a trimmed tool result.">Tail Chars</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1500"
|
||||
@@ -126,9 +125,6 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tool results longer than Max Chars get trimmed, keeping Head + Tail chars.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hard Clear */}
|
||||
@@ -141,10 +137,10 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
onChange({ ...value, hardClear: { ...value.hardClear, enabled: v } })
|
||||
}
|
||||
/>
|
||||
<Label>Enabled</Label>
|
||||
<InfoLabel tip="When enabled, old tool results beyond the hard clear ratio are replaced entirely with placeholder text.">Enabled</InfoLabel>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Placeholder Text</Label>
|
||||
<InfoLabel tip="Text that replaces cleared tool results. Helps the agent understand content was removed.">Placeholder Text</InfoLabel>
|
||||
<Input
|
||||
placeholder="[Old tool result content cleared]"
|
||||
value={value.hardClear?.placeholder ?? ""}
|
||||
@@ -152,9 +148,6 @@ export function ContextPruningSection({ enabled, value, onToggle, onChange }: Co
|
||||
onChange({ ...value, hardClear: { ...value.hardClear, placeholder: e.target.value || undefined } })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replacement text when old tool results are cleared entirely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { MemoryConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface MemorySectionProps {
|
||||
enabled: boolean;
|
||||
@@ -24,11 +23,11 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
checked={value.enabled ?? true}
|
||||
onCheckedChange={(v) => onChange({ ...value, enabled: v })}
|
||||
/>
|
||||
<Label>Enabled</Label>
|
||||
<InfoLabel tip="Enable or disable the memory system for this agent. When enabled, the agent can store and recall information across sessions.">Enabled</InfoLabel>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Embedding Provider</Label>
|
||||
<InfoLabel tip="LLM provider used for generating text embeddings. Leave empty to auto-detect from the agent's main provider.">Embedding Provider</InfoLabel>
|
||||
<Input
|
||||
placeholder="(auto)"
|
||||
value={value.embedding_provider ?? ""}
|
||||
@@ -36,7 +35,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Embedding Model</Label>
|
||||
<InfoLabel tip="Embedding model name. Must be supported by the embedding provider (e.g. text-embedding-3-small for OpenAI).">Embedding Model</InfoLabel>
|
||||
<Input
|
||||
placeholder="text-embedding-3-small"
|
||||
value={value.embedding_model ?? ""}
|
||||
@@ -46,7 +45,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Max Results</Label>
|
||||
<InfoLabel tip="Maximum number of memory entries returned per search query.">Max Results</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="6"
|
||||
@@ -55,7 +54,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max Chunk Length</Label>
|
||||
<InfoLabel tip="Maximum character length for each memory chunk. Longer content is split into smaller chunks before storing.">Max Chunk Length</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="1000"
|
||||
@@ -66,7 +65,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Vector Weight</Label>
|
||||
<InfoLabel tip="Weight for vector (semantic) similarity in hybrid search scoring. Higher values prioritize meaning over keywords.">Vector Weight</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
@@ -76,7 +75,7 @@ export function MemorySection({ enabled, value, onToggle, onChange }: MemorySect
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Text Weight</Label>
|
||||
<InfoLabel tip="Weight for text (keyword/BM25) similarity in hybrid search scoring. Higher values prioritize exact keyword matches.">Text Weight</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { QualityGateConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface QualityGatesSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -70,7 +69,7 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<InfoLabel tip="Validation method. 'Agent' delegates to a reviewer agent, 'Command' runs a shell command and checks exit code.">Type</InfoLabel>
|
||||
<Select
|
||||
value={gate.type}
|
||||
onValueChange={(v) => updateGate(i, {
|
||||
@@ -89,7 +88,7 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual
|
||||
<div className="space-y-2">
|
||||
{gate.type === "agent" ? (
|
||||
<>
|
||||
<Label>Reviewer Agent Key</Label>
|
||||
<InfoLabel tip="Key of the agent used to review and validate the delegation output.">Reviewer Agent Key</InfoLabel>
|
||||
<Input
|
||||
placeholder="qa-reviewer"
|
||||
value={gate.agent ?? ""}
|
||||
@@ -98,7 +97,7 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Label>Command</Label>
|
||||
<InfoLabel tip="Shell command to run for validation. Exit code 0 = pass, non-zero = fail.">Command</InfoLabel>
|
||||
<Input
|
||||
placeholder="npm test"
|
||||
value={gate.command ?? ""}
|
||||
@@ -114,11 +113,11 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual
|
||||
checked={gate.block_on_failure}
|
||||
onCheckedChange={(v) => updateGate(i, { block_on_failure: v })}
|
||||
/>
|
||||
<Label className="text-sm">Block on Failure</Label>
|
||||
<InfoLabel tip="When enabled, a failed gate prevents the result from being returned to the caller and triggers retries.">Block on Failure</InfoLabel>
|
||||
</div>
|
||||
{gate.block_on_failure && (
|
||||
<div className="space-y-2">
|
||||
<Label>Max Retries</Label>
|
||||
<InfoLabel tip="Number of times to retry the delegation if the quality gate fails.">Max Retries</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="2"
|
||||
@@ -130,7 +129,7 @@ export function QualityGatesSection({ enabled, value, onToggle, onChange }: Qual
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Timeout (seconds)</Label>
|
||||
<InfoLabel tip="Maximum time in seconds to wait for the quality gate check to complete before timing out.">Timeout (seconds)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="120"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { SandboxConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface SandboxSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -28,7 +27,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Mode</Label>
|
||||
<InfoLabel tip="'off' disables sandboxing, 'non-main' sandboxes only sub-agents, 'all' sandboxes every execution including the main agent.">Mode</InfoLabel>
|
||||
<Select
|
||||
value={value.mode ?? ""}
|
||||
onValueChange={(v) => onChange({ ...value, mode: v as SandboxConfig["mode"] })}
|
||||
@@ -42,7 +41,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Workspace Access</Label>
|
||||
<InfoLabel tip="How the sandbox accesses the host workspace. 'none' = isolated, 'ro' = read-only mount, 'rw' = full read-write access.">Workspace Access</InfoLabel>
|
||||
<Select
|
||||
value={value.workspace_access ?? ""}
|
||||
onValueChange={(v) =>
|
||||
@@ -59,7 +58,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Image</Label>
|
||||
<InfoLabel tip="Docker image used for the sandbox container. Must be pre-built and available locally.">Image</InfoLabel>
|
||||
<Input
|
||||
placeholder="goclaw-sandbox:bookworm-slim"
|
||||
value={value.image ?? ""}
|
||||
@@ -68,7 +67,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Scope</Label>
|
||||
<InfoLabel tip="Container lifecycle scope. 'session' = one container per chat session, 'agent' = shared across sessions, 'shared' = shared across all agents.">Scope</InfoLabel>
|
||||
<Select
|
||||
value={value.scope ?? ""}
|
||||
onValueChange={(v) => onChange({ ...value, scope: v as SandboxConfig["scope"] })}
|
||||
@@ -82,7 +81,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timeout (sec)</Label>
|
||||
<InfoLabel tip="Maximum execution time in seconds for each command run inside the sandbox.">Timeout (sec)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="300"
|
||||
@@ -93,7 +92,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Memory (MB)</Label>
|
||||
<InfoLabel tip="Maximum memory allocation for the sandbox container in megabytes.">Memory (MB)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="512"
|
||||
@@ -102,7 +101,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>CPUs</Label>
|
||||
<InfoLabel tip="CPU allocation for the sandbox container. Fractional values allowed (e.g. 0.5 = half a core).">CPUs</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
@@ -117,7 +116,7 @@ export function SandboxSection({ enabled, value, onToggle, onChange }: SandboxSe
|
||||
checked={value.network_enabled ?? false}
|
||||
onCheckedChange={(v) => onChange({ ...value, network_enabled: v })}
|
||||
/>
|
||||
<Label>Network Enabled</Label>
|
||||
<InfoLabel tip="Allow the sandbox container to access the network. Disable for fully isolated execution.">Network Enabled</InfoLabel>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { SubagentsConfig } from "@/types/agent";
|
||||
import { ConfigSection, numOrUndef } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, numOrUndef } from "./config-section";
|
||||
|
||||
interface SubagentsSectionProps {
|
||||
enabled: boolean;
|
||||
@@ -27,7 +26,7 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Max Concurrent</Label>
|
||||
<InfoLabel tip="Maximum number of sub-agents that can run simultaneously for this agent.">Max Concurrent</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="8"
|
||||
@@ -36,7 +35,7 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Max Spawn Depth</Label>
|
||||
<InfoLabel tip="How many levels deep sub-agents can spawn other sub-agents. Depth 1 means only the parent can spawn.">Max Spawn Depth</InfoLabel>
|
||||
<Select
|
||||
value={String(value.maxSpawnDepth ?? "")}
|
||||
onValueChange={(v) => onChange({ ...value, maxSpawnDepth: Number(v) })}
|
||||
@@ -52,7 +51,7 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Max Children Per Agent</Label>
|
||||
<InfoLabel tip="Maximum number of sub-agents a single parent agent can spawn in one session.">Max Children Per Agent</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="5"
|
||||
@@ -61,7 +60,7 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Archive After (minutes)</Label>
|
||||
<InfoLabel tip="Idle time in minutes before a sub-agent session is automatically archived and cleaned up.">Archive After (minutes)</InfoLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="60"
|
||||
@@ -71,15 +70,12 @@ export function SubagentsSection({ enabled, value, onToggle, onChange }: Subagen
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Model Override</Label>
|
||||
<InfoLabel tip="LLM model for sub-agents. Leave empty to inherit the parent agent's model.">Model Override</InfoLabel>
|
||||
<Input
|
||||
placeholder="(inherit from agent)"
|
||||
value={value.model ?? ""}
|
||||
onChange={(e) => onChange({ ...value, model: e.target.value || undefined })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LLM model for sub-agents. Leave empty to inherit the parent agent's model.
|
||||
</p>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ToolPolicyConfig } from "@/types/agent";
|
||||
import { ConfigSection, arrayToTags, tagsToArray } from "./config-section";
|
||||
import { ConfigSection, InfoLabel, arrayToTags, tagsToArray } from "./config-section";
|
||||
|
||||
interface ToolPolicySectionProps {
|
||||
enabled: boolean;
|
||||
@@ -26,7 +25,7 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Profile</Label>
|
||||
<InfoLabel tip="Base tool profile. 'default' includes common tools, 'strict' limits to safe tools only, 'permissive' allows all tools.">Profile</InfoLabel>
|
||||
<Select
|
||||
value={value.profile ?? ""}
|
||||
onValueChange={(v) => onChange({ ...value, profile: v || undefined })}
|
||||
@@ -40,16 +39,15 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Allow</Label>
|
||||
<InfoLabel tip="Comma-separated allowlist. Only these tools will be available (overrides profile). Leave empty to use profile defaults.">Allow</InfoLabel>
|
||||
<Input
|
||||
placeholder="tool1, tool2, ..."
|
||||
value={arrayToTags(value.allow)}
|
||||
onChange={(e) => onChange({ ...value, allow: tagsToArray(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of allowed tools.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Deny</Label>
|
||||
<InfoLabel tip="Comma-separated denylist. These tools will be blocked even if allowed by the profile.">Deny</InfoLabel>
|
||||
<Input
|
||||
placeholder="tool1, tool2, ..."
|
||||
value={arrayToTags(value.deny)}
|
||||
@@ -57,13 +55,12 @@ export function ToolPolicySection({ enabled, value, onToggle, onChange }: ToolPo
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Also Allow</Label>
|
||||
<InfoLabel tip="Additional tools added on top of the profile defaults. Useful for enabling optional tools like web_fetch without overriding the whole profile.">Also Allow</InfoLabel>
|
||||
<Input
|
||||
placeholder="web_fetch, web_search, ..."
|
||||
value={arrayToTags(value.alsoAllow)}
|
||||
onChange={(e) => onChange({ ...value, alsoAllow: tagsToArray(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Additional tools to add on top of profile defaults.</p>
|
||||
</div>
|
||||
</ConfigSection>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface SubagentsConfig {
|
||||
export interface CompactionConfig {
|
||||
reserveTokensFloor?: number;
|
||||
maxHistoryShare?: number;
|
||||
minMessages?: number;
|
||||
keepLastMessages?: number;
|
||||
memoryFlush?: {
|
||||
enabled?: boolean;
|
||||
softThresholdTokens?: number;
|
||||
|
||||
Reference in new issue
Block a user