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:
viettranx committed 2026-02-27 12:11:25 +07:00
1 parent 86d58e1021
commit a4325e2abe
18 files changed
+284 -77

No files matched your search

+9 -2
View File
@@ -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.
+15
View File
@@ -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
+25 -6
View File
@@ -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)
}()
+24
View File
@@ -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
}
+2
View File
@@ -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
}
+46
View File
@@ -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))
+19
View File
@@ -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,
}
}
+35
View File
@@ -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()
+9
View File
@@ -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>
);
+2
View File
@@ -20,6 +20,8 @@ export interface SubagentsConfig {
export interface CompactionConfig {
reserveTokensFloor?: number;
maxHistoryShare?: number;
minMessages?: number;
keepLastMessages?: number;
memoryFlush?: {
enabled?: boolean;
softThresholdTokens?: number;