diff --git a/cmd/gateway_consumer_post_turn.go b/cmd/gateway_consumer_post_turn.go index faada3ce..1b84a8ef 100644 --- a/cmd/gateway_consumer_post_turn.go +++ b/cmd/gateway_consumer_post_turn.go @@ -129,10 +129,11 @@ func resolveTeamTaskOutcome( // Smart post-turn decision based on action flags. // Only error, completed/escalated, and reviewed block auto-complete. + runFailure := outcome.Failure() switch { - case outcome.Err != nil: - // Agent errored → auto-fail. - if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, outcome.Err.Error()); err != nil { + case runFailure != nil: + // Agent errored or the pipeline stopped the run → auto-fail. + if err := deps.TeamStore.FailTask(ctx, meta.TaskID, meta.TeamID, runFailure.Error()); err != nil { slog.Warn("auto-complete: FailTask error", "task_id", meta.TaskID, "error", err) } else { bus.BroadcastForTenant(deps.MsgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), tools.BuildTaskEventPayload( @@ -140,7 +141,7 @@ func resolveTeamTaskOutcome( store.TeamTaskStatusFailed, "agent", toAgent, tools.WithTaskInfo(taskNumber, taskSubject), - tools.WithReason(outcome.Err.Error()), + tools.WithReason(runFailure.Error()), tools.WithChannel(taskChannel), tools.WithChatID(taskChatID), tools.WithPeerKind(taskPeerKind), diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go index 07d3aec9..8c8b36b7 100644 --- a/cmd/gateway_cron.go +++ b/cmd/gateway_cron.go @@ -191,8 +191,8 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg case <-cronCtx.Done(): return nil, fmt.Errorf("cron job %s timed out after %s", job.Name, jobTimeout) } - if outcome.Err != nil { - return nil, outcome.Err + if err := outcome.Failure(); err != nil { + return nil, err } result := outcome.Result diff --git a/cmd/gateway_cron_test.go b/cmd/gateway_cron_test.go index 91af25f1..f18c3ac1 100644 --- a/cmd/gateway_cron_test.go +++ b/cmd/gateway_cron_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "strings" "testing" "time" @@ -235,6 +236,45 @@ func TestCronJobHandlerSuppressesNoReplyDelivery(t *testing.T) { } } +// A run stopped by the context budget guard is a failed cron run: record the +// error and do not deliver the "start a new session" notice to the channel. +func TestCronJobHandler_PipelineStopIsFailure(t *testing.T) { + mb := bus.New() + defer mb.Close() + + sched := scheduler.NewScheduler( + scheduler.DefaultLanes(), + scheduler.QueueConfig{Mode: scheduler.QueueModeQueue, Cap: 1, Drop: scheduler.DropOld, MaxConcurrent: 1}, + func(context.Context, agent.RunRequest) (*agent.RunResult, error) { + return &agent.RunResult{Content: "context budget notice", StopReason: "final request context budget exceeded"}, nil + }, + ) + defer sched.Stop() + + handler := makeCronJobHandler(sched, mb, &config.Config{}, nil, nil, nil, nil, nil, nil) + _, err := handler(&store.CronJob{ + ID: uuid.NewString(), + TenantID: uuid.New(), + Name: "daily-report", + AgentID: "reporter", + UserID: "user-1", + Stateless: true, + Deliver: true, + DeliverChannel: "telegram", + DeliverTo: "chat-1", + Payload: store.CronPayload{Kind: "agent_turn", Message: "daily report"}, + }) + if err == nil || !strings.Contains(err.Error(), "context budget exceeded") { + t.Fatalf("handler error = %v, want the pipeline stop reason", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if got, ok := mb.SubscribeOutbound(ctx); ok { + t.Fatalf("unexpected outbound message: %#v", got) + } +} + // fakeCronSessionStore records Reset calls. The embedded nil SessionStore // satisfies the interface; the cron handler only calls Reset/Save. type fakeCronSessionStore struct { diff --git a/docs/01-agent-loop.md b/docs/01-agent-loop.md index 998bd471..be05a8de 100644 --- a/docs/01-agent-loop.md +++ b/docs/01-agent-loop.md @@ -64,12 +64,13 @@ Finalize (runs once, uses background context if cancelled) - Call LLM, record span with token counts - Emit `chunk` events (streaming) or single response -**PruneStage** (opt-in via `contextPruning.mode: "cache-ttl"`) -- Estimate token ratio vs context window -- If >= 25%, run soft trim pass (keep first/last 3000 chars, replace middle with "...") +**PruneStage** (pruning enabled by default; disable with `contextPruning.mode: "off"`) +- Count history with the same `BudgetCounter` as ThinkStage's request guard +- If >= 30%, run soft trim pass (keep first/last 3000 chars, replace middle with "...") - If >= 50%, run hard clear pass (replace with placeholder) - Run sanitizeHistory to fix broken tool_use/tool_result pairs after prune - Trigger memory flush (synchronous) if compaction threshold exceeded +- Never stops the run: if compaction cannot bring history under budget, ThinkStage's request guard decides **ToolStage** - Execute single tool sequentially (no goroutine overhead) @@ -430,13 +431,13 @@ Repairs tool message pairing that may have been broken by truncation or compacti ## 6. Context Pruning -Context pruning reduces oversized tool results using a 2-pass algorithm. **It is opt-in** — configure `contextPruning.mode: "cache-ttl"` to enable. When disabled (default), zero overhead. Owned by PruneStage in the agent pipeline. +Context pruning reduces oversized tool results using a 2-pass algorithm. **It is enabled by default** (an unset `contextPruning.mode` prunes like `"cache-ttl"`, without the prompt-cache TTL gate); set `contextPruning.mode: "off"` to disable it with zero overhead. Owned by PruneStage in the agent pipeline. ```mermaid flowchart TD START[Check mode == cache-ttl?] --> GATE{Mode enabled?} GATE -->|No| SKIP[No pruning - zero overhead] - GATE -->|Yes| CHECK{Ratio >= softTrimRatio 0.25?} + GATE -->|Yes| CHECK{Ratio >= softTrimRatio 0.3?} CHECK -->|No| DONE[No pruning needed] CHECK -->|Yes| PASS1 @@ -451,12 +452,12 @@ flowchart TD ### Configuration -Enable pruning by setting `contextPruning.mode` in agent defaults: +Disable pruning by setting `contextPruning.mode` in agent defaults (per-agent `context_pruning` overrides it): ```json5 agents: { defaults: { - contextPruning: { mode: "cache-ttl" } + contextPruning: { mode: "off" } } } ``` @@ -465,9 +466,9 @@ agents: { | Parameter | Default | Description | |-----------|---------|-------------| -| `mode` | `""` (disabled) | `""` or `"off"` = disabled; `"cache-ttl"` = enabled | +| `mode` | `""` (enabled) | `""` or `"cache-ttl"` = enabled; `"off"` = disabled | | `keepLastAssistants` | 3 | Number of recent assistant messages protected from pruning | -| `softTrimRatio` | 0.25 | Token ratio threshold to trigger Pass 1 | +| `softTrimRatio` | 0.3 | Token ratio threshold to trigger Pass 1 | | `hardClearRatio` | 0.5 | Token ratio threshold to trigger Pass 2 | | `minPrunableToolChars` | 50,000 | Minimum tool result length eligible for hard clear | @@ -495,6 +496,8 @@ Trigger: Once per run, inside the iteration loop (between LLM calls) Output: In-memory messages replaced with [summary] + [recent 4 messages] ``` +If there is no clean split point (for example the history is only tool call/result pairs), compaction returns `ErrNotCompacted` and nothing is recorded as compacted. When the final request still exceeds the budget after every reduction step (prune, compact, shrink memory), ThinkStage stops the run with the localized `chat.context_budget_exceeded` notice instead of an error: the run's tool results are still persisted, and the trace is marked `error` with the stop reason. Memory flush and compaction attempts appear in the trace as `memory_flush` and `mid_loop_compaction` event spans. + ### Post-Run Compaction (After Completion) When the session history exceeds thresholds **after** a run completes, the session is compacted in the background. diff --git a/internal/agent/loop_compact_not_compacted_test.go b/internal/agent/loop_compact_not_compacted_test.go new file mode 100644 index 00000000..5d28fced --- /dev/null +++ b/internal/agent/loop_compact_not_compacted_test.go @@ -0,0 +1,33 @@ +package agent + +import ( + "context" + "errors" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/pipeline" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// A run made only of tool call/result pairs has no clean split point, so +// compaction cannot summarize anything and must say so explicitly. +func TestMakeCompactMessages_NoCleanBoundary_ReturnsErrNotCompacted(t *testing.T) { + t.Parallel() + history := []providers.Message{{Role: "user", Content: "compare pages"}} + for _, id := range []string{"c1", "c2", "c3"} { + history = append(history, + providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{ID: id, Name: "read_file"}}}, + providers.Message{Role: "tool", ToolCallID: id, Content: "result"}, + ) + } + + compact := (&Loop{}).makeCompactMessages(nil) + got, err := compact(context.Background(), history, "test-model") + + if !errors.Is(err, pipeline.ErrNotCompacted) { + t.Fatalf("err = %v, want pipeline.ErrNotCompacted", err) + } + if got != nil { + t.Fatalf("got %d messages, want nil when nothing was compacted", len(got)) + } +} diff --git a/internal/agent/loop_context_budget_stop_test.go b/internal/agent/loop_context_budget_stop_test.go new file mode 100644 index 00000000..bfd78bef --- /dev/null +++ b/internal/agent/loop_context_budget_stop_test.go @@ -0,0 +1,130 @@ +package agent + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/pipeline" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +// spanCaptureStore records spans and span updates flushed by a tracing.Collector. +type spanCaptureStore struct { + store.TracingStore + + mu sync.Mutex + spans []store.SpanData + updates map[uuid.UUID]map[string]any +} + +func (c *spanCaptureStore) BatchCreateSpans(_ context.Context, spans []store.SpanData) error { + c.mu.Lock() + defer c.mu.Unlock() + c.spans = append(c.spans, spans...) + return nil +} + +func (c *spanCaptureStore) UpdateSpan(_ context.Context, spanID uuid.UUID, updates map[string]any) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.updates == nil { + c.updates = map[uuid.UUID]map[string]any{} + } + c.updates[spanID] = updates + return nil +} + +func (c *spanCaptureStore) BatchUpdateTraceAggregates(context.Context, uuid.UUID) error { return nil } +func (c *spanCaptureStore) DeleteTracesOlderThan(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (c *spanCaptureStore) RecoverStaleRunningTraces(context.Context, time.Time) (int64, error) { + return 0, nil +} + +func newCaptureCtx(t *testing.T) (context.Context, *tracing.Collector, *spanCaptureStore) { + t.Helper() + cs := &spanCaptureStore{} + c := tracing.NewCollector(cs) + c.Start() + ctx := tracing.WithCollector(context.Background(), c) + ctx = tracing.WithTraceID(ctx, uuid.New()) + return ctx, c, cs +} + +func TestConvertRunResult_CopiesStopReason(t *testing.T) { + t.Parallel() + got := convertRunResult(&pipeline.RunResult{Content: "notice", StopReason: "final request context budget exceeded"}) + if got.StopReason != "final request context budget exceeded" { + t.Fatalf("StopReason = %q, want it copied from the pipeline result", got.StopReason) + } +} + +func TestRunTraceStatus(t *testing.T) { + t.Parallel() + status, errMsg := runTraceStatus(&RunResult{StopReason: "context budget exceeded"}) + if status != store.TraceStatusError || errMsg != "context budget exceeded" { + t.Errorf("stopped run: got (%q, %q), want (%q, reason)", status, errMsg, store.TraceStatusError) + } + status, errMsg = runTraceStatus(&RunResult{Content: "answer"}) + if status != store.TraceStatusCompleted || errMsg != "" { + t.Errorf("normal run: got (%q, %q), want (%q, \"\")", status, errMsg, store.TraceStatusCompleted) + } + if status, _ = runTraceStatus(nil); status != store.TraceStatusCompleted { + t.Errorf("nil result: got %q, want %q", status, store.TraceStatusCompleted) + } +} + +func TestEmitAgentSpanEnd_StoppedRunMarksSpanError(t *testing.T) { + t.Parallel() + ctx, c, cs := newCaptureCtx(t) + spanID := uuid.New() + + (&Loop{}).emitAgentSpanEnd(ctx, spanID, time.Now(), &RunResult{Content: "notice", StopReason: "context budget exceeded"}, nil) + c.Stop() + + cs.mu.Lock() + defer cs.mu.Unlock() + updates := cs.updates[spanID] + if updates["status"] != store.SpanStatusError { + t.Errorf("status = %v, want %q", updates["status"], store.SpanStatusError) + } + if updates["error"] != "context budget exceeded" { + t.Errorf("error = %v, want the stop reason", updates["error"]) + } + if updates["output_preview"] != "notice" { + t.Errorf("output_preview = %v, want the delivered notice", updates["output_preview"]) + } +} + +func TestMakeCompactMessages_EmitsCompactionSpan(t *testing.T) { + t.Parallel() + ctx, c, cs := newCaptureCtx(t) + history := []providers.Message{{Role: "user", Content: "compare pages"}} + for _, id := range []string{"c1", "c2", "c3"} { + history = append(history, + providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{ID: id, Name: "read_file"}}}, + providers.Message{Role: "tool", ToolCallID: id, Content: "result"}, + ) + } + + _, _ = (&Loop{}).makeCompactMessages(nil)(ctx, history, "test-model") + c.Stop() + + cs.mu.Lock() + defer cs.mu.Unlock() + if len(cs.spans) != 1 || cs.spans[0].Name != "mid_loop_compaction" { + t.Fatalf("spans = %+v, want one mid_loop_compaction span", cs.spans) + } + var meta map[string]any + if err := json.Unmarshal(cs.spans[0].Metadata, &meta); err != nil || meta["outcome"] != "not_compacted" { + t.Errorf("metadata = %s, want outcome=not_compacted", cs.spans[0].Metadata) + } +} diff --git a/internal/agent/loop_pipeline_adapter.go b/internal/agent/loop_pipeline_adapter.go index 2673da79..a1c024f8 100644 --- a/internal/agent/loop_pipeline_adapter.go +++ b/internal/agent/loop_pipeline_adapter.go @@ -310,6 +310,7 @@ func convertRunResult(pr *pipeline.RunResult) *RunResult { LastBlockReply: pr.LastBlockReply, LoopKilled: pr.LoopKilled, Calls: pr.Calls, + StopReason: pr.StopReason, } } diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index d6a0973c..bd1a60a7 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -20,6 +20,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" + "github.com/nextlevelbuilder/goclaw/internal/tracing" usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps" "github.com/nextlevelbuilder/goclaw/internal/workspace" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -689,9 +690,19 @@ func (l *Loop) makePruneMessages() func(msgs []providers.Message, budget int) ([ func (l *Loop) makeCompactMessages(req *RunRequest) func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error) { return func(ctx context.Context, msgs []providers.Message, model string) ([]providers.Message, error) { + start := time.Now().UTC() compacted := l.compactMessagesInPlace(ctx, msgs) + outcome := "compacted" if compacted == nil { - return msgs, nil // compaction failed, return original + outcome = "not_compacted" + } + tracing.EmitEventSpan(ctx, "mid_loop_compaction", start, nil, map[string]any{ + "outcome": outcome, + "input_messages": len(msgs), + "output_messages": len(compacted), + }) + if compacted == nil { + return nil, pipeline.ErrNotCompacted } // Stamp session metadata with the compaction timestamp so operators // can diagnose compaction cadence without a dedicated column. Stored diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go index 5137adde..54f2277a 100644 --- a/internal/agent/loop_run.go +++ b/internal/agent/loop_run.go @@ -215,13 +215,17 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { if result.LastUsage != nil { logAttrs = append(logAttrs, "last_usage_prompt_tokens", result.LastUsage.PromptTokens) } + if result.StopReason != "" { + logAttrs = append(logAttrs, "stop_reason", result.StopReason) + } slog.Info("v3.run.completed", logAttrs...) if agentSpanID != uuid.Nil { l.emitAgentSpanEnd(ctx, agentSpanID, runStart, result, nil) } + traceStatus, traceErr := runTraceStatus(result) if isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { - l.traceCollector.SetTraceStatus(ctx, traceID, store.TraceStatusCompleted) + l.traceCollector.SetTraceStatus(ctx, traceID, traceStatus) } completedPayload := map[string]any{"content": result.Content} if result.Thinking != "" { @@ -244,7 +248,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil { traceFinalized = true if result != nil { - l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", + l.traceCollector.FinishTrace(ctx, traceID, traceStatus, tracing.RedactText(ctx, traceErr), tracing.RedactText(ctx, truncateStr(result.Content, l.traceCollector.PreviewMaxLen()))) } else { l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", "") diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 1607521e..627201f9 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -431,11 +431,23 @@ func (l *Loop) emitAgentSpanEnd(ctx context.Context, agentSpanID uuid.UUID, star updates["output_preview"] = tracing.TruncateMid(result.Content, limit) // Note: token counts are NOT set on agent spans to avoid double-counting // with child spans that directly report model usage. + if result.StopReason != "" { + updates["status"] = store.SpanStatusError + updates["error"] = result.StopReason + } } collector.EmitSpanUpdate(agentSpanID, traceID, tracing.RedactSpanUpdates(ctx, updates)) } +// runTraceStatus maps a finished run to its trace status and error text. +func runTraceStatus(result *RunResult) (status, errMsg string) { + if result != nil && result.StopReason != "" { + return store.TraceStatusError, result.StopReason + } + return store.TraceStatusCompleted, "" +} + // previewLimitForVerbose returns the preview character limit based on verbose mode. func previewLimitForVerbose(verbose bool) int { if verbose { diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 7d9a52e0..8d1583c5 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -702,6 +702,7 @@ type RunResult struct { LastBlockReply string `json:"lastBlockReply,omitempty"` // last block reply content (for dedup) LoopKilled bool `json:"loopKilled,omitempty"` // true when run was terminated by loop detector Calls []providers.CallUsage `json:"calls,omitempty"` // per-call usage breakdown + StopReason string `json:"stopReason,omitempty"` // why the pipeline (not the model) ended the run } // MediaResult represents a media file produced by a tool during the agent run. diff --git a/internal/config/config.go b/internal/config/config.go index 3f459292..511b354f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -349,12 +349,11 @@ type MemoryFlushConfig struct { // ContextPruningConfig configures in-memory context pruning of old tool results. // Matches TS openclaw/src/agents/pi-hooks/context-pruning/settings.ts. // -// Mode "" (default) or "off" → pruning disabled, zero overhead. -// Mode "cache-ttl" → prune eligible tool results when ratio exceeds softTrimRatio, -// -// gated by provider prompt-cache TTL (see PruneStage). +// Mode "" (default) or "cache-ttl" → prune eligible tool results when ratio exceeds +// softTrimRatio; only an explicit "cache-ttl" is gated by the provider prompt-cache TTL (see PruneStage). +// Mode "off" → pruning disabled, zero overhead. type ContextPruningConfig struct { - Mode string `json:"mode,omitempty"` // "" (default off), "off", "cache-ttl" + Mode string `json:"mode,omitempty"` // "" (default cache-ttl), "off", "cache-ttl" TTL string `json:"ttl,omitempty"` // cache TTL gate duration (default "5m"), Go duration string e.g. "5m", "30s" KeepLastAssistants int `json:"keepLastAssistants,omitempty"` // protect last N assistant msgs (default 3) SoftTrimRatio float64 `json:"softTrimRatio,omitempty"` // start soft trim at this % of window (default 0.3) diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go index 91948486..31cf7066 100644 --- a/internal/heartbeat/ticker.go +++ b/internal/heartbeat/ticker.go @@ -298,7 +298,7 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { outcome := <-outCh if outcome.Err == nil { result = outcome.Result - lastErr = nil + lastErr = outcome.Failure() // a pipeline stop will not succeed on retry break } lastErr = outcome.Err diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 65e766b5..0ae50775 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -230,10 +230,11 @@ func init() { MsgToolAnnouncementSingle: "I'll use %s to handle the next step.", MsgToolAnnouncementMulti: "I'll use %s to handle the next step.", - MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.", - MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.", - MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.", - MsgEmptyReplyFallback: "⚠️ Agent couldn't generate a response. Note: some tool actions may have already been executed — please verify before retrying", + MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.", + MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.", + MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.", + MsgEmptyReplyFallback: "⚠️ Agent couldn't generate a response. Note: some tool actions may have already been executed — please verify before retrying", + MsgContextBudgetExceeded: "⚠️ This conversation has grown too long for the model's context window, so I stopped before answering. Some tool actions from this turn may already have run. Please start a new session to continue.", MsgInvalidRole: "invalid role: allowed values are owner, admin, operator, member, viewer", diff --git a/internal/i18n/catalog_ko.go b/internal/i18n/catalog_ko.go index 96365ef5..6b636ad8 100644 --- a/internal/i18n/catalog_ko.go +++ b/internal/i18n/catalog_ko.go @@ -81,12 +81,12 @@ func init() { MsgNotImplemented: "%s은(는) 아직 구현되지 않았습니다", // Agent links - MsgLinksNotConfigured: "에이전트 링크가 설정되지 않았습니다", - MsgInvalidDirection: "방향은 outbound, inbound, 또는 bidirectional이어야 합니다", - MsgSourceTargetSame: "소스와 대상은 서로 다른 에이전트여야 합니다", - MsgCannotDelegateOpen: "오픈 에이전트에게는 위임할 수 없습니다 — 사전 정의된 에이전트만 위임 대상이 될 수 있습니다", - MsgNoUpdatesProvided: "업데이트가 제공되지 않았습니다", - MsgInvalidLinkStatus: "상태는 active 또는 disabled여야 합니다", + MsgLinksNotConfigured: "에이전트 링크가 설정되지 않았습니다", + MsgInvalidDirection: "방향은 outbound, inbound, 또는 bidirectional이어야 합니다", + MsgSourceTargetSame: "소스와 대상은 서로 다른 에이전트여야 합니다", + MsgCannotDelegateOpen: "오픈 에이전트에게는 위임할 수 없습니다 — 사전 정의된 에이전트만 위임 대상이 될 수 있습니다", + MsgNoUpdatesProvided: "업데이트가 제공되지 않았습니다", + MsgInvalidLinkStatus: "상태는 active 또는 disabled여야 합니다", // Teams MsgTeamsNotConfigured: "팀이 설정되지 않았습니다", @@ -172,9 +172,10 @@ func init() { MsgToolPublishSkill: "스킬 디렉토리를 시스템 데이터베이스에 등록하여 검색 가능하게 만듭니다", MsgToolTeamTasks: "팀 작업 보드에서 작업을 보고, 생성하고, 업데이트하고, 완료합니다", - MsgSkillNudgePostscript: "이 작업은 여러 단계를 포함했습니다. 이 과정을 재사용 가능한 스킬로 저장할까요? **\"스킬로 저장\"** 또는 **\"건너뛰기\"**로 답장하세요.", - MsgSkillNudge70Pct: "[System] 반복 예산의 70%에 도달했습니다. 이 세션의 패턴 중 좋은 스킬이 될 수 있는 것이 있는지 고려해보세요.", - MsgSkillNudge90Pct: "[System] 반복 예산의 90%에 도달했습니다. 이 세션에 재사용 가능한 패턴이 포함되어 있다면 완료하기 전에 스킬로 저장하는 것을 고려해보세요.", + MsgSkillNudgePostscript: "이 작업은 여러 단계를 포함했습니다. 이 과정을 재사용 가능한 스킬로 저장할까요? **\"스킬로 저장\"** 또는 **\"건너뛰기\"**로 답장하세요.", + MsgSkillNudge70Pct: "[System] 반복 예산의 70%에 도달했습니다. 이 세션의 패턴 중 좋은 스킬이 될 수 있는 것이 있는지 고려해보세요.", + MsgSkillNudge90Pct: "[System] 반복 예산의 90%에 도달했습니다. 이 세션에 재사용 가능한 패턴이 포함되어 있다면 완료하기 전에 스킬로 저장하는 것을 고려해보세요.", + MsgContextBudgetExceeded: "⚠️ 대화가 모델의 컨텍스트 창에 비해 너무 길어져 답변하기 전에 중단했습니다. 이번 차례의 일부 도구 작업은 이미 실행되었을 수 있습니다. 계속하려면 새 세션을 시작해 주세요.", MsgInvalidRole: "잘못된 역할: 허용되는 값은 owner, admin, operator, member, viewer입니다", diff --git a/internal/i18n/catalog_ru.go b/internal/i18n/catalog_ru.go index 79ee810e..46375ccb 100644 --- a/internal/i18n/catalog_ru.go +++ b/internal/i18n/catalog_ru.go @@ -230,9 +230,10 @@ func init() { MsgToolAnnouncementSingle: "Я использую %s для следующего шага.", MsgToolAnnouncementMulti: "Я использую %s для следующего шага.", - MsgSkillNudgePostscript: "Эта задача включала несколько шагов. Хотите, чтобы я сохранил процесс как повторно используемый навык? Ответьте **\"сохранить как навык\"** или **\"пропустить\"**.", - MsgSkillNudge70Pct: "[Система] Вы использовали 70% бюджета итераций. Подумайте, могут ли какие-либо шаблоны из этой сессии стать хорошим навыком.", - MsgSkillNudge90Pct: "[Система] Вы использовали 90% бюджета итераций. Если эта сессия включала повторно используемые шаблоны, подумайте о сохранении их как навыка до завершения.", + MsgSkillNudgePostscript: "Эта задача включала несколько шагов. Хотите, чтобы я сохранил процесс как повторно используемый навык? Ответьте **\"сохранить как навык\"** или **\"пропустить\"**.", + MsgSkillNudge70Pct: "[Система] Вы использовали 70% бюджета итераций. Подумайте, могут ли какие-либо шаблоны из этой сессии стать хорошим навыком.", + MsgSkillNudge90Pct: "[Система] Вы использовали 90% бюджета итераций. Если эта сессия включала повторно используемые шаблоны, подумайте о сохранении их как навыка до завершения.", + MsgContextBudgetExceeded: "⚠️ Диалог стал слишком длинным для контекстного окна модели, поэтому я остановился, не ответив. Некоторые действия инструментов в этом ходе могли уже выполниться. Пожалуйста, начните новую сессию, чтобы продолжить.", MsgInvalidRole: "неверная роль: допустимые значения — owner, admin, operator, member, viewer", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index dadf1dee..7e8b7a9c 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -230,10 +230,11 @@ func init() { MsgToolAnnouncementSingle: "Tôi sẽ dùng %s để xử lý bước tiếp theo.", MsgToolAnnouncementMulti: "Tôi sẽ dùng %s để xử lý bước tiếp theo.", - MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.", - MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.", - MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.", - MsgEmptyReplyFallback: "⚠️ Agent không thể tạo phản hồi. Lưu ý: một số thao tác công cụ có thể đã được thực hiện — vui lòng kiểm tra trước khi thử lại", + MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.", + MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.", + MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.", + MsgEmptyReplyFallback: "⚠️ Agent không thể tạo phản hồi. Lưu ý: một số thao tác công cụ có thể đã được thực hiện — vui lòng kiểm tra trước khi thử lại", + MsgContextBudgetExceeded: "⚠️ Cuộc hội thoại đã quá dài so với cửa sổ ngữ cảnh của model nên tôi đã dừng trước khi trả lời. Một số thao tác công cụ trong lượt này có thể đã được thực hiện. Vui lòng mở phiên mới để tiếp tục.", MsgInvalidRole: "vai trò không hợp lệ: giá trị cho phép là owner, admin, operator, member, viewer", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index c622dfb8..2c6ec712 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -230,10 +230,11 @@ func init() { MsgToolAnnouncementSingle: "我将使用 %s 处理下一步。", MsgToolAnnouncementMulti: "我将使用 %s 处理下一步。", - MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。", - MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。", - MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。", - MsgEmptyReplyFallback: "⚠️ 代理无法生成响应。注意:部分工具操作可能已经执行 — 请先确认后再重试", + MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。", + MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。", + MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。", + MsgEmptyReplyFallback: "⚠️ 代理无法生成响应。注意:部分工具操作可能已经执行 — 请先确认后再重试", + MsgContextBudgetExceeded: "⚠️ 对话内容已超出模型的上下文窗口,因此我在回答前停止了。本轮的部分工具操作可能已经执行。请开启新会话后继续。", MsgInvalidRole: "无效角色:允许的值为 owner、admin、operator、member、viewer", diff --git a/internal/i18n/context_budget_key_test.go b/internal/i18n/context_budget_key_test.go new file mode 100644 index 00000000..c18be829 --- /dev/null +++ b/internal/i18n/context_budget_key_test.go @@ -0,0 +1,15 @@ +package i18n + +import ( + "strings" + "testing" +) + +func TestI18nCatalogs_HasContextBudgetExceeded(t *testing.T) { + for _, locale := range []string{LocaleEN, LocaleVI, LocaleZH, LocaleRU, LocaleKO} { + msg := lookup(locale, MsgContextBudgetExceeded) + if msg == MsgContextBudgetExceeded || strings.TrimSpace(msg) == "" { + t.Errorf("locale=%s: translation missing for %s", locale, MsgContextBudgetExceeded) + } + } +} diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index da7cf436..5b4ac7a8 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -275,6 +275,9 @@ const ( // output and no deliverable media, replacing the old meaningless "...". MsgEmptyReplyFallback = "chat.empty_reply_fallback" + // Shown when the request guard stops a run because the context budget is exhausted. + MsgContextBudgetExceeded = "chat.context_budget_exceeded" + // Tool progress announcements (user-facing) MsgToolAnnouncementSingle = "progress.tool_announcement.single" // "I'll use %s to handle the next step." MsgToolAnnouncementMulti = "progress.tool_announcement.multi" // "I'll use %s to handle the next step." diff --git a/internal/pipeline/budget_count.go b/internal/pipeline/budget_count.go new file mode 100644 index 00000000..dd2a1d41 --- /dev/null +++ b/internal/pipeline/budget_count.go @@ -0,0 +1,41 @@ +package pipeline + +import ( + "log/slog" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +// PruneStage budgets with the request guard's BudgetCounter so both stages agree; +// TokenCounter falls back to a chars/2 heuristic for unregistered models. +func budgetMessageTokens(deps *PipelineDeps, model string, msgs []providers.Message) int { + if deps.BudgetCounter != nil { + n, err := deps.BudgetCounter.CountMessages(msgs) + if err == nil { + return n + } + slog.Warn("budget_counter.count_failed", "target", "messages", "error", err) + } + if deps.TokenCounter == nil { + return 0 + } + return deps.TokenCounter.CountMessages(model, msgs) +} + +func budgetOverheadTokens(deps *PipelineDeps, model string, system providers.Message, tools []providers.ToolDefinition) (int, bool) { + if deps.BudgetCounter != nil { + msgTokens, err := deps.BudgetCounter.CountMessages([]providers.Message{system}) + if err == nil { + var toolTokens int + if toolTokens, err = deps.BudgetCounter.CountToolSchemas(tools); err == nil { + return msgTokens + toolTokens, true + } + } + slog.Warn("budget_counter.count_failed", "target", "overhead", "error", err) + } + if deps.TokenCounter == nil { + return 0, false + } + return deps.TokenCounter.CountMessages(model, []providers.Message{system}) + + deps.TokenCounter.CountToolSchemas(model, tools), true +} diff --git a/internal/pipeline/compaction_not_compacted_test.go b/internal/pipeline/compaction_not_compacted_test.go new file mode 100644 index 00000000..7cb730ba --- /dev/null +++ b/internal/pipeline/compaction_not_compacted_test.go @@ -0,0 +1,169 @@ +package pipeline + +import ( + "context" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func TestPruneStage_NotCompacted_LeavesHistoryAndCountersUnchanged(t *testing.T) { + t.Parallel() + deps := &PipelineDeps{ + Config: PipelineConfig{ContextWindow: 1000, MaxTokens: 100}, + TokenCounter: &mockTokenCounter{countPerMessage: 100}, + PruneMessages: func(msgs []providers.Message, _ int) ([]providers.Message, PruneStats) { + return msgs, PruneStats{} + }, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + return nil, ErrNotCompacted + }, + } + stage := NewPruneStage(deps, NewMemoryFlushStage(deps)) + state := defaultState() + history := make([]providers.Message, 50) + for i := range history { + history[i] = providers.Message{Role: "user", Content: "msg"} + } + state.Messages.SetHistory(history) + state.Messages.AppendPending(providers.Message{Role: "assistant", ToolCalls: []providers.ToolCall{{ID: "c1", Name: "read_file"}}}) + state.Messages.AppendPending(providers.Message{Role: "tool", ToolCallID: "c1", Content: "result"}) + + if err := stage.Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if got := len(state.Messages.History()); got != 50 { + t.Errorf("history len = %d, want 50 (unchanged)", got) + } + if got := len(state.Messages.Pending()); got != 2 { + t.Errorf("pending len = %d, want 2 (preserved)", got) + } + if state.Prune.MidLoopCompacted { + t.Error("MidLoopCompacted = true, want false when nothing was compacted") + } + if state.Compact.CompactionCount != 0 { + t.Errorf("CompactionCount = %d, want 0", state.Compact.CompactionCount) + } +} + +func TestThinkStage_FinalRequestGuard_NotCompactedFallsThroughToShrinkMemory(t *testing.T) { + t.Parallel() + called := false + memory := strings.Repeat("m", 80) + + deps := &PipelineDeps{ + TokenCounter: finalRequestBudgetCounter{}, + Config: PipelineConfig{ContextWindow: 100, MaxTokens: 10, Compaction: &config.CompactionConfig{MaxRequestShare: 0.85}}, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + return nil, ErrNotCompacted + }, + CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { + called = true + return &providers.ChatResponse{Content: "ok", FinishReason: "stop"}, nil + }, + } + stage := NewThinkStage(deps) + state := defaultState() + state.Messages.SetSystem(providers.Message{Role: "system", Content: "sys\n\n" + memory}) + state.Context.MemorySection = memory + state.Messages.SetHistory([]providers.Message{{Role: "user", Content: "short"}}) + + if err := stage.Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if !called { + t.Fatal("expected CallLLM after shrink_memory brought the request under the limit") + } + if state.Prune.MidLoopCompacted { + t.Error("MidLoopCompacted = true, want false when nothing was compacted") + } +} + +// A failed compaction (no split point, summarizer error/timeout) is not retried +// every iteration; the request guard remains the authority for this run. +func TestPruneStage_NotCompacted_DoesNotRetryInSameRun(t *testing.T) { + t.Parallel() + compactCalls, flushCalls := 0, 0 + deps := &PipelineDeps{ + Config: PipelineConfig{ContextWindow: 1000, MaxTokens: 100}, + TokenCounter: &mockTokenCounter{countPerMessage: 100}, + RunMemoryFlush: func(context.Context, *RunState) error { + flushCalls++ + return nil + }, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + compactCalls++ + return nil, ErrNotCompacted + }, + } + stage := NewPruneStage(deps, NewMemoryFlushStage(deps)) + state := defaultState() + history := make([]providers.Message, 50) + for i := range history { + history[i] = providers.Message{Role: "user", Content: "msg"} + } + state.Messages.SetHistory(history) + + for range 3 { + if err := stage.Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + } + if compactCalls != 1 || flushCalls != 1 { + t.Errorf("compact calls = %d, flush calls = %d; want 1 each", compactCalls, flushCalls) + } + if !state.Compact.Unavailable { + t.Error("Compact.Unavailable = false, want true after ErrNotCompacted") + } +} + +func TestThinkStage_FinalRequestGuard_SkipsCompactionWhenUnavailable(t *testing.T) { + t.Parallel() + compactCalls := 0 + memory := strings.Repeat("m", 80) + deps := &PipelineDeps{ + TokenCounter: finalRequestBudgetCounter{}, + Config: PipelineConfig{ContextWindow: 100, MaxTokens: 10, Compaction: &config.CompactionConfig{MaxRequestShare: 0.85}}, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + compactCalls++ + return nil, ErrNotCompacted + }, + CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{Content: "ok", FinishReason: "stop"}, nil + }, + } + state := defaultState() + state.Compact.Unavailable = true + state.Messages.SetSystem(providers.Message{Role: "system", Content: "sys\n\n" + memory}) + state.Context.MemorySection = memory + state.Messages.SetHistory([]providers.Message{{Role: "user", Content: "short"}}) + + if err := NewThinkStage(deps).Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if compactCalls != 0 { + t.Errorf("CompactMessages calls = %d, want 0 once compaction is unavailable", compactCalls) + } +} + +// Post-run summarization must still see context pressure when mid-loop +// compaction was needed but could not compact anything. +func TestFinalizeStage_PassesPressureWhenCompactionUnavailable(t *testing.T) { + t.Parallel() + var gotPressure bool + deps := &PipelineDeps{ + MaybeSummarize: func(_ context.Context, _ string, pressure bool) { gotPressure = pressure }, + } + state := defaultState() + state.Observe.FinalContent = "answer" + state.Compact.Unavailable = true + + if err := NewFinalizeStage(deps).Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if !gotPressure { + t.Error("MaybeSummarize pressure = false, want true when compaction was unavailable") + } +} diff --git a/internal/pipeline/context_budget_stop_test.go b/internal/pipeline/context_budget_stop_test.go new file mode 100644 index 00000000..c356fa34 --- /dev/null +++ b/internal/pipeline/context_budget_stop_test.go @@ -0,0 +1,113 @@ +package pipeline + +import ( + "context" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func runBudgetPipeline(t *testing.T, deps PipelineDeps, state *RunState, iteration func(*PipelineDeps) []Stage) *RunResult { + t.Helper() + d := &deps + p := NewPipeline(nil, iteration(d), []Stage{NewFinalizeStage(d)}, deps) + result, err := p.Run(context.Background(), state) + if err != nil { + t.Fatalf("Run() error: %v", err) + } + return result +} + +// Regression for trace 01a0ec56: the chars/2 heuristic said "over budget" while +// the real request fitted, and the run ended with the empty-reply fallback. +func TestPipeline_HeuristicOverBudget_StillCallsModel(t *testing.T) { + t.Parallel() + called := false + deps := PipelineDeps{ + Config: PipelineConfig{ContextWindow: 1000, MaxTokens: 100, MaxIterations: 3}, + TokenCounter: &mockTokenCounter{countPerMessage: 1000}, + BudgetCounter: perItemBudgetCounter{perMessage: 1}, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + return nil, ErrNotCompacted + }, + CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { + called = true + return &providers.ChatResponse{Content: "answer", FinishReason: "stop"}, nil + }, + } + state := defaultState() + state.Messages.SetHistory([]providers.Message{ + {Role: "user", Content: "q1"}, {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "q3"}, + }) + + result := runBudgetPipeline(t, deps, state, func(d *PipelineDeps) []Stage { + return []Stage{NewPruneStage(d, NewMemoryFlushStage(d)), NewThinkStage(d), NewObserveStage(d)} + }) + + if !called { + t.Fatal("CallLLM was not called; the heuristic estimate must not stop the run") + } + if result.Content != "answer" { + t.Errorf("Content = %q, want %q", result.Content, "answer") + } + if result.StopReason != "" { + t.Errorf("StopReason = %q, want empty", result.StopReason) + } +} + +func TestPipeline_GuardExhausted_DeliversContextBudgetNotice(t *testing.T) { + t.Parallel() + deps := PipelineDeps{ + TokenCounter: finalRequestBudgetCounter{}, + Config: PipelineConfig{ + ContextWindow: 100, MaxTokens: 10, MaxIterations: 3, + Compaction: &config.CompactionConfig{MaxRequestShare: 0.85}, + }, + CompactMessages: func(_ context.Context, _ []providers.Message, _ string) ([]providers.Message, error) { + return nil, ErrNotCompacted + }, + CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { + t.Error("CallLLM must not run while the request exceeds the context budget") + return &providers.ChatResponse{Content: "unexpected", FinishReason: "stop"}, nil + }, + } + state := defaultState() + state.Messages.SetHistory([]providers.Message{{Role: "user", Content: strings.Repeat("long-history", 10)}}) + + result := runBudgetPipeline(t, deps, state, func(d *PipelineDeps) []Stage { + return []Stage{NewThinkStage(d), NewObserveStage(d)} + }) + + if want := i18n.T(store.LocaleFromContext(context.Background()), i18n.MsgContextBudgetExceeded); result.Content != want { + t.Errorf("Content = %q, want the context budget notice %q", result.Content, want) + } + if !strings.Contains(result.StopReason, "context budget exceeded") { + t.Errorf("StopReason = %q, want the guard error", result.StopReason) + } +} + +// A "save this as a skill?" postscript makes no sense under a stop notice. +func TestFinalizeStage_StoppedRun_SkipsSkillPostscript(t *testing.T) { + t.Parallel() + deps := &PipelineDeps{ + SkillPostscript: func(_ context.Context, content string, _ int) string { + return content + "\n\nsave as skill?" + }, + } + state := defaultState() + state.Observe.FinalContent = "context budget notice" + state.StopReason = "final request context budget exceeded" + + if err := NewFinalizeStage(deps).Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if state.Observe.FinalContent != "context budget notice" { + t.Errorf("FinalContent = %q, want the notice without a postscript", state.Observe.FinalContent) + } +} diff --git a/internal/pipeline/context_stage.go b/internal/pipeline/context_stage.go index 570e7fea..022f46ca 100644 --- a/internal/pipeline/context_stage.go +++ b/internal/pipeline/context_stage.go @@ -141,13 +141,9 @@ func (s *ContextStage) Execute(ctx context.Context, state *RunState) error { } } - // 5. Compute overhead tokens via TokenCounter (replaces heuristic estimateOverhead). - // Includes both system-prompt tokens and tool-schema tokens so PruneStage + // 5. Compute overhead tokens (system prompt + tool schemas) so PruneStage // budget shrinks correctly when tools are large. - if s.deps.TokenCounter != nil { - system := state.Messages.System() - overhead := s.deps.TokenCounter.CountMessages(state.Model, []providers.Message{system}) - overhead += s.deps.TokenCounter.CountToolSchemas(state.Model, state.Think.Tools) + if overhead, ok := budgetOverheadTokens(s.deps, state.Model, state.Messages.System(), state.Think.Tools); ok { state.Context.OverheadTokens = overhead } diff --git a/internal/pipeline/deps.go b/internal/pipeline/deps.go index 8fa8ce85..255432ce 100644 --- a/internal/pipeline/deps.go +++ b/internal/pipeline/deps.go @@ -2,6 +2,7 @@ package pipeline import ( "context" + "errors" "time" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" @@ -13,6 +14,9 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/workspace" ) +// ErrNotCompacted: CompactMessages left history unchanged; callers must not count it as a compaction. +var ErrNotCompacted = errors.New("history not compacted") + // PruneStats holds counts from a single pruneContextMessages invocation. // Populated by the pruning function; read by PruneStage for event emission. type PruneStats struct { @@ -128,9 +132,10 @@ type PipelineDeps struct { // msgCount is the message count captured alongside lastUsage. UpdateMetadata func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error BootstrapCleanup func(ctx context.Context, state *RunState) error - // MaybeSummarize takes midLoopCompacted: when the final-request guard already - // compacted mid-loop this run, post-turn summarization lowers its trigger - // threshold so the compaction is PERSISTED (episodic Bug B / anti-loop). + // MaybeSummarize takes midLoopCompacted: when this run hit mid-loop context + // pressure (compacted, compaction unavailable, or stopped for budget), + // post-turn summarization lowers its trigger threshold so the compaction is + // PERSISTED (episodic Bug B / anti-loop). MaybeSummarize func(ctx context.Context, sessionKey string, midLoopCompacted bool) } diff --git a/internal/pipeline/final_request_guard.go b/internal/pipeline/final_request_guard.go index 742cd239..73cef2e6 100644 --- a/internal/pipeline/final_request_guard.go +++ b/internal/pipeline/final_request_guard.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -27,6 +28,9 @@ type FinalRequestEstimate struct { const defaultMaxRequestShare = 0.85 +// ErrContextBudgetExceeded marks a request that still exceeds the context budget after every reduction step. +var ErrContextBudgetExceeded = errors.New("context budget exceeded") + func effectiveMaxRequestShare(cfg *config.CompactionConfig) float64 { if cfg != nil && cfg.MaxRequestShare > 0 && cfg.MaxRequestShare <= 1 { return cfg.MaxRequestShare @@ -181,8 +185,8 @@ func (s *ThinkStage) prepareFinalRequest(ctx context.Context, state *RunState, t } s.logFinalRequestGuard(state, estimate, "abort", "exhausted") - return req, estimate, fmt.Errorf("final request context budget exceeded: estimated_input=%d compact_target=%d hard_input_cap=%d context_window=%d max_request_share=%.2f", - estimate.InputTokens, estimate.CompactTargetTokens, estimate.HardInputCapTokens, estimate.ContextWindow, estimate.MaxRequestShare) + return req, estimate, fmt.Errorf("final request %w: estimated_input=%d compact_target=%d hard_input_cap=%d context_window=%d max_request_share=%.2f", + ErrContextBudgetExceeded, estimate.InputTokens, estimate.CompactTargetTokens, estimate.HardInputCapTokens, estimate.ContextWindow, estimate.MaxRequestShare) } func (s *ThinkStage) reduceFinalRequestContext(ctx context.Context, state *RunState, estimate FinalRequestEstimate, step string) (bool, error) { @@ -208,7 +212,10 @@ func (s *ThinkStage) pruneForFinalRequestBudget(state *RunState, estimate FinalR } fixedMessages := []providers.Message{state.Messages.System()} fixedMessages = append(fixedMessages, state.Messages.Pending()...) - fixedMessageTokens := countRequestMessages(s.deps, state.Model, fixedMessages) + fixedMessageTokens, _, err := countBudgetInput(s.deps, state.Model, providers.ChatRequest{Messages: fixedMessages}) + if err != nil { + fixedMessageTokens = countRequestMessages(s.deps, state.Model, fixedMessages) + } budget := estimate.CompactTargetTokens - estimate.ToolTokens - fixedMessageTokens if budget <= 0 { budget = 1 @@ -226,7 +233,7 @@ func (s *ThinkStage) pruneForFinalRequestBudget(state *RunState, estimate FinalR } func (s *ThinkStage) compactForFinalRequestBudget(ctx context.Context, state *RunState) (bool, error) { - if s.deps.CompactMessages == nil { + if s.deps.CompactMessages == nil || state.Compact.Unavailable { return false, nil } history := state.Messages.History() @@ -235,6 +242,10 @@ func (s *ThinkStage) compactForFinalRequestBudget(ctx context.Context, state *Ru } savedPending := state.Messages.Pending() compacted, err := s.deps.CompactMessages(ctx, history, state.Model) + if errors.Is(err, ErrNotCompacted) { + state.Compact.Unavailable = true + return false, nil + } if err != nil { return false, fmt.Errorf("compact final request context: %w", err) } diff --git a/internal/pipeline/finalize_stage.go b/internal/pipeline/finalize_stage.go index 6221535d..19da016c 100644 --- a/internal/pipeline/finalize_stage.go +++ b/internal/pipeline/finalize_stage.go @@ -38,7 +38,7 @@ func (s *FinalizeStage) Execute(ctx context.Context, state *RunState) error { } // 1b. Skill evolution postscript (matching v2 loop_finalize.go:52-57). - if s.deps.SkillPostscript != nil && state.Observe.FinalContent != "" { + if s.deps.SkillPostscript != nil && state.Observe.FinalContent != "" && state.StopReason == "" { state.Observe.FinalContent = s.deps.SkillPostscript(ctx, state.Observe.FinalContent, state.Tool.TotalToolCalls) } @@ -161,7 +161,8 @@ func (s *FinalizeStage) Execute(ctx context.Context, state *RunState) error { // and advances the cumulative compaction count so episodic can progress (Việc 1-B). // Both mid-loop paths (prune_stage + compactForFinalRequestBudget) set this flag. if s.deps.MaybeSummarize != nil { - s.deps.MaybeSummarize(ctx, state.Input.SessionKey, state.Prune.MidLoopCompacted) + pressure := state.Prune.MidLoopCompacted || state.Compact.Unavailable || state.StopReason != "" + s.deps.MaybeSummarize(ctx, state.Input.SessionKey, pressure) } // 8. Emit session.completed for consolidation pipeline (episodic → semantic → dreaming). diff --git a/internal/pipeline/memory_flush_span_test.go b/internal/pipeline/memory_flush_span_test.go new file mode 100644 index 00000000..e346f1a2 --- /dev/null +++ b/internal/pipeline/memory_flush_span_test.go @@ -0,0 +1,65 @@ +package pipeline + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +type spanRecorder struct { + store.TracingStore + + mu sync.Mutex + spans []store.SpanData +} + +func (r *spanRecorder) BatchCreateSpans(_ context.Context, spans []store.SpanData) error { + r.mu.Lock() + defer r.mu.Unlock() + r.spans = append(r.spans, spans...) + return nil +} +func (r *spanRecorder) BatchUpdateTraceAggregates(context.Context, uuid.UUID) error { return nil } +func (r *spanRecorder) DeleteTracesOlderThan(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (r *spanRecorder) RecoverStaleRunningTraces(context.Context, time.Time) (int64, error) { + return 0, nil +} + +// Memory flush runs its own LLM loop; without a span its duration is invisible in the trace. +func TestMemoryFlushStage_EmitsSpan(t *testing.T) { + t.Parallel() + rec := &spanRecorder{} + c := tracing.NewCollector(rec) + c.Start() + ctx := tracing.WithCollector(context.Background(), c) + ctx = tracing.WithTraceID(ctx, uuid.New()) + + deps := &PipelineDeps{ + RunMemoryFlush: func(context.Context, *RunState) error { return nil }, + } + state := defaultState() + state.Prune.HistoryTokens = 188939 + state.Prune.HistoryBudget = 180058 + + if err := NewMemoryFlushStage(deps).Execute(ctx, state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + c.Stop() + + rec.mu.Lock() + defer rec.mu.Unlock() + if len(rec.spans) != 1 || rec.spans[0].Name != "memory_flush" { + t.Fatalf("spans = %+v, want one memory_flush span", rec.spans) + } + if rec.spans[0].Status != store.SpanStatusCompleted { + t.Errorf("status = %q, want %q", rec.spans[0].Status, store.SpanStatusCompleted) + } +} diff --git a/internal/pipeline/memory_flush_stage.go b/internal/pipeline/memory_flush_stage.go index 34c8a139..80b99c74 100644 --- a/internal/pipeline/memory_flush_stage.go +++ b/internal/pipeline/memory_flush_stage.go @@ -3,6 +3,9 @@ package pipeline import ( "context" "log/slog" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/tracing" ) // MemoryFlushStage flushes memories to long-term storage before compaction. @@ -23,7 +26,13 @@ func (s *MemoryFlushStage) Execute(ctx context.Context, state *RunState) error { if s.deps.RunMemoryFlush == nil { return nil } - if err := s.deps.RunMemoryFlush(ctx, state); err != nil { + start := time.Now().UTC() + err := s.deps.RunMemoryFlush(ctx, state) + tracing.EmitEventSpan(ctx, "memory_flush", start, err, map[string]any{ + "history_tokens": state.Prune.HistoryTokens, + "budget": state.Prune.HistoryBudget, + }) + if err != nil { // Memory flush failure is non-fatal — log and continue to compaction. slog.Warn("memory flush failed, continuing to compaction", "err", err) } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index ae99716e..638fbaf3 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -67,7 +67,7 @@ func (p *Pipeline) Run(ctx context.Context, state *RunState) (*RunResult, error) // 2. Iteration loop // BreakLoop: complete all remaining stages in this iteration (ObserveStage must // capture FinalContent), then exit the outer loop. - // AbortRun: exit inner loop immediately (unrecoverable, e.g. over budget after compaction). + // AbortRun: exit inner loop immediately (unrecoverable, e.g. truncation retries exhausted). for state.Iteration = 0; state.Iteration < p.Deps.Config.MaxIterations; state.Iteration++ { for _, stage := range p.iteration { if err := stage.Execute(ctx, state); err != nil { diff --git a/internal/pipeline/prune_stage.go b/internal/pipeline/prune_stage.go index 15e5f2b5..c1794b0e 100644 --- a/internal/pipeline/prune_stage.go +++ b/internal/pipeline/prune_stage.go @@ -2,6 +2,7 @@ package pipeline import ( "context" + "errors" "fmt" "log/slog" "time" @@ -14,20 +15,18 @@ import ( // - Phase 1 (70% budget): soft trim via PruneMessages callback // - Phase 2 (100% budget): memory flush + LLM compaction // -// Implements StageWithResult — returns AbortRun if still over budget after compaction. +// Never controls loop flow: ThinkStage's final request guard is the budget authority. type PruneStage struct { deps *PipelineDeps memoryFlush *MemoryFlushStage - result StageResult } // NewPruneStage creates a PruneStage with inline memory flush. func NewPruneStage(deps *PipelineDeps, memFlush *MemoryFlushStage) *PruneStage { - return &PruneStage{deps: deps, memoryFlush: memFlush, result: Continue} + return &PruneStage{deps: deps, memoryFlush: memFlush} } -func (s *PruneStage) Name() string { return "prune" } -func (s *PruneStage) Result() StageResult { return s.result } +func (s *PruneStage) Name() string { return "prune" } // defaultCachePruneTTL is used when cfg.TTL is empty or invalid. const defaultCachePruneTTL = 5 * time.Minute @@ -48,8 +47,6 @@ func parseTTL(s string) time.Duration { // Execute checks history tokens against budget, prunes/compacts as needed. func (s *PruneStage) Execute(ctx context.Context, state *RunState) error { - s.result = Continue - // Compute budget using the effective context window for this run's model. // ContextStage resolves EffectiveContextWindow once per run via ModelRegistry; // if zero (unknown model, registry not wired) fall back to the pipeline-wide @@ -187,7 +184,7 @@ func (s *PruneStage) Execute(ctx context.Context, state *RunState) error { state.Compact.MemoryFlushedThisCycle = true } - if s.deps.CompactMessages == nil { + if s.deps.CompactMessages == nil || state.Compact.Unavailable { return nil // no compaction available } @@ -197,41 +194,45 @@ func (s *PruneStage) Execute(ctx context.Context, state *RunState) error { savedPending := state.Messages.Pending() compacted, err := s.deps.CompactMessages(ctx, state.Messages.History(), state.Model) - if err != nil { + switch { + case errors.Is(err, ErrNotCompacted): + state.Compact.Unavailable = true + slog.Warn("prune: compaction made no change", "session_key", sessionKey, "tokens", historyTokens, "budget", budget) + return nil + case err != nil: return fmt.Errorf("compact messages: %w", err) - } - state.Messages.ReplaceHistory(compacted) + default: + state.Messages.ReplaceHistory(compacted) - // Restore pending messages that were cleared by ReplaceHistory. - for _, msg := range savedPending { - state.Messages.AppendPending(msg) - } - state.Prune.MidLoopCompacted = true - state.Compact.CompactionCount++ - state.Compact.MemoryFlushedThisCycle = false // reset for next cycle + // Restore pending messages that were cleared by ReplaceHistory. + for _, msg := range savedPending { + state.Messages.AppendPending(msg) + } + state.Prune.MidLoopCompacted = true + state.Compact.CompactionCount++ + state.Compact.MemoryFlushedThisCycle = false // reset for next cycle - // Recount after compaction - historyTokens = s.countHistory(state) - state.Prune.HistoryTokens = historyTokens + historyTokens = s.countHistory(state) + state.Prune.HistoryTokens = historyTokens + } if historyTokens > budget { slog.Warn("still over budget after compaction", "tokens", historyTokens, "budget", budget) - s.result = AbortRun } return nil } -// countHistory counts history + pending tokens via TokenCounter. +// countHistory counts history + pending tokens. func (s *PruneStage) countHistory(state *RunState) int { h := state.Messages.History() p := state.Messages.Pending() - if s.deps.TokenCounter == nil || (len(h) == 0 && len(p) == 0) { + if len(h) == 0 && len(p) == 0 { return 0 } // Explicit copy to avoid aliasing the history slice. msgs := make([]providers.Message, 0, len(h)+len(p)) msgs = append(msgs, h...) msgs = append(msgs, p...) - return s.deps.TokenCounter.CountMessages(state.Model, msgs) + return budgetMessageTokens(s.deps, state.Model, msgs) } diff --git a/internal/pipeline/prune_stage_budget_counter_test.go b/internal/pipeline/prune_stage_budget_counter_test.go new file mode 100644 index 00000000..35640502 --- /dev/null +++ b/internal/pipeline/prune_stage_budget_counter_test.go @@ -0,0 +1,130 @@ +package pipeline + +import ( + "context" + "errors" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +type perItemBudgetCounter struct { + perMessage int + perTool int + err error +} + +func (c perItemBudgetCounter) CountText(string) (int, error) { return c.perMessage, c.err } +func (c perItemBudgetCounter) CountMessages(msgs []providers.Message) (int, error) { + return len(msgs) * c.perMessage, c.err +} +func (c perItemBudgetCounter) CountToolSchemas(tools []providers.ToolDefinition) (int, error) { + return len(tools) * c.perTool, c.err +} +func (c perItemBudgetCounter) CountRequest(req providers.ChatRequest) (int, error) { + return len(req.Messages)*c.perMessage + len(req.Tools)*c.perTool, c.err +} + +// Unregistered models make TokenCounter fall back to a chars/2 heuristic that +// overcounts; PruneStage must budget with the same counter as the request guard. +func TestPruneStage_CountsHistoryWithBudgetCounter(t *testing.T) { + t.Parallel() + compactCalls := 0 + deps := &PipelineDeps{ + Config: PipelineConfig{ContextWindow: 1000, MaxTokens: 100}, + TokenCounter: &mockTokenCounter{countPerMessage: 100}, + BudgetCounter: perItemBudgetCounter{perMessage: 1}, + CompactMessages: func(_ context.Context, msgs []providers.Message, _ string) ([]providers.Message, error) { + compactCalls++ + return msgs, nil + }, + } + stage := NewPruneStage(deps, NewMemoryFlushStage(deps)) + state := defaultState() + history := make([]providers.Message, 50) + for i := range history { + history[i] = providers.Message{Role: "user", Content: "msg"} + } + state.Messages.SetHistory(history) + + if err := stage.Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if state.Prune.HistoryTokens != 50 { + t.Errorf("HistoryTokens = %d, want 50 from BudgetCounter", state.Prune.HistoryTokens) + } + if compactCalls != 0 { + t.Errorf("CompactMessages calls = %d, want 0 (history is under budget)", compactCalls) + } +} + +func TestContextStage_OverheadUsesBudgetCounter(t *testing.T) { + t.Parallel() + deps := &PipelineDeps{ + TokenCounter: &spyTokenCounter{fixed: 100, toolFixed: 50}, + BudgetCounter: perItemBudgetCounter{perMessage: 7, perTool: 3}, + BuildMessages: func(_ context.Context, _ *RunInput, _ []providers.Message, _ string) ([]providers.Message, error) { + return []providers.Message{{Role: "system", Content: "system prompt"}}, nil + }, + BuildFilteredTools: func(_ *RunState) ([]providers.ToolDefinition, error) { + return fixtureTools(5), nil + }, + } + state := defaultState() + + if err := NewContextStage(deps).Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if want := 7 + 5*3; state.Context.OverheadTokens != want { + t.Errorf("OverheadTokens = %d, want %d from BudgetCounter", state.Context.OverheadTokens, want) + } +} + +func TestContextStage_OverheadFallsBackToTokenCounterOnBudgetError(t *testing.T) { + t.Parallel() + deps := &PipelineDeps{ + TokenCounter: &spyTokenCounter{fixed: 100, toolFixed: 50}, + BudgetCounter: perItemBudgetCounter{err: errors.New("encoder unavailable")}, + BuildMessages: func(_ context.Context, _ *RunInput, _ []providers.Message, _ string) ([]providers.Message, error) { + return []providers.Message{{Role: "system", Content: "system prompt"}}, nil + }, + BuildFilteredTools: func(_ *RunState) ([]providers.ToolDefinition, error) { + return fixtureTools(5), nil + }, + } + state := defaultState() + + if err := NewContextStage(deps).Execute(context.Background(), state); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if want := 100 + 5*50; state.Context.OverheadTokens != want { + t.Errorf("OverheadTokens = %d, want %d from TokenCounter fallback", state.Context.OverheadTokens, want) + } +} + +func TestThinkStage_FinalRequestGuard_PruneBudgetUsesBudgetCounter(t *testing.T) { + t.Parallel() + gotBudget := -1 + deps := &PipelineDeps{ + TokenCounter: &mockTokenCounter{countPerMessage: 1000}, + BudgetCounter: perItemBudgetCounter{perMessage: 40}, + Config: PipelineConfig{ContextWindow: 100, MaxTokens: 10}, + PruneMessages: func(msgs []providers.Message, budget int) ([]providers.Message, PruneStats) { + gotBudget = budget + return msgs, PruneStats{} + }, + CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{Content: "ok", FinishReason: "stop"}, nil + }, + } + state := defaultState() + state.Messages.SetSystem(providers.Message{Role: "system", Content: "sys"}) + state.Messages.SetHistory([]providers.Message{{Role: "user", Content: "q"}}) + + _ = NewThinkStage(deps).Execute(context.Background(), state) + + // compact target 75 - tools 0 - fixed (system) 40 = 35 in BudgetCounter units. + if gotBudget != 35 { + t.Errorf("prune budget = %d, want 35 (fixed messages counted with BudgetCounter)", gotBudget) + } +} diff --git a/internal/pipeline/run_state.go b/internal/pipeline/run_state.go index 745adfb5..d11f8514 100644 --- a/internal/pipeline/run_state.go +++ b/internal/pipeline/run_state.go @@ -40,6 +40,8 @@ type RunState struct { Iteration int RunID string ExitCode StageResult + // StopReason explains a run stopped by the pipeline instead of the model (empty otherwise). + StopReason string // CurrentLLMSpanID is the most recent LLM-call span in this run; tool spans parent to it. CurrentLLMSpanID *uuid.UUID @@ -88,6 +90,7 @@ func (rs *RunState) BuildResult() *RunResult { BlockReplies: rs.Observe.BlockReplies, LastBlockReply: rs.Observe.LastBlockReply, Calls: rs.Calls, + StopReason: rs.StopReason, } } diff --git a/internal/pipeline/stages_test.go b/internal/pipeline/stages_test.go index 4d52eb00..1252fd72 100644 --- a/internal/pipeline/stages_test.go +++ b/internal/pipeline/stages_test.go @@ -17,6 +17,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/hooks" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/workspace" @@ -724,9 +725,6 @@ func TestPruneStage_UnderBudget_NoOp(t *testing.T) { if err != nil { t.Fatalf("Execute() error: %v", err) } - if stage.Result() != Continue { - t.Errorf("Result() = %v, want Continue", stage.Result()) - } if pruneCallCount != 0 { t.Errorf("PruneMessages called %d times, want 0", pruneCallCount) } @@ -811,7 +809,8 @@ func TestPruneStage_Over100Percent_CallsCompact(t *testing.T) { } } -func TestPruneStage_StillOverAfterCompaction_ReturnsAbortRun(t *testing.T) { +// PruneStage's estimate is not authoritative; ThinkStage's request guard decides. +func TestPruneStage_StillOverAfterCompaction_DefersToRequestGuard(t *testing.T) { t.Parallel() deps := &PipelineDeps{ Config: PipelineConfig{ @@ -840,8 +839,8 @@ func TestPruneStage_StillOverAfterCompaction_ReturnsAbortRun(t *testing.T) { if err != nil { t.Fatalf("Execute() error: %v", err) } - if stage.Result() != AbortRun { - t.Errorf("Result() = %v, want AbortRun after compaction still over budget", stage.Result()) + if _, ok := any(stage).(StageWithResult); ok { + t.Fatal("PruneStage must not control loop flow") } } @@ -861,9 +860,6 @@ func TestPruneStage_ZeroBudget_NoOp(t *testing.T) { if err != nil { t.Fatalf("Execute() error: %v", err) } - if stage.Result() != Continue { - t.Errorf("Result() = %v, want Continue for zero budget", stage.Result()) - } } // TestPruneStage_EffectiveContextWindow_OverridesConfig verifies that when @@ -3535,7 +3531,7 @@ func TestThinkStage_FinalRequestGuard_AllowsRequestUnderLimit(t *testing.T) { } } -func TestThinkStage_FinalRequestGuard_AbortsWhenStillOverLimit(t *testing.T) { +func TestThinkStage_FinalRequestGuard_StopsRunWhenStillOverLimit(t *testing.T) { t.Parallel() called := false compactCalls := 0 @@ -3556,12 +3552,18 @@ func TestThinkStage_FinalRequestGuard_AbortsWhenStillOverLimit(t *testing.T) { state := defaultState() state.Messages.SetHistory([]providers.Message{{Role: "user", Content: strings.Repeat("long-history", 10)}}) - err := stage.Execute(context.Background(), state) - if err == nil { - t.Fatal("expected final request context budget error") + ctx := context.Background() + if err := stage.Execute(ctx, state); err != nil { + t.Fatalf("Execute() error: %v, want graceful stop", err) } - if !strings.Contains(err.Error(), "final request context budget exceeded") { - t.Fatalf("unexpected error: %v", err) + if stage.Result() != AbortRun { + t.Errorf("Result() = %v, want AbortRun", stage.Result()) + } + if want := i18n.T(store.LocaleFromContext(ctx), i18n.MsgContextBudgetExceeded); state.Observe.FinalContent != want { + t.Errorf("FinalContent = %q, want %q", state.Observe.FinalContent, want) + } + if !strings.Contains(state.StopReason, "final request context budget exceeded") { + t.Errorf("StopReason = %q, want the guard error", state.StopReason) } if compactCalls != 1 { t.Fatalf("CompactMessages calls = %d, want 1", compactCalls) diff --git a/internal/pipeline/substates.go b/internal/pipeline/substates.go index 0b270def..b6429997 100644 --- a/internal/pipeline/substates.go +++ b/internal/pipeline/substates.go @@ -14,7 +14,7 @@ type ContextState struct { MemorySection string // L0 auto-injected memory context for system prompt Summary string // session summary for context continuity HadBootstrap bool - OverheadTokens int // system prompt + context files (accurate via TokenCounter) + OverheadTokens int // system prompt + tool schemas, in BudgetCounter units // EffectiveContextWindow is the context window size (in tokens) resolved // per-run from the provider/model pair via ModelRegistry. Resolved ONCE in @@ -101,6 +101,7 @@ type CompactState struct { CheckpointFlushedMsgs int MemoryFlushedThisCycle bool CompactionCount int + Unavailable bool // CompactMessages returned ErrNotCompacted: don't retry this run } // EvolutionState: owned by skill evolution nudge logic. @@ -130,4 +131,5 @@ type RunResult struct { BlockReplies int LastBlockReply string Calls []providers.CallUsage + StopReason string } diff --git a/internal/pipeline/think_stage.go b/internal/pipeline/think_stage.go index 0fd6fda0..df394669 100644 --- a/internal/pipeline/think_stage.go +++ b/internal/pipeline/think_stage.go @@ -7,7 +7,9 @@ import ( "log/slog" "strings" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -67,6 +69,9 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error { // 3. Construct the final ChatRequest and enforce the request-level // context budget before any provider call is attempted. req, estimate, err := s.prepareFinalRequest(ctx, state, toolDefs) + if errors.Is(err, ErrContextBudgetExceeded) { + return s.stopForContextBudget(ctx, state, err) + } if err != nil { return err } @@ -85,7 +90,7 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error { // the guard already guaranteed zero transport calls were made. if isRequestBudgetExceededErr(err) { if state.Think.OverflowRetries >= maxBudgetReductionRetries { - return fmt.Errorf("request context budget exceeded after reduction: %w", err) + return s.stopForContextBudget(ctx, state, fmt.Errorf("request %w after reduction: %w", ErrContextBudgetExceeded, err)) } if s.reduceForBudgetExceeded(ctx, state) { // This LLM call produced no response, so LastResponse still holds @@ -96,7 +101,7 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error { state.Think.LastResponse = nil return nil // Retry this iteration (Continue result) with reduced context. } - return fmt.Errorf("request context budget exceeded, reduction exhausted: %w", err) + return s.stopForContextBudget(ctx, state, fmt.Errorf("request %w, reduction exhausted: %w", ErrContextBudgetExceeded, err)) } // Issue 958: Check for context overflow — attempt emergency compaction + retry if isContextOverflowErr(err) { @@ -218,6 +223,16 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error { return nil } +// stopForContextBudget ends the run with a localized notice instead of an error so +// FinalizeStage still persists this run's tool results and delivers the reply. +func (s *ThinkStage) stopForContextBudget(ctx context.Context, state *RunState, cause error) error { + slog.Warn("context_budget.run_stopped", "run_id", state.RunID, "iteration", state.Iteration, "error", cause) + state.Observe.FinalContent = i18n.T(store.LocaleFromContext(ctx), i18n.MsgContextBudgetExceeded) + state.StopReason = cause.Error() + s.result = AbortRun + return nil +} + func (s *ThinkStage) logFinalRequestActual(ctx context.Context, state *RunState, estimate FinalRequestEstimate, usage providers.Usage) { actualInput := InputContextTokens(usage) if actualInput <= 0 || estimate.InputTokens <= 0 { diff --git a/internal/pipeline/think_stage_budget_test.go b/internal/pipeline/think_stage_budget_test.go index f9a09bd4..924738ee 100644 --- a/internal/pipeline/think_stage_budget_test.go +++ b/internal/pipeline/think_stage_budget_test.go @@ -2,10 +2,12 @@ package pipeline import ( "context" - "errors" + "strings" "testing" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // fakeBudgetErr satisfies the contextBudgetExceededError interface that @@ -51,10 +53,11 @@ func TestThinkStage_BudgetExceeded_ReducesThenRetries(t *testing.T) { } } -// TestThinkStage_BudgetExceeded_AbortsWhenReductionExhausted verifies that when -// no reduction step can change the request, the stage surfaces the budget error -// instead of looping forever. The guard already guaranteed zero transport calls. -func TestThinkStage_BudgetExceeded_AbortsWhenReductionExhausted(t *testing.T) { +// TestThinkStage_BudgetExceeded_StopsRunWhenReductionExhausted verifies that when +// no reduction step can change the request, the stage stops the run with a +// localized notice instead of looping forever. The guard already guaranteed zero +// transport calls. +func TestThinkStage_BudgetExceeded_StopsRunWhenReductionExhausted(t *testing.T) { deps := &PipelineDeps{ Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000, ContextWindow: 200_000}, CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) { @@ -66,12 +69,18 @@ func TestThinkStage_BudgetExceeded_AbortsWhenReductionExhausted(t *testing.T) { stage := NewThinkStage(deps) state := defaultState() - err := stage.Execute(context.Background(), state) - if err == nil { - t.Fatal("expected abort when reduction is exhausted, got nil") + ctx := context.Background() + if err := stage.Execute(ctx, state); err != nil { + t.Fatalf("Execute() error: %v, want graceful stop", err) } - if !errors.As(err, new(interface{ ContextBudgetExceeded() bool })) { - t.Fatalf("expected wrapped budget error, got %v", err) + if stage.Result() != AbortRun { + t.Errorf("Result() = %v, want AbortRun", stage.Result()) + } + if want := i18n.T(store.LocaleFromContext(ctx), i18n.MsgContextBudgetExceeded); state.Observe.FinalContent != want { + t.Errorf("FinalContent = %q, want %q", state.Observe.FinalContent, want) + } + if !strings.Contains(state.StopReason, "context budget exceeded") { + t.Errorf("StopReason = %q, want the budget error", state.StopReason) } } diff --git a/internal/scheduler/queue.go b/internal/scheduler/queue.go index 0f9e3ed5..f5c794be 100644 --- a/internal/scheduler/queue.go +++ b/internal/scheduler/queue.go @@ -2,6 +2,7 @@ package scheduler import ( "context" + "errors" "fmt" "log/slog" "sync" @@ -74,6 +75,18 @@ type RunOutcome struct { Err error } +// Failure returns Err, or the reason the pipeline stopped the run itself (the +// run still carries a user-facing notice). Automated consumers treat both as failed. +func (o RunOutcome) Failure() error { + if o.Err != nil { + return o.Err + } + if o.Result != nil && o.Result.StopReason != "" { + return errors.New(o.Result.StopReason) + } + return nil +} + // activeRunEntry tracks a running agent execution with its generation. type activeRunEntry struct { cancel context.CancelFunc diff --git a/internal/scheduler/run_outcome_test.go b/internal/scheduler/run_outcome_test.go new file mode 100644 index 00000000..7db30578 --- /dev/null +++ b/internal/scheduler/run_outcome_test.go @@ -0,0 +1,37 @@ +package scheduler + +import ( + "errors" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/agent" +) + +func TestRunOutcomeFailure(t *testing.T) { + runErr := errors.New("provider down") + tests := []struct { + name string + outcome RunOutcome + want string + }{ + {name: "success", outcome: RunOutcome{Result: &agent.RunResult{Content: "answer"}}, want: ""}, + {name: "run error", outcome: RunOutcome{Err: runErr}, want: "provider down"}, + {name: "pipeline stop", outcome: RunOutcome{Result: &agent.RunResult{Content: "notice", StopReason: "final request context budget exceeded"}}, want: "final request context budget exceeded"}, + {name: "error wins over stop", outcome: RunOutcome{Err: runErr, Result: &agent.RunResult{StopReason: "stop"}}, want: "provider down"}, + {name: "nil result", outcome: RunOutcome{}, want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.outcome.Failure() + if tt.want == "" { + if got != nil { + t.Fatalf("Failure() = %v, want nil", got) + } + return + } + if got == nil || got.Error() != tt.want { + t.Fatalf("Failure() = %v, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/tracing/event_span.go b/internal/tracing/event_span.go new file mode 100644 index 00000000..8c21e874 --- /dev/null +++ b/internal/tracing/event_span.go @@ -0,0 +1,54 @@ +package tracing + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// EmitEventSpan records a finished internal step (memory flush, compaction) as an event span. +func EmitEventSpan(ctx context.Context, name string, start time.Time, err error, metadata map[string]any) { + collector := CollectorFromContext(ctx) + traceID := TraceIDFromContext(ctx) + if collector == nil || traceID == uuid.Nil { + return + } + + end := time.Now().UTC() + span := store.SpanData{ + TraceID: traceID, + SpanType: store.SpanTypeEvent, + Name: name, + StartTime: start, + EndTime: &end, + DurationMS: int(end.Sub(start).Milliseconds()), + Status: store.SpanStatusCompleted, + Level: store.SpanLevelDefault, + TeamID: TraceTeamIDPtrFromContext(ctx), + TenantID: store.TenantIDFromContext(ctx), + CreatedAt: end, + } + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } + if agentID := store.AgentIDFromContext(ctx); agentID != uuid.Nil { + span.AgentID = &agentID + } + if parentID := ParentSpanIDFromContext(ctx); parentID != uuid.Nil { + span.ParentSpanID = &parentID + } + if err != nil { + span.Status = store.SpanStatusError + span.Error = err.Error() + } + if len(metadata) > 0 { + if b, mErr := json.Marshal(metadata); mErr == nil { + span.Metadata = b + } + } + collector.EmitSpan(RedactSpan(ctx, span)) +} diff --git a/internal/tracing/event_span_test.go b/internal/tracing/event_span_test.go new file mode 100644 index 00000000..93582e7d --- /dev/null +++ b/internal/tracing/event_span_test.go @@ -0,0 +1,77 @@ +package tracing_test + +import ( + "context" + "encoding/json" + "errors" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/internal/tracing" +) + +type eventSpanStore struct { + store.TracingStore + + mu sync.Mutex + spans []store.SpanData +} + +func (s *eventSpanStore) BatchCreateSpans(_ context.Context, spans []store.SpanData) error { + s.mu.Lock() + defer s.mu.Unlock() + s.spans = append(s.spans, spans...) + return nil +} +func (s *eventSpanStore) BatchUpdateTraceAggregates(context.Context, uuid.UUID) error { return nil } +func (s *eventSpanStore) DeleteTracesOlderThan(context.Context, time.Time) (int64, error) { + return 0, nil +} +func (s *eventSpanStore) RecoverStaleRunningTraces(context.Context, time.Time) (int64, error) { + return 0, nil +} + +func TestEmitEventSpan_NoCollector_NoPanic(t *testing.T) { + tracing.EmitEventSpan(context.Background(), "memory_flush", time.Now(), nil, nil) +} + +func TestEmitEventSpan_RecordsStatusDurationAndMetadata(t *testing.T) { + st := &eventSpanStore{} + c := tracing.NewCollector(st) + c.Start() + traceID, parentID := uuid.New(), uuid.New() + ctx := tracing.WithCollector(context.Background(), c) + ctx = tracing.WithTraceID(ctx, traceID) + ctx = tracing.WithParentSpanID(ctx, parentID) + + tracing.EmitEventSpan(ctx, "mid_loop_compaction", time.Now().Add(-40*time.Millisecond), + errors.New("summarize failed"), map[string]any{"outcome": "not_compacted"}) + c.Stop() + + st.mu.Lock() + defer st.mu.Unlock() + if len(st.spans) != 1 { + t.Fatalf("spans = %d, want 1", len(st.spans)) + } + span := st.spans[0] + if span.Name != "mid_loop_compaction" || span.SpanType != store.SpanTypeEvent { + t.Errorf("span = %s/%s, want mid_loop_compaction/%s", span.SpanType, span.Name, store.SpanTypeEvent) + } + if span.TraceID != traceID || span.ParentSpanID == nil || *span.ParentSpanID != parentID { + t.Errorf("span not attached to trace %s / parent %s", traceID, parentID) + } + if span.Status != store.SpanStatusError || span.Error != "summarize failed" { + t.Errorf("status/error = %q/%q, want error/summarize failed", span.Status, span.Error) + } + if span.DurationMS < 40 || span.EndTime == nil { + t.Errorf("duration = %dms, end_time = %v; want >= 40ms and set", span.DurationMS, span.EndTime) + } + var meta map[string]any + if err := json.Unmarshal(span.Metadata, &meta); err != nil || meta["outcome"] != "not_compacted" { + t.Errorf("metadata = %s, want outcome=not_compacted", span.Metadata) + } +} diff --git a/ui/desktop/frontend/src/hooks/use-agent-detail-state.ts b/ui/desktop/frontend/src/hooks/use-agent-detail-state.ts index 3fb9a632..5d24b8be 100644 --- a/ui/desktop/frontend/src/hooks/use-agent-detail-state.ts +++ b/ui/desktop/frontend/src/hooks/use-agent-detail-state.ts @@ -3,6 +3,7 @@ import type { AgentData, ContextPruningConfig, SubagentsConfig, ToolPolicyConfig, SandboxConfig, AgentReasoningConfig, ReasoningOverrideMode, } from '../types/agent' +import { buildContextPruningPayload, isContextPruningEnabled } from '../lib/context-pruning' export function useAgentDetailState( agent: AgentData, @@ -40,7 +41,7 @@ export function useAgentDetailState( ) // --- Context pruning --- - const [pruningEnabled, setPruningEnabled] = useState(agent.context_pruning != null) + const [pruningEnabled, setPruningEnabled] = useState(isContextPruningEnabled(agent.context_pruning)) const [pruningConfig, setPruningConfig] = useState(agent.context_pruning ?? {}) // --- Compaction --- @@ -115,7 +116,7 @@ export function useAgentDetailState( is_default: isDefault, status, other_config: Object.keys(otherConfig).length > 0 ? otherConfig : {}, - context_pruning: pruningEnabled ? pruningConfig : null, + context_pruning: buildContextPruningPayload(agent.context_pruning, pruningEnabled, pruningConfig), compaction_config: compactionConfig, subagents_config: subEnabled ? subConfig : null, tools_config: toolsEnabled ? toolsConfig : {}, diff --git a/ui/desktop/frontend/src/lib/__tests__/context-pruning.test.ts b/ui/desktop/frontend/src/lib/__tests__/context-pruning.test.ts new file mode 100644 index 00000000..1fcbcc82 --- /dev/null +++ b/ui/desktop/frontend/src/lib/__tests__/context-pruning.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { buildContextPruningPayload, isContextPruningEnabled } from '../context-pruning' + +describe('isContextPruningEnabled', () => { + it('treats an unset config as the backend default (enabled)', () => { + expect(isContextPruningEnabled(null)).toBe(true) + expect(isContextPruningEnabled(undefined)).toBe(true) + expect(isContextPruningEnabled({ keepLastAssistants: 5 })).toBe(true) + }) + + it('is disabled only for an explicit off mode', () => { + expect(isContextPruningEnabled({ mode: 'off' })).toBe(false) + expect(isContextPruningEnabled({ mode: 'cache-ttl' })).toBe(true) + }) +}) + +describe('buildContextPruningPayload', () => { + it('leaves an inherited (unset) config unset when the user did not change it', () => { + expect(buildContextPruningPayload(null, true, {})).toBeUndefined() + }) + + it('saves an explicit off mode when disabled, since null means enabled', () => { + expect(buildContextPruningPayload(null, false, { keepLastAssistants: 5 })).toEqual({ mode: 'off' }) + }) + + it('keeps settings and forces cache-ttl mode when re-enabled', () => { + expect(buildContextPruningPayload({ mode: 'off' }, true, { mode: 'off', keepLastAssistants: 5 })).toEqual({ + mode: 'cache-ttl', + keepLastAssistants: 5, + }) + }) +}) diff --git a/ui/desktop/frontend/src/lib/context-pruning.ts b/ui/desktop/frontend/src/lib/context-pruning.ts new file mode 100644 index 00000000..a3da2532 --- /dev/null +++ b/ui/desktop/frontend/src/lib/context-pruning.ts @@ -0,0 +1,16 @@ +import type { ContextPruningConfig } from '../types/agent' + +// The backend treats an unset context_pruning as enabled (cache-ttl); only mode "off" disables it. +export function isContextPruningEnabled(config: ContextPruningConfig | null | undefined): boolean { + return config?.mode !== 'off' +} + +// An unset config inherits the global default, so it stays unset unless the user changed it. +export function buildContextPruningPayload( + current: ContextPruningConfig | null | undefined, + enabled: boolean, + config: ContextPruningConfig, +): ContextPruningConfig | undefined { + if (current == null && enabled && Object.keys(config).length === 0) return undefined + return enabled ? { ...config, mode: 'cache-ttl' } : { mode: 'off' } +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-pruning.test.ts b/ui/web/src/pages/agents/agent-detail/agent-advanced-pruning.test.ts new file mode 100644 index 00000000..e15013bb --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-pruning.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { AgentData } from "@/types/agent"; +import { buildAdvancedUpdatePayload, deriveState } from "./agent-advanced-state-utils"; + +const baseAgent = { + id: "agent-1", + agent_key: "captain", + provider: "9router", + model: "cx/gpt-6-astra-high", + other_config: {}, +} as unknown as AgentData; + +function pruneEnabledFor(contextPruning: unknown) { + return deriveState({ ...baseAgent, context_pruning: contextPruning } as AgentData, undefined).pruneEnabled; +} + +describe("context pruning toggle state", () => { + it("is enabled when the agent inherits the backend default", () => { + expect(pruneEnabledFor(null)).toBe(true); + expect(pruneEnabledFor(undefined)).toBe(true); + expect(pruneEnabledFor({ keepLastAssistants: 5 })).toBe(true); + }); + + it("is enabled for cache-ttl mode", () => { + expect(pruneEnabledFor({ mode: "cache-ttl" })).toBe(true); + }); + + it("is disabled only for an explicit off mode", () => { + expect(pruneEnabledFor({ mode: "off" })).toBe(false); + }); +}); + +function pruningPayloadFor(contextPruning: unknown, pruneEnabled: boolean, prune: Record) { + return buildAdvancedUpdatePayload({ + agent: { ...baseAgent, context_pruning: contextPruning } as AgentData, + currentProvider: undefined, + providersLoading: false, + providerModelsLoading: false, + expertReasoningAvailable: false, + reasoningMode: "inherit", + reasoningEffort: "off", + reasoningExpert: false, + reasoningFallback: "downgrade", + thinkingLevel: "off", + chatgptRouting: {}, + modelFallback: { enabled: false, strategy: "priority_order", candidates: [] }, + wsSharing: {}, + comp: {}, + deliveryBehaviorMode: "inherit", + deliveryBehavior: {}, + inboundDebounceMode: "inherit", + inboundDebounceMs: 0, + pruneEnabled, + prune, + sbEnabled: false, + sb: {}, + }).context_pruning; +} + +describe("context pruning update payload", () => { + it("leaves an inherited (unset) config unset when the user did not change it", () => { + expect(pruningPayloadFor(null, true, {})).toBeUndefined(); + }); + + it("re-enables a previously disabled agent", () => { + expect(pruningPayloadFor({ mode: "off", keepLastAssistants: 5 }, true, { mode: "off", keepLastAssistants: 5 })).toEqual({ + mode: "cache-ttl", + keepLastAssistants: 5, + }); + }); + + it("saves an explicit off mode when disabled", () => { + expect(pruningPayloadFor(null, false, {})).toEqual({ mode: "off" }); + }); +}); diff --git a/ui/web/src/pages/agents/agent-detail/agent-advanced-state-utils.ts b/ui/web/src/pages/agents/agent-detail/agent-advanced-state-utils.ts index 184f8a11..e792cef0 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-advanced-state-utils.ts +++ b/ui/web/src/pages/agents/agent-detail/agent-advanced-state-utils.ts @@ -123,7 +123,7 @@ export function deriveState( }, inboundDebounceMode: inboundDebounceMs === undefined ? "inherit" : "custom", inboundDebounceMs: inboundDebounceMs ?? 0, - pruneEnabled: agent.context_pruning?.mode === "cache-ttl", + pruneEnabled: agent.context_pruning?.mode !== "off", prune: agent.context_pruning ?? {}, sbEnabled: agent.sandbox_config != null, sb: agent.sandbox_config ?? {}, @@ -177,9 +177,7 @@ export function buildAdvancedUpdatePayload( const updates: Record = { compaction_config: comp, - context_pruning: pruneEnabled - ? { mode: "cache-ttl", ...prune } - : { mode: "off" }, + context_pruning: buildContextPruningPayload(agent.context_pruning, pruneEnabled, prune), sandbox_config: sbEnabled ? sb : null, model_fallback: normalizeModelFallbackForPayload(modelFallback), ...routingPayload, @@ -230,6 +228,16 @@ export function buildAdvancedUpdatePayload( return updates; } +// An unset context_pruning inherits the global default; keep it unset unless the user changed it. +function buildContextPruningPayload( + current: ContextPruningConfig | null | undefined, + enabled: boolean, + prune: ContextPruningConfig, +): ContextPruningConfig | undefined { + if (current == null && enabled && Object.keys(prune).length === 0) return undefined; + return enabled ? { ...prune, mode: "cache-ttl" } : { mode: "off" }; +} + function buildOtherConfigWithDeliveryBehavior( base: unknown, mode: "inherit" | "custom", diff --git a/ui/web/src/pages/config/sections/ai-defaults-section.tsx b/ui/web/src/pages/config/sections/ai-defaults-section.tsx index c3d5cf36..baadf72d 100644 --- a/ui/web/src/pages/config/sections/ai-defaults-section.tsx +++ b/ui/web/src/pages/config/sections/ai-defaults-section.tsx @@ -209,7 +209,7 @@ export function AiDefaultsSection({ data, onSave, saving }: Props) {
- updateNested("contextPruning", { mode: v })}> Off