mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b1689a62 | ||
|
|
6a258e1876 | ||
|
|
e3337d06d5 | ||
|
|
3aa190bd7f | ||
|
|
00ad4d4b8d | ||
|
|
a394fd60ee |
No files matched your search
@@ -38,9 +38,8 @@ const compactionTriggerRatio = 0.7
|
||||
// transcript (and so tests can assert on it).
|
||||
const summaryLabel = "[Summary of earlier conversation]"
|
||||
|
||||
// CompactionSummaryInstructions is the system prompt handed to both automatic
|
||||
// and manually requested compaction summarizers.
|
||||
const CompactionSummaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
// summaryInstructions is the system prompt handed to the summarizer model.
|
||||
const summaryInstructions = "You are compacting a coding-assistant conversation to save context. " +
|
||||
"Write a dense, factual summary of the conversation so far. Preserve: the user's goals and explicit constraints; " +
|
||||
"decisions made and why; files created or modified (with paths) and key code changes; commands run and their important " +
|
||||
"results; and anything still in progress or unresolved. Omit pleasantries. Use terse bullet points. Do not invent details. " +
|
||||
@@ -76,12 +75,6 @@ type CompactionResult struct {
|
||||
// summary message also includes summaryLabel and any preserved structured
|
||||
// state needed by later compactions.
|
||||
SummaryText string
|
||||
// ProjectedChars is the text content sent to the summarizer after semantic
|
||||
// projection, excluding provider protocol framing.
|
||||
ProjectedChars int
|
||||
// Truncated reports whether projection dropped content to satisfy its
|
||||
// bounded brief or previous-summary limits.
|
||||
Truncated bool
|
||||
// Compacted reports whether Messages contains an injected summary.
|
||||
Compacted bool
|
||||
}
|
||||
@@ -223,11 +216,11 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
summaryResult, err := SummarizeCompactionMessages(middle, opts.Summarize)
|
||||
summary, err := opts.Summarize(middle)
|
||||
if err != nil {
|
||||
return CompactionResult{}, err
|
||||
}
|
||||
summary := summaryResult.SummaryText
|
||||
summary = strings.TrimSpace(summary)
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
@@ -245,53 +238,10 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
RemovedCount: len(middle),
|
||||
PreservedCount: len(messages) - len(middle),
|
||||
SummaryText: summary,
|
||||
ProjectedChars: summaryResult.ProjectedChars,
|
||||
Truncated: summaryResult.Truncated,
|
||||
Compacted: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CompactionSummaryResult describes the shared summarization input and output.
|
||||
type CompactionSummaryResult struct {
|
||||
SummaryText string
|
||||
ProjectedChars int
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// SummarizeCompactionMessages applies the shared semantic projection before
|
||||
// invoking a caller-supplied summarizer. Automatic context-pressure compaction
|
||||
// and manual session compaction both use this path.
|
||||
func SummarizeCompactionMessages(messages []zeroruntime.Message, summarize func([]zeroruntime.Message) (string, error)) (CompactionSummaryResult, error) {
|
||||
if summarize == nil {
|
||||
return CompactionSummaryResult{}, errors.New("compaction requires a Summarize function")
|
||||
}
|
||||
projection := projectCompactionInput(messages)
|
||||
summaryInput := projection.messages
|
||||
if len(summaryInput) == 0 {
|
||||
summaryInput = messages
|
||||
}
|
||||
summary, err := summarize(summaryInput)
|
||||
if err != nil {
|
||||
return CompactionSummaryResult{}, err
|
||||
}
|
||||
return CompactionSummaryResult{
|
||||
SummaryText: strings.TrimSpace(summary),
|
||||
ProjectedChars: compactionMessageChars(summaryInput),
|
||||
Truncated: projection.truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compactionMessageChars(messages []zeroruntime.Message) int {
|
||||
total := 0
|
||||
for _, message := range messages {
|
||||
total += len(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
total += len(call.Name) + len(call.Arguments)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// safeSuffixBoundary walks the preserve boundary backward (toward systemEnd) so
|
||||
// the preserved suffix begins on a user or assistant message rather than a
|
||||
// tool/tool_result message. A tool result with no preceding assistant tool call
|
||||
@@ -381,8 +331,6 @@ type compactionState struct {
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
planner *contextPlanner
|
||||
trace *trace.Recorder
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
@@ -429,8 +377,6 @@ func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
|
||||
trace: options.Trace,
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -486,7 +432,7 @@ func (state *compactionState) maybeCompact(
|
||||
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -537,7 +483,7 @@ func (state *compactionState) recover(
|
||||
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: state.summarizeClosure(ctx, provider),
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
@@ -571,12 +517,9 @@ func (state *compactionState) recover(
|
||||
// provider call. The summary stream intentionally does NOT forward OnText (so
|
||||
// compaction stays invisible on the user-facing surface), but it DOES forward
|
||||
// OnUsage so the summarizer's token cost is still counted by usage/budgeting.
|
||||
func (state *compactionState) summarizeClosure(ctx context.Context, provider Provider) func([]zeroruntime.Message) (string, error) {
|
||||
if state.planner == nil {
|
||||
state.planner = newContextPlanner(contextPlannerConfig{})
|
||||
}
|
||||
func summarizeClosure(ctx context.Context, provider Provider, onUsage func(Usage)) func([]zeroruntime.Message) (string, error) {
|
||||
return func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
return summarizeWithPlanner(ctx, provider, toSummarize, state.onUsage, state.planner, state.trace)
|
||||
return summarizeWithFallback(ctx, provider, toSummarize, onUsage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,11 +531,7 @@ func (state *compactionState) summarizeClosure(ctx context.Context, provider Pro
|
||||
// Non-context-limit errors (and a single message that still won't fit) surface
|
||||
// to the caller unchanged.
|
||||
func summarizeWithFallback(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
return summarizeWithPlanner(ctx, provider, messages, onUsage, newContextPlanner(contextPlannerConfig{}), nil)
|
||||
}
|
||||
|
||||
func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage, planner, recorder)
|
||||
summary, err := summarizeMessagesOnce(ctx, provider, messages, onUsage)
|
||||
if err == nil {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -601,11 +540,11 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
}
|
||||
|
||||
mid := len(messages) / 2
|
||||
left, leftErr := summarizeWithPlanner(ctx, provider, messages[:mid], onUsage, planner, recorder)
|
||||
left, leftErr := summarizeWithFallback(ctx, provider, messages[:mid], onUsage)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
right, rightErr := summarizeWithPlanner(ctx, provider, messages[mid:], onUsage, planner, recorder)
|
||||
right, rightErr := summarizeWithFallback(ctx, provider, messages[mid:], onUsage)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
@@ -618,7 +557,7 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
combined := strings.TrimSpace(left + "\n\n" + right)
|
||||
reduced, reduceErr := summarizeMessagesOnce(ctx, provider, []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: combined},
|
||||
}, onUsage, planner, recorder)
|
||||
}, onUsage)
|
||||
if reduceErr != nil {
|
||||
if isContextLimitError(reduceErr.Error()) {
|
||||
// Even the two combined partials don't fit (extreme): fall back to the
|
||||
@@ -634,15 +573,15 @@ func summarizeWithPlanner(ctx context.Context, provider Provider, messages []zer
|
||||
}
|
||||
|
||||
// summarizeMessagesOnce performs a single tool-less summarization call.
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage), planner *contextPlanner, recorder *trace.Recorder) (string, error) {
|
||||
requestMessages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: CompactionSummaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
func summarizeMessagesOnce(ctx context.Context, provider Provider, messages []zeroruntime.Message, onUsage func(Usage)) (string, error) {
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: summaryInstructions},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Summarize this conversation:\n\n" + renderTranscript(messages)},
|
||||
},
|
||||
// No tools: this is a plain text summarization call.
|
||||
}
|
||||
parts := systemPromptParts{prompt: CompactionSummaryInstructions, baseInstructions: CompactionSummaryInstructions}
|
||||
plan := planner.planWithPromptParts(requestMessages, nil, "", parts)
|
||||
recordContextPlanTrace(recorder, plan)
|
||||
stream, err := provider.StreamCompletion(ctx, plan.Request)
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -43,14 +43,8 @@ func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
if result.SummaryText != "manual summary" {
|
||||
t.Fatalf("SummaryText = %q, want trimmed summary", result.SummaryText)
|
||||
}
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
if result.Truncated {
|
||||
t.Fatal("Truncated = true for a projection within its limits")
|
||||
}
|
||||
if len(captured) != 1 || !strings.Contains(captured[0].Content, "first question") || !strings.Contains(captured[0].Content, "second question") {
|
||||
t.Fatalf("summarized projection = %#v, want intent from the non-preserved middle", captured)
|
||||
if len(captured) != 3 || captured[0].Content != "first question" || captured[2].Content != "second question" {
|
||||
t.Fatalf("summarized middle = %#v, want the three non-preserved non-system messages", captured)
|
||||
}
|
||||
if len(result.Messages) != 4 {
|
||||
t.Fatalf("compacted message count = %d, want 4", len(result.Messages))
|
||||
@@ -102,46 +96,11 @@ func TestCompactMessagesNoopReturnsUncompactedMetadata(t *testing.T) {
|
||||
if result.SummaryText != "" {
|
||||
t.Fatalf("SummaryText = %q, want empty", result.SummaryText)
|
||||
}
|
||||
if result.ProjectedChars != 0 || result.Truncated {
|
||||
t.Fatalf("no-op projection metadata = (%d, %t), want zero values", result.ProjectedChars, result.Truncated)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Messages, messages) {
|
||||
t.Fatalf("Messages changed on no-op: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesReturnsTruncatedProjectionMetadata(t *testing.T) {
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}}
|
||||
for index := range 20 {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: strings.Repeat("contextword ", 256) + string(rune('a'+index)),
|
||||
})
|
||||
}
|
||||
messages = append(messages,
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "recent answer"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "latest question"},
|
||||
)
|
||||
|
||||
var captured []zeroruntime.Message
|
||||
result, err := CompactMessages(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func(toSummarize []zeroruntime.Message) (string, error) {
|
||||
captured = append([]zeroruntime.Message(nil), toSummarize...)
|
||||
return "bounded summary", nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted || !result.Truncated {
|
||||
t.Fatalf("compaction metadata = compacted %t, truncated %t; want both true", result.Compacted, result.Truncated)
|
||||
}
|
||||
if result.ProjectedChars != compactionMessageChars(captured) || result.ProjectedChars == 0 {
|
||||
t.Fatalf("ProjectedChars = %d, want %d", result.ProjectedChars, compactionMessageChars(captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactMessagesPropagatesSummarizeError(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
|
||||
@@ -102,7 +102,7 @@ type skillEntry struct {
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns files mutated by editing calls in messages
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
@@ -113,7 +113,6 @@ type skillEntry struct {
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
noteByPath := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
@@ -130,24 +129,12 @@ func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || len(message.ChangedFiles) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, path := range message.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
@@ -438,20 +425,15 @@ type preservedState struct {
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
@@ -486,12 +468,7 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
Constraints: mergeBoundedComparable(priorTaskConstraints(priorState.Task), taskSnapshot.Constraints, maxTaskConstraints),
|
||||
ChangedFiles: mergeBoundedComparable(priorTaskChangedFiles(priorState.Task), taskSnapshot.ChangedFiles, maxTaskEvidenceEntries),
|
||||
UnresolvedFailures: mergeTaskFailures(priorTaskFailures(priorState.Task), taskSnapshot.UnresolvedFailures, taskSnapshot.ResolvedFailureKeys),
|
||||
Approvals: mergeBoundedComparable(priorTaskApprovals(priorState.Task), taskSnapshot.Approvals, maxTaskEvidenceEntries),
|
||||
Artifacts: mergeBoundedComparable(priorTaskArtifacts(priorState.Task), taskSnapshot.Artifacts, maxTaskEvidenceEntries),
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
@@ -507,13 +484,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
freshConstraints := explicitConstraintsFromMessages(middle)
|
||||
if len(freshConstraints) > 0 {
|
||||
if task == nil {
|
||||
task = &preservedTaskState{}
|
||||
}
|
||||
task.Constraints = mergeBoundedComparable(task.Constraints, freshConstraints, maxTaskConstraints)
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -547,95 +517,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
return summary
|
||||
}
|
||||
|
||||
func explicitConstraintsFromMessages(messages []zeroruntime.Message) []string {
|
||||
var constraints []string
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleUser || strings.Contains(message.Content, preservedStateLabel) {
|
||||
continue
|
||||
}
|
||||
if _, body := projectInstructionBlock(message.Content); body != "" {
|
||||
continue
|
||||
}
|
||||
constraints = mergeBoundedComparable(constraints, extractExplicitConstraints(message.Content), maxTaskConstraints)
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func priorTaskConstraints(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Constraints
|
||||
}
|
||||
|
||||
func priorTaskChangedFiles(task *preservedTaskState) []string {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.ChangedFiles
|
||||
}
|
||||
|
||||
func priorTaskFailures(task *preservedTaskState) []taskFailureState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.UnresolvedFailures
|
||||
}
|
||||
|
||||
func priorTaskApprovals(task *preservedTaskState) []taskApprovalState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Approvals
|
||||
}
|
||||
|
||||
func priorTaskArtifacts(task *preservedTaskState) []taskArtifactState {
|
||||
if task == nil {
|
||||
return nil
|
||||
}
|
||||
return task.Artifacts
|
||||
}
|
||||
|
||||
func mergeTaskFailures(older, newer []taskFailureState, resolved []string) []taskFailureState {
|
||||
resolvedSet := make(map[string]struct{}, len(resolved))
|
||||
for _, key := range resolved {
|
||||
resolvedSet[key] = struct{}{}
|
||||
}
|
||||
merged := make([]taskFailureState, 0, len(older)+len(newer))
|
||||
for _, failure := range append(append([]taskFailureState(nil), older...), newer...) {
|
||||
if _, ok := resolvedSet[failure.Key]; ok {
|
||||
continue
|
||||
}
|
||||
withoutSameKey := make([]taskFailureState, 0, len(merged))
|
||||
for _, existing := range merged {
|
||||
if existing.Key != failure.Key {
|
||||
withoutSameKey = append(withoutSameKey, existing)
|
||||
}
|
||||
}
|
||||
merged = append(withoutSameKey, failure)
|
||||
}
|
||||
if len(merged) > maxTaskEvidenceEntries {
|
||||
merged = merged[len(merged)-maxTaskEvidenceEntries:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeBoundedComparable[T comparable](older, newer []T, limit int) []T {
|
||||
merged := make([]T, 0, len(older)+len(newer))
|
||||
seen := make(map[T]struct{}, len(older)+len(newer))
|
||||
for _, value := range append(append([]T(nil), older...), newer...) {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
merged = append(merged, value)
|
||||
}
|
||||
if len(merged) > limit {
|
||||
merged = merged[len(merged)-limit:]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -65,9 +64,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"patch":"*** Update File: internal/db.go"}`, toolResult: ToolResult{
|
||||
Name: "apply_patch", Status: tools.StatusOK, Output: "Done!", ChangedFiles: []string{"internal/db.go"},
|
||||
}})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
@@ -84,69 +80,6 @@ func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
if len(state.Task.ChangedFiles) != 1 || state.Task.ChangedFiles[0] != "internal/db.go" {
|
||||
t.Fatalf("changed files were not preserved: %#v", state.Task.ChangedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesRuntimeEvidenceAcrossRepeatedCompaction(t *testing.T) {
|
||||
task := newTaskState("Please keep the change focused.", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests failed",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession, Scope: "/tmp",
|
||||
}})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"cmd":"go test ./..."}`, toolResult: ToolResult{
|
||||
Name: "exec_command", Status: tools.StatusError, Output: "Error: tests still fail with newer evidence",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/tests.txt"},
|
||||
}})
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "Never add background memory models."},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "understood"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
)
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: task.snapshotForCompaction(secondInput),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || len(state.Task.UnresolvedFailures) != 1 || len(state.Task.Approvals) != 1 || len(state.Task.Artifacts) != 1 {
|
||||
t.Fatalf("runtime evidence did not survive repeated compaction: %#v", state.Task)
|
||||
}
|
||||
if got := state.Task.UnresolvedFailures[0].Summary; got != "Error: tests still fail with newer evidence" {
|
||||
t.Fatalf("newer failure evidence did not replace the older summary: %#v", state.Task.UnresolvedFailures)
|
||||
}
|
||||
for _, want := range []string{"Please keep the change focused.", "Never add background memory models."} {
|
||||
if !containsString(state.Task.Constraints, want) {
|
||||
t.Fatalf("constraint %q missing after repeated compaction: %#v", want, state.Task.Constraints)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
compactionUserWordBudget = 256
|
||||
compactionAssistantWordBudget = 200
|
||||
compactionToolCallsPerTurn = 8
|
||||
compactionToolArgumentBytes = 120
|
||||
compactionErrorBytes = 150
|
||||
compactionResultBytes = 1200
|
||||
compactionPreviousSummaryBytes = 16 * 1024
|
||||
compactionBriefMaxBytes = 24 * 1024
|
||||
)
|
||||
|
||||
var compactionWordPattern = regexp.MustCompile(`[\p{L}\p{N}]+`)
|
||||
|
||||
type compactionProjection struct {
|
||||
messages []zeroruntime.Message
|
||||
truncated bool
|
||||
}
|
||||
|
||||
// projectCompactionInput removes reconstructible tool output before the model
|
||||
// summary call. It retains a bounded transcript of user intent, assistant
|
||||
// decisions, tool actions, and concise errors. Exact durable state is appended
|
||||
// separately from the original messages after the summary returns.
|
||||
func projectCompactionInput(messages []zeroruntime.Message) compactionProjection {
|
||||
sections := make([]string, 0, len(messages))
|
||||
previousSummary := ""
|
||||
truncated := false
|
||||
for index, message := range messages {
|
||||
switch message.Role {
|
||||
case zeroruntime.MessageRoleUser:
|
||||
content := message.Content
|
||||
if marker := strings.Index(content, preservedStateLabel); marker >= 0 {
|
||||
content = content[:marker]
|
||||
}
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if strings.HasPrefix(trimmed, summaryLabel) {
|
||||
fullSummary := strings.TrimSpace(strings.TrimPrefix(trimmed, summaryLabel))
|
||||
previousSummary = clipHeadTailBytes(fullSummary, compactionPreviousSummaryBytes)
|
||||
truncated = truncated || len(fullSummary) > compactionPreviousSummaryBytes
|
||||
continue
|
||||
}
|
||||
if content = clipInformativeWords(content, compactionUserWordBudget); content != "" {
|
||||
sections = append(sections, fmt.Sprintf("[user #%d]\n%s", index, content))
|
||||
}
|
||||
case zeroruntime.MessageRoleAssistant:
|
||||
lines := make([]string, 0, 1+len(message.ToolCalls))
|
||||
if content := clipInformativeWords(message.Content, compactionAssistantWordBudget); content != "" {
|
||||
lines = append(lines, content)
|
||||
}
|
||||
calls := message.ToolCalls
|
||||
omitted := 0
|
||||
if len(calls) > compactionToolCallsPerTurn {
|
||||
omitted = len(calls) - compactionToolCallsPerTurn
|
||||
calls = calls[omitted:]
|
||||
}
|
||||
if omitted > 0 {
|
||||
lines = append(lines, fmt.Sprintf("* (%d earlier tool calls omitted)", omitted))
|
||||
}
|
||||
for _, call := range calls {
|
||||
lines = append(lines, compactionToolCallLine(call))
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
sections = append(sections, fmt.Sprintf("[assistant #%d]\n%s", index, strings.Join(lines, "\n")))
|
||||
}
|
||||
case zeroruntime.MessageRoleTool:
|
||||
// New tool-result messages carry the execution status directly, as the
|
||||
// source ToolResult does. Keep the text check for older session history
|
||||
// created before Message exposed IsError.
|
||||
name := toolNameForResult(messages, index)
|
||||
mustPreserve := name == "ask_user" || len(message.ChangedFiles) > 0
|
||||
if !mustPreserve && !message.IsError && !isLikelyToolError(message.Content) {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = "tool"
|
||||
}
|
||||
switch {
|
||||
case message.IsError || isLikelyToolError(message.Content):
|
||||
sections = append(sections, fmt.Sprintf("[tool_error #%d] %s\n%s", index, name, clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
case name == "ask_user":
|
||||
sections = append(sections, fmt.Sprintf("[user_answer #%d] %s\n%s", index, name, clipBytes(message.Content, compactionResultBytes)))
|
||||
default:
|
||||
sections = append(sections, fmt.Sprintf("[tool_result #%d] %s changed %s\n%s", index, name,
|
||||
clipBytes(strings.Join(message.ChangedFiles, ", "), compactionToolArgumentBytes), clipBytes(firstLine(message.Content), compactionErrorBytes)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sections) == 0 && previousSummary == "" {
|
||||
return compactionProjection{}
|
||||
}
|
||||
brief, briefTruncated := capCompactionBrief(strings.Join(sections, "\n\n"))
|
||||
truncated = truncated || briefTruncated
|
||||
if previousSummary != "" {
|
||||
brief = "[previous summary]\n" + previousSummary + "\n\n" + brief
|
||||
}
|
||||
return compactionProjection{
|
||||
messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: strings.TrimSpace(brief)}},
|
||||
truncated: truncated,
|
||||
}
|
||||
}
|
||||
|
||||
func capCompactionBrief(brief string) (string, bool) {
|
||||
if len(brief) <= compactionBriefMaxBytes {
|
||||
return brief, false
|
||||
}
|
||||
marker := "\n\n...[middle transcript omitted to fit compaction budget]...\n\n"
|
||||
available := compactionBriefMaxBytes - len(marker)
|
||||
// Keep both chronological edges: the oldest material has not necessarily
|
||||
// been summarized before, while the newest material is most actionable.
|
||||
headBytes := available * 2 / 5
|
||||
tailBytes := available - headBytes
|
||||
head := clipPrefixAtBoundary(brief, headBytes)
|
||||
tail := clipSuffixAtBoundary(brief, tailBytes)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail), true
|
||||
}
|
||||
|
||||
func compactionToolCallLine(call zeroruntime.ToolCall) string {
|
||||
detail := ""
|
||||
var arguments map[string]any
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(call.Arguments)), &arguments) == nil {
|
||||
for _, key := range []string{"file_path", "path", "url", "query", "pattern", "prompt", "description", "question"} {
|
||||
if value, ok := arguments[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
detail = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == "" && call.Name == "ask_user" {
|
||||
detail = askUserQuestionsDetail(arguments)
|
||||
}
|
||||
if detail == "" && call.Name == "apply_patch" {
|
||||
if patch, ok := arguments["patch"].(string); ok {
|
||||
detail = strings.Join(patchPaths(patch), ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = commandFromArguments(call.Arguments)
|
||||
}
|
||||
if detail == "" {
|
||||
return "* " + call.Name
|
||||
}
|
||||
return fmt.Sprintf("* %s %q", call.Name, clipBytes(detail, compactionToolArgumentBytes))
|
||||
}
|
||||
|
||||
func isLikelyToolError(content string) bool {
|
||||
prefix := strings.TrimSpace(content)
|
||||
if len(prefix) > compactionErrorBytes {
|
||||
prefix = prefix[:compactionErrorBytes]
|
||||
}
|
||||
lower := strings.ToLower(prefix)
|
||||
for _, prefix := range []string{"error:", "tool error:", "failed:", "permission denied", "command failed"} {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clipInformativeWords(text string, limit int) string {
|
||||
flat := strings.Join(strings.Fields(text), " ")
|
||||
if flat == "" || limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for _, location := range compactionWordPattern.FindAllStringIndex(flat, -1) {
|
||||
word := strings.ToLower(flat[location[0]:location[1]])
|
||||
if compactionStopWords[word] {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if count > limit {
|
||||
end := location[0]
|
||||
return strings.TrimSpace(flat[:end]) + "...(truncated)"
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func clipBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 3 {
|
||||
end := limit
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
end := limit - 3
|
||||
for end > 0 && end < len(text) && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
return strings.TrimSpace(text[:end]) + "..."
|
||||
}
|
||||
|
||||
func clipHeadTailBytes(text string, limit int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
marker := "\n...[middle omitted]...\n"
|
||||
if limit <= len(marker) {
|
||||
return clipBytes(text, limit)
|
||||
}
|
||||
available := limit - len(marker)
|
||||
head := clipPrefixAtBoundary(text, available/2)
|
||||
tail := clipSuffixAtBoundary(text, available-available/2)
|
||||
return strings.TrimSpace(head) + marker + strings.TrimSpace(tail)
|
||||
}
|
||||
|
||||
func clipPrefixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
end := limit
|
||||
for end > 0 && text[end]&0xc0 == 0x80 {
|
||||
end--
|
||||
}
|
||||
if boundary := strings.LastIndex(text[:end], "\n\n["); boundary > 0 {
|
||||
end = boundary
|
||||
}
|
||||
return text[:end]
|
||||
}
|
||||
|
||||
func clipSuffixAtBoundary(text string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
start := len(text) - limit
|
||||
for start < len(text) && text[start]&0xc0 == 0x80 {
|
||||
start++
|
||||
}
|
||||
if boundary := strings.Index(text[start:], "\n\n["); boundary >= 0 {
|
||||
start += boundary + 2
|
||||
}
|
||||
return text[start:]
|
||||
}
|
||||
|
||||
func askUserQuestionsDetail(arguments map[string]any) string {
|
||||
raw, ok := arguments["questions"].([]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
questions := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if question, ok := object["question"].(string); ok && strings.TrimSpace(question) != "" {
|
||||
questions = append(questions, strings.TrimSpace(question))
|
||||
}
|
||||
}
|
||||
return strings.Join(questions, " | ")
|
||||
}
|
||||
|
||||
func patchPaths(patch string) []string {
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(patch, "\n") {
|
||||
for _, prefix := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
if path != "" && !seen[path] {
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
var compactionStopWords = map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true,
|
||||
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
|
||||
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
|
||||
"should": true, "may": true, "might": true, "shall": true, "can": true, "need": true, "must": true,
|
||||
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true, "at": true,
|
||||
"by": true, "from": true, "as": true, "into": true, "through": true, "during": true,
|
||||
"before": true, "after": true, "above": true, "below": true, "between": true, "under": true, "over": true,
|
||||
"and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true,
|
||||
"both": true, "either": true, "neither": true, "each": true, "every": true, "all": true,
|
||||
"any": true, "few": true, "more": true, "most": true, "other": true, "some": true, "such": true, "no": true,
|
||||
"that": true, "this": true, "these": true, "those": true, "it": true, "its": true,
|
||||
"i": true, "me": true, "my": true, "we": true, "our": true, "you": true, "your": true,
|
||||
"he": true, "him": true, "his": true, "she": true, "her": true, "they": true, "them": true, "their": true,
|
||||
"who": true, "which": true, "what": true, "if": true, "then": true, "than": true,
|
||||
"when": true, "where": true, "how": true, "just": true, "also": true,
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
|
||||
for _, limit := range []int{0, 1, 2, 3} {
|
||||
if clipped := clipBytes("世界", limit); len(clipped) > limit || !utf8.ValidString(clipped) {
|
||||
t.Fatalf("clipBytes limit %d returned invalid result %q", limit, clipped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundaryClippersHandleNonPositiveLimits(t *testing.T) {
|
||||
for _, clip := range []func(string, int) string{clipPrefixAtBoundary, clipSuffixAtBoundary} {
|
||||
for _, limit := range []int{-1, 0} {
|
||||
if got := clip("世界", limit); got != "" {
|
||||
t.Fatalf("clipper limit %d = %q, want empty", limit, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDropsRecoverableToolBodies(t *testing.T) {
|
||||
largeBody := strings.Repeat("recoverable source line\n", 1000)
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "Inspect the parser and preserve its current behavior."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "I will inspect before editing.", ToolCalls: []zeroruntime.ToolCall{{ID: "read", Name: "read_file", Arguments: `{"path":"internal/parser.go"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "read", Content: largeBody},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "test", Name: "exec_command", Arguments: `{"cmd":"go test ./internal/parser"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "test", Content: "Error: parser regression\nlong diagnostic details"},
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
projected := projection.messages
|
||||
if len(projected) != 1 {
|
||||
t.Fatalf("projection = %#v, want one compact message", projected)
|
||||
}
|
||||
content := projected[0].Content
|
||||
for _, want := range []string{"Inspect the parser", `read_file "internal/parser.go"`, `exec_command "go test ./internal/parser"`, "Error: parser regression"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, "recoverable source line") || strings.Contains(content, "long diagnostic details") {
|
||||
t.Fatalf("projection retained reconstructible or overlong tool output: %q", content)
|
||||
}
|
||||
if estimateTokens(projected)*20 >= estimateTokens(messages) {
|
||||
t.Fatalf("projection did not materially reduce tokens: before=%d after=%d", estimateTokens(messages), estimateTokens(projected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsStructurallyMarkedToolErrors(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "git", Name: "exec_command", Arguments: `{"cmd":"git status"}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "git", Content: "fatal: not a git repository", IsError: true},
|
||||
}
|
||||
|
||||
projected := projectCompactionInput(messages).messages
|
||||
if len(projected) != 1 || !strings.Contains(projected[0].Content, "fatal: not a git repository") {
|
||||
t.Fatalf("structured tool error was dropped from projection: %#v", projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputRetainsAskUserExchangeAndSemanticArguments(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{ID: "ask", Name: "ask_user", Arguments: `{"questions":[{"question":"Which database should remain?"}]}`}}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "ask", Content: "Which database should remain?: Postgres only; do not touch MySQL."},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "patch", Name: "apply_patch", Arguments: `{"patch":"*** Begin Patch\n*** Update File: internal/db.go\n*** End Patch"}`},
|
||||
{ID: "fetch", Name: "web_fetch", Arguments: `{"url":"https://example.com/spec"}`},
|
||||
{ID: "task", Name: "task", Arguments: `{"prompt":"Inspect the Windows implementation"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "patch", Content: "Done!", ChangedFiles: []string{"internal/db.go"}},
|
||||
}
|
||||
|
||||
content := projectCompactionInput(messages).messages[0].Content
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only; do not touch MySQL", "internal/db.go", "https://example.com/spec", "Inspect the Windows implementation"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("projection missing %q: %q", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCapsToolCallsPerTurn(t *testing.T) {
|
||||
calls := make([]zeroruntime.ToolCall, 12)
|
||||
for index := range calls {
|
||||
calls[index] = zeroruntime.ToolCall{Name: "read_file", Arguments: `{"path":"file.go"}`}
|
||||
}
|
||||
projected := projectCompactionInput([]zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: calls}}).messages
|
||||
content := projected[0].Content
|
||||
if strings.Count(content, "* read_file") != compactionToolCallsPerTurn || !strings.Contains(content, "4 earlier tool calls omitted") {
|
||||
t.Fatalf("tool-call tail was not bounded: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputCarriesPreviousSummaryOutsideBriefCap(t *testing.T) {
|
||||
previous := "keep the earlier architecture decision\n" + strings.Repeat("prior detail ", 2000) + "\nkeep the newest prior decision"
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\n" + previous + "\n\n" + preservedStateLabel + "\n{}"}}
|
||||
for index := range 500 {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: strings.Repeat("later transcript detail ", 20)})
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "later request " + strings.Repeat("context ", 20) + string(rune('a'+index%26))})
|
||||
}
|
||||
|
||||
projection := projectCompactionInput(messages)
|
||||
content := projection.messages[0].Content
|
||||
if !strings.Contains(content, "[previous summary]\nkeep the earlier architecture decision") || !strings.Contains(content, "keep the newest prior decision") {
|
||||
t.Fatalf("previous summary was lost when the brief tail was capped: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "middle transcript omitted to fit compaction budget") {
|
||||
t.Fatalf("oversized brief was not head/tail capped: %q", content)
|
||||
}
|
||||
if !projection.truncated {
|
||||
t.Fatal("projection did not structurally report truncation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCompactionInputDoesNotInferTruncationFromUserText(t *testing.T) {
|
||||
projection := projectCompactionInput([]zeroruntime.Message{{
|
||||
Role: zeroruntime.MessageRoleUser, Content: "Keep this literal marker: [middle omitted]",
|
||||
}})
|
||||
if projection.truncated {
|
||||
t.Fatal("literal user text produced a false truncation signal")
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,11 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e3", Name: "apply_patch", Arguments: `{"patch":"*** Update File: internal/baz.go"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e3", Content: "Done!", ChangedFiles: []string{"internal/baz.go"}},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 3 {
|
||||
t.Fatalf("expected 3 edited files, got %d: %#v", len(edits), edits)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
@@ -36,9 +32,6 @@ func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
if edits[2].name != "internal/baz.go" || edits[2].body != "Done!" {
|
||||
t.Fatalf("third edit = %#v, want apply_patch changed file with its note", edits[2])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -59,15 +58,12 @@ func TestCompactKeepsSystemAndPreservedSuffix(t *testing.T) {
|
||||
if last.Content != "most recent question" {
|
||||
t.Fatalf("expected most recent message preserved, got %q", last.Content)
|
||||
}
|
||||
// The summarizer receives a compact semantic projection of the middle rather
|
||||
// than reconstructible raw tool output.
|
||||
if len(captured) != 1 || captured[0].Role != zeroruntime.MessageRoleUser {
|
||||
t.Fatalf("expected one projected summary input, got %#v", captured)
|
||||
// The summarized middle excludes system and preserved suffix.
|
||||
if len(captured) != 3 {
|
||||
t.Fatalf("expected 3 summarized messages, got %d: %#v", len(captured), captured)
|
||||
}
|
||||
for _, want := range []string{"[user #0]", "first question", "[assistant #1]", "first answer", "second question"} {
|
||||
if !strings.Contains(captured[0].Content, want) {
|
||||
t.Fatalf("projected summary input missing %q: %q", want, captured[0].Content)
|
||||
}
|
||||
if captured[0].Content != "first question" {
|
||||
t.Fatalf("expected oldest non-system message first, got %#v", captured[0])
|
||||
}
|
||||
// Compaction must shrink the conversation.
|
||||
if estimateTokens(result) >= estimateTokens(messages) {
|
||||
@@ -429,7 +425,6 @@ type reactiveProvider struct {
|
||||
summarizeCalls int
|
||||
turnRequests int
|
||||
failedOnce bool
|
||||
connectError bool
|
||||
bigText string
|
||||
finalText string
|
||||
}
|
||||
@@ -451,9 +446,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
return streamEvents(toolTurnWithText(provider.bigText, "1", "read_file", `{"path":"x"}`)), nil
|
||||
case provider.turnRequests == 2 && !provider.failedOnce:
|
||||
provider.failedOnce = true
|
||||
if provider.connectError {
|
||||
return nil, errors.New("context_length_exceeded")
|
||||
}
|
||||
return streamEvents([]zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventError, Error: "This model's maximum context length is 1000 tokens. Please reduce the length of the messages."},
|
||||
}), nil
|
||||
@@ -462,39 +454,6 @@ func (provider *reactiveProvider) StreamCompletion(ctx context.Context, request
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConnectErrorCompactionRecordsReplacementPlan(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
connectError: true,
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("connect-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
Registry: registry,
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil || result.FinalAnswer != "recovered" {
|
||||
t.Fatalf("connect-error compaction failed: result=%#v err=%v", result, err)
|
||||
}
|
||||
if len(contextPlans) != 3 || contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("replacement request context not reported: %#v", contextPlans)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 || gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("replacement request trace not recorded: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
provider := &reactiveProvider{
|
||||
bigText: strings.Repeat("b", 6000),
|
||||
@@ -506,8 +465,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
recorder := trace.NewRecorder("reactive-compaction", "run-1", "test")
|
||||
var contextPlans []ContextBreakdown
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -516,10 +473,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
PermissionMode: PermissionModeUnsafe,
|
||||
ContextWindow: 10_000_000,
|
||||
CompactionPreserveLast: 2,
|
||||
Trace: recorder,
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
contextPlans = append(contextPlans, breakdown)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected reactive compaction to recover the run, got error: %v", err)
|
||||
@@ -530,25 +483,6 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
if provider.summarizeCalls == 0 {
|
||||
t.Fatal("expected reactive compaction to invoke the summarizer")
|
||||
}
|
||||
if len(contextPlans) != 3 {
|
||||
t.Fatalf("main request context callbacks = %d, want initial turns plus compacted retry", len(contextPlans))
|
||||
}
|
||||
if contextPlans[2].MessageTokens >= contextPlans[1].MessageTokens {
|
||||
t.Fatalf("retry context was not updated after compaction: before=%d after=%d", contextPlans[1].MessageTokens, contextPlans[2].MessageTokens)
|
||||
}
|
||||
gotTrace := recorder.Finish()
|
||||
if len(gotTrace.PrefixHashes) != 4 {
|
||||
t.Fatalf("prefix evidence count = %d, want two turns, compaction, and retry: %#v", len(gotTrace.PrefixHashes), gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[0].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[1].InvalidationReason != "unchanged" ||
|
||||
gotTrace.PrefixHashes[2].InvalidationReason != "initial" ||
|
||||
gotTrace.PrefixHashes[3].InvalidationReason != "unchanged" {
|
||||
t.Fatalf("unexpected request-specific invalidation evidence: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
if gotTrace.PrefixHashes[2].CompletePrefixHash == gotTrace.PrefixHashes[1].CompletePrefixHash {
|
||||
t.Fatalf("compaction fingerprint reused the main request prefix: %#v", gotTrace.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
// midStreamReactiveProvider forwards some text BEFORE surfacing a context-limit
|
||||
|
||||
@@ -23,24 +23,6 @@ type ContextBreakdown struct {
|
||||
TotalTokens int // SystemTokens + ToolTokens + MessageTokens
|
||||
ContextWindow int // model context window; 0 when unknown
|
||||
UsedFraction float64 // TotalTokens / ContextWindow; 0 when window unknown
|
||||
// Blocks explain why each model-visible category is present without exposing
|
||||
// its content. The planner currently preserves all three categories.
|
||||
Blocks []ContextBlock
|
||||
// CompletePrefixHash identifies the provider-visible system+tool prefix.
|
||||
CompletePrefixHash string
|
||||
// PrefixInvalidationReason is "initial", "unchanged", or a stable comma-
|
||||
// separated list of changed prefix categories.
|
||||
PrefixInvalidationReason string
|
||||
}
|
||||
|
||||
// ContextBlock is content-free evidence for one model-visible context category.
|
||||
type ContextBlock struct {
|
||||
Kind string
|
||||
Tokens int
|
||||
CacheClass string
|
||||
Authority string
|
||||
Recoverable bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// MeasureContext estimates the per-category token footprint of a request: the
|
||||
@@ -59,24 +41,6 @@ func MeasureContext(messages []zeroruntime.Message, tools []zeroruntime.ToolDefi
|
||||
ContextWindow: contextWindow,
|
||||
}
|
||||
breakdown.TotalTokens = breakdown.SystemTokens + breakdown.ToolTokens + breakdown.MessageTokens
|
||||
if breakdown.SystemTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "system", Tokens: breakdown.SystemTokens, CacheClass: "stable_prefix",
|
||||
Authority: "required", Reason: "instructions, workspace policy, and safety policy",
|
||||
})
|
||||
}
|
||||
if breakdown.ToolTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "tools", Tokens: breakdown.ToolTokens, CacheClass: "stable_prefix",
|
||||
Authority: "capability", Reason: "currently permitted and selected tool definitions",
|
||||
})
|
||||
}
|
||||
if breakdown.MessageTokens > 0 {
|
||||
breakdown.Blocks = append(breakdown.Blocks, ContextBlock{
|
||||
Kind: "conversation", Tokens: breakdown.MessageTokens, CacheClass: "dynamic_tail",
|
||||
Authority: "transcript", Reason: "valid provider replay history and current task context",
|
||||
})
|
||||
}
|
||||
if contextWindow > 0 {
|
||||
breakdown.UsedFraction = float64(breakdown.TotalTokens) / float64(contextWindow)
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ func TestMeasureContextSplitsByCategory(t *testing.T) {
|
||||
if breakdown.UsedFraction != wantFraction {
|
||||
t.Fatalf("UsedFraction = %v, want %v", breakdown.UsedFraction, wantFraction)
|
||||
}
|
||||
if len(breakdown.Blocks) != 3 {
|
||||
t.Fatalf("Blocks = %#v, want system/tools/conversation", breakdown.Blocks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeasureContextUnknownWindowHasZeroFraction(t *testing.T) {
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type contextPlannerConfig struct {
|
||||
contextWindow int
|
||||
promptCacheKey string
|
||||
promptParts systemPromptParts
|
||||
}
|
||||
|
||||
// contextPlanner is the single seam for constructing model-visible requests.
|
||||
// Planning is deterministic and does not retrieve content, execute tools, or
|
||||
// change permissions. The initial implementation deliberately preserves every
|
||||
// message and tool definition while making composition and cache drift
|
||||
// inspectable; later selection policies must continue to cross this seam.
|
||||
type contextPlanner struct {
|
||||
config contextPlannerConfig
|
||||
previousPrefix prefixFingerprint
|
||||
hasPrevious bool
|
||||
toolSnapshotKey string
|
||||
toolSnapshot []zeroruntime.ToolDefinition
|
||||
}
|
||||
|
||||
type contextPlan struct {
|
||||
Request zeroruntime.CompletionRequest
|
||||
Breakdown ContextBreakdown
|
||||
PrefixFingerprint prefixFingerprint
|
||||
}
|
||||
|
||||
func newContextPlanner(config contextPlannerConfig) *contextPlanner {
|
||||
return &contextPlanner{config: config}
|
||||
}
|
||||
|
||||
// Plan returns a provider request snapshot plus content-free accounting.
|
||||
// It intentionally performs no relevance filtering: preserving current model
|
||||
// capability is the baseline contract for future planner policies.
|
||||
func (planner *contextPlanner) Plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, planner.config.promptParts)
|
||||
}
|
||||
|
||||
// planWithPromptParts plans a request whose stable system sections differ from
|
||||
// the planner's configured main-run prompt, such as a compaction summary call.
|
||||
func (planner *contextPlanner) planWithPromptParts(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
return planner.plan(messages, toolDefs, reasoningEffort, parts)
|
||||
}
|
||||
|
||||
func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, reasoningEffort string, parts systemPromptParts) contextPlan {
|
||||
requestMessages := copyMessages(messages)
|
||||
requestSystemPrompt := leadingSystemContent(requestMessages)
|
||||
if parts.prompt != requestSystemPrompt {
|
||||
// Component boundaries from a different request would make the detailed
|
||||
// invalidation reason misleading. Retain the exact request prefix and
|
||||
// classify it conservatively as an unstructured system prompt instead.
|
||||
parts = systemPromptParts{prompt: requestSystemPrompt}
|
||||
}
|
||||
fingerprint := computePrefixFingerprint(buildPromptSubstringsFromParts(parts, toolDefs))
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: requestMessages,
|
||||
Tools: planner.snapshotTools(toolDefs, fingerprint),
|
||||
ReasoningEffort: reasoningEffort,
|
||||
PromptCacheKey: planner.config.promptCacheKey,
|
||||
}
|
||||
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
|
||||
breakdown.CompletePrefixHash = fingerprint.CompletePrefixHash
|
||||
var previous *prefixFingerprint
|
||||
if planner.hasPrevious {
|
||||
previous = &planner.previousPrefix
|
||||
}
|
||||
breakdown.PrefixInvalidationReason = explainPrefixChange(previous, fingerprint)
|
||||
planner.previousPrefix = fingerprint
|
||||
planner.hasPrevious = true
|
||||
return contextPlan{
|
||||
Request: request,
|
||||
Breakdown: breakdown,
|
||||
PrefixFingerprint: fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTools freezes provider-visible tool definitions when their semantic
|
||||
// fingerprint changes. Stable turns reuse the frozen schemas instead of
|
||||
// recursively cloning every map and slice for every request.
|
||||
func (planner *contextPlanner) snapshotTools(toolDefs []zeroruntime.ToolDefinition, fingerprint prefixFingerprint) []zeroruntime.ToolDefinition {
|
||||
key := fingerprint.ToolsHash + "\x00" + fingerprint.SchemaHash
|
||||
if planner.toolSnapshotKey != key {
|
||||
planner.toolSnapshot = copyToolDefinitions(toolDefs)
|
||||
planner.toolSnapshotKey = key
|
||||
}
|
||||
return planner.toolSnapshot
|
||||
}
|
||||
|
||||
func copyToolDefinitions(toolDefs []zeroruntime.ToolDefinition) []zeroruntime.ToolDefinition {
|
||||
if toolDefs == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make([]zeroruntime.ToolDefinition, len(toolDefs))
|
||||
for index, definition := range toolDefs {
|
||||
copied[index] = definition
|
||||
copied[index].Parameters = copySchemaMap(definition.Parameters)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaMap(schema map[string]any) map[string]any {
|
||||
if schema == nil {
|
||||
return nil
|
||||
}
|
||||
copied := make(map[string]any, len(schema))
|
||||
for key, value := range schema {
|
||||
copied[key] = copySchemaValue(value)
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
func copySchemaValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return copySchemaMap(typed)
|
||||
case []any:
|
||||
copied := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
copied[index] = copySchemaValue(item)
|
||||
}
|
||||
return copied
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func leadingSystemContent(messages []zeroruntime.Message) string {
|
||||
contents := make([]string, 0, 1)
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleSystem {
|
||||
break
|
||||
}
|
||||
contents = append(contents, message.Content)
|
||||
}
|
||||
return strings.Join(contents, "\n\n")
|
||||
}
|
||||
|
||||
func explainPrefixChange(previous *prefixFingerprint, current prefixFingerprint) string {
|
||||
if previous == nil {
|
||||
return "initial"
|
||||
}
|
||||
if previous.CompletePrefixHash == current.CompletePrefixHash {
|
||||
return "unchanged"
|
||||
}
|
||||
reasons := make([]string, 0, 3)
|
||||
if previous.SystemPromptHash != current.SystemPromptHash {
|
||||
parts := make([]string, 0, 4)
|
||||
if previous.BaseInstructionsHash != current.BaseInstructionsHash {
|
||||
parts = append(parts, "base_instructions")
|
||||
}
|
||||
if previous.ConfirmationPolicyHash != current.ConfirmationPolicyHash {
|
||||
parts = append(parts, "confirmation_policy")
|
||||
}
|
||||
if previous.ProjectContextHash != current.ProjectContextHash {
|
||||
parts = append(parts, "project_context")
|
||||
}
|
||||
if previous.SkillsHash != current.SkillsHash {
|
||||
parts = append(parts, "skills")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "system_prompt")
|
||||
}
|
||||
reasons = append(reasons, parts...)
|
||||
}
|
||||
if previous.ToolsHash != current.ToolsHash {
|
||||
reasons = append(reasons, "tools")
|
||||
}
|
||||
if previous.SchemaHash != current.SchemaHash {
|
||||
reasons = append(reasons, "schema")
|
||||
}
|
||||
if len(reasons) == 0 {
|
||||
return "prefix_changed"
|
||||
}
|
||||
return strings.Join(reasons, ",")
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "inspect this", Images: []zeroruntime.ImageBlock{{MediaType: "image/png", Data: []byte{1, 2, 3}}}},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working", ToolCalls: []zeroruntime.ToolCall{{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}}, Reasoning: []zeroruntime.ReasoningBlock{{Provider: "test", Type: "thinking", Signature: "sig"}}},
|
||||
}
|
||||
toolDefs := []zeroruntime.ToolDefinition{{
|
||||
Name: "read_file", Description: "Read a file",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"path"},
|
||||
"properties": map[string]any{"path": map[string]any{"type": "string"}},
|
||||
"anyOf": []any{map[string]any{"type": "object"}},
|
||||
},
|
||||
}}
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: 128_000,
|
||||
promptCacheKey: "session-1",
|
||||
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
|
||||
})
|
||||
|
||||
plan := planner.Plan(messages, toolDefs, "medium")
|
||||
want := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: "medium",
|
||||
PromptCacheKey: "session-1",
|
||||
}
|
||||
if !reflect.DeepEqual(plan.Request, want) {
|
||||
t.Fatalf("planned request changed provider input:\n got: %#v\nwant: %#v", plan.Request, want)
|
||||
}
|
||||
stable := planner.Plan(messages, toolDefs, "medium")
|
||||
if &stable.Request.Tools[0] != &plan.Request.Tools[0] {
|
||||
t.Fatal("stable tool fingerprint rebuilt the immutable schema snapshot")
|
||||
}
|
||||
|
||||
// A plan owns its request snapshot; later caller mutation cannot change its
|
||||
// messages or its recursively nested tool schema.
|
||||
messages[1].Content = "changed"
|
||||
messages[1].Images[0].Data[0] = 9
|
||||
toolDefs[0].Name = "changed_tool"
|
||||
toolDefs[0].Parameters["required"].([]string)[0] = "other"
|
||||
toolDefs[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] = "integer"
|
||||
toolDefs[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] = "array"
|
||||
if plan.Request.Messages[1].Content != "inspect this" || plan.Request.Messages[1].Images[0].Data[0] != 1 {
|
||||
t.Fatalf("planned messages alias caller state: %#v", plan.Request.Messages[1])
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" ||
|
||||
plan.Request.Tools[0].Parameters["required"].([]string)[0] != "path" ||
|
||||
plan.Request.Tools[0].Parameters["properties"].(map[string]any)["path"].(map[string]any)["type"] != "string" ||
|
||||
plan.Request.Tools[0].Parameters["anyOf"].([]any)[0].(map[string]any)["type"] != "object" {
|
||||
t.Fatalf("planned tools alias caller state: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
|
||||
next := planner.Plan(messages, toolDefs, "medium")
|
||||
if next.PrefixFingerprint == plan.PrefixFingerprint || next.Request.Tools[0].Name != "changed_tool" {
|
||||
t.Fatalf("changed tool schema did not refresh snapshot: %#v", next)
|
||||
}
|
||||
if plan.Request.Tools[0].Name != "read_file" {
|
||||
t.Fatalf("refresh mutated earlier request: %#v", plan.Request.Tools[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerReportsInspectableBlocks(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{contextWindow: 100_000})
|
||||
plan := planner.Plan([]zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: filler(400)},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: filler(200)},
|
||||
}, []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read a file"}}, "")
|
||||
|
||||
if plan.Breakdown.PrefixInvalidationReason != "initial" || plan.Breakdown.CompletePrefixHash == "" {
|
||||
t.Fatalf("prefix evidence = %#v", plan.Breakdown)
|
||||
}
|
||||
if len(plan.Breakdown.Blocks) != 3 {
|
||||
t.Fatalf("blocks = %#v, want system/tools/conversation", plan.Breakdown.Blocks)
|
||||
}
|
||||
wantKinds := []string{"system", "tools", "conversation"}
|
||||
total := 0
|
||||
for index, block := range plan.Breakdown.Blocks {
|
||||
if block.Kind != wantKinds[index] || block.Tokens <= 0 || block.Reason == "" || block.Authority == "" || block.CacheClass == "" {
|
||||
t.Fatalf("block %d = %#v", index, block)
|
||||
}
|
||||
total += block.Tokens
|
||||
}
|
||||
if total != plan.Breakdown.TotalTokens {
|
||||
t.Fatalf("block tokens = %d, want total %d", total, plan.Breakdown.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerFingerprintsActualRequestSystemPrompt(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "configured system", baseInstructions: "configured system",
|
||||
}})
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "request-specific system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "task"},
|
||||
}
|
||||
|
||||
plan := planner.Plan(messages, nil, "")
|
||||
want := computePrefixFingerprint(buildPromptSubstringsFromParts(
|
||||
systemPromptParts{prompt: "request-specific system"}, nil,
|
||||
))
|
||||
if plan.PrefixFingerprint != want {
|
||||
t.Fatalf("fingerprint describes configured prompt instead of request:\n got: %#v\nwant: %#v", plan.PrefixFingerprint, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextPlannerExplainsPrefixInvalidation(t *testing.T) {
|
||||
planner := newContextPlanner(contextPlannerConfig{promptParts: systemPromptParts{
|
||||
prompt: "system", baseInstructions: "base", projectContext: "project",
|
||||
}})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: "system"}, {Role: zeroruntime.MessageRoleUser, Content: "task"}}
|
||||
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Read", Parameters: map[string]any{"type": "object"}}}
|
||||
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "initial" {
|
||||
t.Fatalf("first reason = %q, want initial", got)
|
||||
}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "unchanged" {
|
||||
t.Fatalf("stable reason = %q, want unchanged", got)
|
||||
}
|
||||
tools[0].Description = "Read an exact file"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "tools" {
|
||||
t.Fatalf("tool reason = %q, want tools", got)
|
||||
}
|
||||
tools[0].Parameters["required"] = []any{"path"}
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "schema" {
|
||||
t.Fatalf("schema reason = %q, want schema", got)
|
||||
}
|
||||
planner.config.promptParts.prompt = "system with changed project"
|
||||
planner.config.promptParts.projectContext = "changed project"
|
||||
messages[0].Content = "system with changed project"
|
||||
if got := planner.Plan(messages, tools, "").Breakdown.PrefixInvalidationReason; got != "project_context" {
|
||||
t.Fatalf("project reason = %q, want project_context", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainPrefixChangeNamesEveryChangedComponent(t *testing.T) {
|
||||
baseline := prefixFingerprint{
|
||||
SystemPromptHash: "system",
|
||||
BaseInstructionsHash: "base",
|
||||
ConfirmationPolicyHash: "policy",
|
||||
ProjectContextHash: "project",
|
||||
SkillsHash: "skills",
|
||||
ToolsHash: "tools",
|
||||
SchemaHash: "schema",
|
||||
CompletePrefixHash: "complete",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*prefixFingerprint)
|
||||
want string
|
||||
}{
|
||||
{name: "base instructions", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.BaseInstructionsHash = "changed", "changed"
|
||||
}, want: "base_instructions"},
|
||||
{name: "confirmation policy", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ConfirmationPolicyHash = "changed", "changed"
|
||||
}, want: "confirmation_policy"},
|
||||
{name: "project context", mutate: func(value *prefixFingerprint) {
|
||||
value.SystemPromptHash, value.ProjectContextHash = "changed", "changed"
|
||||
}, want: "project_context"},
|
||||
{name: "skills", mutate: func(value *prefixFingerprint) { value.SystemPromptHash, value.SkillsHash = "changed", "changed" }, want: "skills"},
|
||||
{name: "unclassified system prompt", mutate: func(value *prefixFingerprint) { value.SystemPromptHash = "changed" }, want: "system_prompt"},
|
||||
{name: "tools and schema", mutate: func(value *prefixFingerprint) { value.ToolsHash, value.SchemaHash = "changed", "changed" }, want: "tools,schema"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := baseline
|
||||
test.mutate(¤t)
|
||||
current.CompletePrefixHash = "changed"
|
||||
if got := explainPrefixChange(&baseline, current); got != test.want {
|
||||
t.Fatalf("reason = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+67
-88
@@ -183,22 +183,10 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
defer runPermissions.cleanup()
|
||||
|
||||
promptParts := buildSystemPromptParts(options)
|
||||
planner := newContextPlanner(contextPlannerConfig{
|
||||
contextWindow: options.ContextWindow,
|
||||
promptCacheKey: options.SessionID,
|
||||
promptParts: promptParts,
|
||||
})
|
||||
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
|
||||
|
||||
guards := newGuardState()
|
||||
task := newTaskState(prompt, options.Trace)
|
||||
onPermission := options.OnPermission
|
||||
options.OnPermission = func(event PermissionEvent) {
|
||||
task.observe(taskStateEvent{kind: taskStateEventPermission, permission: event})
|
||||
if onPermission != nil {
|
||||
onPermission(event)
|
||||
}
|
||||
}
|
||||
compactor := newCompactionState(options, task)
|
||||
defer func() {
|
||||
// A final transcript comparison is observational. It records drift but
|
||||
@@ -287,15 +275,42 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
toolPartitionSpan.End()
|
||||
|
||||
// Fingerprint the exact system prompt built at run start plus this turn's
|
||||
// provider-visible tool definitions in their emitted order. Reusing the
|
||||
// retained prompt parts avoids rebuilding workspace context while ensuring
|
||||
// the trace describes the same bytes carried by the request.
|
||||
if options.Trace != nil {
|
||||
fp := computePrefixFingerprint(buildPromptSubstringsFromParts(promptParts, exposed))
|
||||
options.Trace.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fp.SystemPromptHash,
|
||||
BaseInstructionsHash: fp.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fp.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fp.ProjectContextHash,
|
||||
SkillsHash: fp.SkillsHash,
|
||||
ToolsHash: fp.ToolsHash,
|
||||
SchemaHash: fp.SchemaHash,
|
||||
CompletePrefixHash: fp.CompletePrefixHash,
|
||||
})
|
||||
}
|
||||
|
||||
// PROACTIVE compaction: if the history is approaching the model's
|
||||
// context window, summarize the oldest middle before building the
|
||||
// request. A no-op when ContextWindow == 0 (compaction disabled).
|
||||
compactionSpan := options.Trace.Span(trace.SpanCompaction)
|
||||
messages = compactor.maybeCompact(ctx, provider, messages, exposed)
|
||||
compactionSpan.End()
|
||||
plan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request := plan.Request
|
||||
recordContextPlan(options, plan)
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
|
||||
// Report the per-category context budget for this turn so a surface can
|
||||
// show utilization. Opt-in: a no-op when OnContext is unset.
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(MeasureContext(messages, request.Tools, options.ContextWindow))
|
||||
}
|
||||
|
||||
// A transient upstream disconnect on the initial connect is retried with
|
||||
// backoff (before any content is forwarded, so no OnText is duplicated);
|
||||
@@ -320,9 +335,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// on registry+loaded, not on the messages, so they stay valid after
|
||||
// compaction. Using the bare toolDefinitions here would route through an
|
||||
// empty-loaded partition, re-hiding every already-loaded deferred tool.
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
request = retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
request = zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
// Pre-content connect after a context-limit compaction: route through the
|
||||
// reconnect helper so a transient upstream hiccup here doesn't fail the
|
||||
// whole run and re-burn every token (AUDIT-L1).
|
||||
@@ -406,9 +424,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// Reuse the SAME active-mode partition (exposed) from this turn rather
|
||||
// than the bare toolDefinitions: exposed depends on registry+loaded (not
|
||||
// the messages), so it stays valid after compaction.
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryStreamErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryStreamErr != nil {
|
||||
return collected, retryStreamErr
|
||||
@@ -466,9 +487,12 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, err
|
||||
}
|
||||
retryPlan := planner.Plan(messages, exposed, options.ReasoningEffort)
|
||||
retryRequest := retryPlan.Request
|
||||
recordContextPlan(options, retryPlan)
|
||||
retryRequest := zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(messages),
|
||||
Tools: exposed,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}
|
||||
retryStream, retryErr := streamWithReconnect(ctx, provider, retryRequest, reconnectNoticeFor(options))
|
||||
if retryErr != nil {
|
||||
result.Messages = copyMessages(messages)
|
||||
@@ -674,7 +698,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
}
|
||||
@@ -688,11 +712,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
turnRequestedModel = toolResult.RequestedModel
|
||||
}
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.ModelOutput(),
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
IsError: toolResult.Status == tools.StatusError,
|
||||
ChangedFiles: append([]string(nil), toolResult.ChangedFiles...),
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: toolResult.Output,
|
||||
ToolCallID: toolResult.ToolCallID,
|
||||
})
|
||||
// Images ride a following USER message rather than the tool result
|
||||
// above. Every provider drops images on a tool-role message —
|
||||
@@ -721,7 +743,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
if stopReason := stopReasonFromToolResult(toolResult); stopReason != "" {
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, toolImageMessages...)
|
||||
result.FinalAnswer = toolResult.ModelOutput()
|
||||
result.FinalAnswer = toolResult.Output
|
||||
result.StopReason = stopReason
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -734,7 +756,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// aren't fixed by reformatting the call, so a "match this schema" hint
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.ModelOutput())
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
@@ -750,7 +772,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, nil
|
||||
}
|
||||
if outcome.InjectHint && failureHint == "" {
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.ModelOutput())
|
||||
failureHint = toolFailureHint(call.Name, toolSchemaJSON(registry, call.Name), toolResult.Output)
|
||||
}
|
||||
|
||||
// Collect the files this successful mutating tool changed. Self-correct
|
||||
@@ -898,7 +920,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -922,21 +944,6 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
if recorder == nil || result.Meta["output_budget_category"] == "" {
|
||||
return
|
||||
}
|
||||
if result.Outcome.Finalized() {
|
||||
diagnostics := result.Outcome.Diagnostics
|
||||
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
|
||||
Tool: result.Name,
|
||||
Category: diagnostics.Category,
|
||||
OriginalBytes: diagnostics.OriginalBytes,
|
||||
RetainedBytes: diagnostics.ModelBytes,
|
||||
EstimatedOriginalTokens: diagnostics.EstimatedOriginalTokens,
|
||||
EstimatedRetainedTokens: diagnostics.EstimatedModelTokens,
|
||||
Truncated: diagnostics.Truncated,
|
||||
Reason: diagnostics.Reason,
|
||||
SpillCreated: result.Outcome.Artifact != nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
parseInt := func(key string) int {
|
||||
value, _ := strconv.Atoi(result.Meta[key])
|
||||
return value
|
||||
@@ -955,32 +962,7 @@ func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
|
||||
})
|
||||
}
|
||||
|
||||
func recordContextPlan(options Options, plan contextPlan) {
|
||||
recordContextPlanTrace(options.Trace, plan)
|
||||
if options.OnContext != nil {
|
||||
options.OnContext(plan.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
fingerprint := plan.PrefixFingerprint
|
||||
recorder.EmitPrefixHash(trace.PrefixHash{
|
||||
SystemPromptHash: fingerprint.SystemPromptHash,
|
||||
BaseInstructionsHash: fingerprint.BaseInstructionsHash,
|
||||
ConfirmationPolicyHash: fingerprint.ConfirmationPolicyHash,
|
||||
ProjectContextHash: fingerprint.ProjectContextHash,
|
||||
SkillsHash: fingerprint.SkillsHash,
|
||||
ToolsHash: fingerprint.ToolsHash,
|
||||
SchemaHash: fingerprint.SchemaHash,
|
||||
CompletePrefixHash: fingerprint.CompletePrefixHash,
|
||||
InvalidationReason: plan.Breakdown.PrefixInvalidationReason,
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -989,9 +971,12 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *c
|
||||
// The max-turns final-answer call is a pre-content connect, often after a long
|
||||
// autonomous/cron run — route it through the reconnect helper so a single
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
plan := planner.Plan(finalMessages, toolDefs, options.ReasoningEffort)
|
||||
recordContextPlan(options, plan)
|
||||
stream, err := streamWithReconnect(ctx, provider, plan.Request, reconnectNoticeFor(options))
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
if err != nil {
|
||||
return "", messages, ""
|
||||
}
|
||||
@@ -1472,15 +1457,14 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.ModelOutput(),
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Images: result.Images,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
Display: result.Display,
|
||||
LoadedTools: loadedToolsFromResult(result.Meta),
|
||||
// A tool may signal a mid-run model escalation by carrying the target id
|
||||
// in Meta["escalate_to_model"]. Lift it into the typed loop-level field;
|
||||
@@ -2077,14 +2061,13 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.ModelOutput(),
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
ChangeSummaries: result.ChangeSummaries,
|
||||
Display: result.HumanDisplay(),
|
||||
Outcome: result.Outcome,
|
||||
Display: result.Display,
|
||||
}
|
||||
}
|
||||
return ToolResult{
|
||||
@@ -3295,7 +3278,6 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
|
||||
Role: zeroruntime.MessageRoleTool,
|
||||
Content: abortedToolResultNotice,
|
||||
ToolCallID: call.ID,
|
||||
IsError: true,
|
||||
})
|
||||
}
|
||||
return messages
|
||||
@@ -3326,9 +3308,6 @@ func copyMessages(messages []Message) []Message {
|
||||
if message.Reasoning != nil {
|
||||
copied[index].Reasoning = append([]zeroruntime.ReasoningBlock{}, message.Reasoning...)
|
||||
}
|
||||
if message.ChangedFiles != nil {
|
||||
copied[index].ChangedFiles = append([]string(nil), message.ChangedFiles...)
|
||||
}
|
||||
// Deep-copy image attachments (slice AND each Data byte slice) so the
|
||||
// raw image bytes are never aliased across history/request/result copies.
|
||||
copied[index].Images = zeroruntime.CloneImageBlocks(message.Images)
|
||||
|
||||
@@ -3592,11 +3592,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
if !strings.Contains(strings.ToLower(placeholder), "aborted") {
|
||||
t.Fatalf("expected the placeholder result to mark the call as aborted, got %q", placeholder)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "flaky-2" && !message.IsError {
|
||||
t.Fatalf("aborted placeholder must carry error status: %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Every tool_use in the final assistant message must have a matching result.
|
||||
for _, message := range result.Messages {
|
||||
@@ -3611,33 +3606,6 @@ func TestRunAppendsAbortedPlaceholderForUnexecutedToolCallsOnGuardStop(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCarriesToolErrorStatusIntoMessageHistory(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "failed-call", ToolName: "flaky"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "failed-call"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, message := range result.Messages {
|
||||
if message.ToolCallID == "failed-call" {
|
||||
if !message.IsError {
|
||||
t.Fatalf("failed tool result lost its structured status: %#v", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("failed tool result missing from message history: %#v", result.Messages)
|
||||
}
|
||||
|
||||
type secretEmittingTool struct{ output string }
|
||||
|
||||
func (t secretEmittingTool) Name() string { return "leak" }
|
||||
@@ -3729,8 +3697,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
onContextCalls := 0
|
||||
var contextPlan ContextBreakdown
|
||||
rec := trace.NewRecorder("tracing-session", "run-1", "test")
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "tracing-session",
|
||||
@@ -3739,10 +3705,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
Model: "test-model",
|
||||
Trace: rec,
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
OnContext: func(breakdown ContextBreakdown) {
|
||||
onContextCalls++
|
||||
contextPlan = breakdown
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
@@ -3769,12 +3731,6 @@ func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
|
||||
}
|
||||
if onContextCalls != 1 || len(contextPlan.Blocks) != 2 || contextPlan.PrefixInvalidationReason != "initial" {
|
||||
t.Fatalf("context plan callback = calls %d, plan %#v", onContextCalls, contextPlan)
|
||||
}
|
||||
if len(tr.PrefixHashes) != 1 || tr.PrefixHashes[0].InvalidationReason != "initial" || tr.PrefixHashes[0].CompletePrefixHash != contextPlan.CompletePrefixHash {
|
||||
t.Fatalf("trace context evidence = %#v, plan %#v", tr.PrefixHashes, contextPlan)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
|
||||
|
||||
+10
-217
@@ -1,14 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
@@ -56,41 +52,17 @@ type taskVerificationState struct {
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskFailureState struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type taskApprovalState struct {
|
||||
Tool string `json:"tool"`
|
||||
Decision PermissionDecisionAction `json:"decision,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type taskArtifactState struct {
|
||||
Tool string `json:"tool"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
UnresolvedFailures []taskFailureState `json:"unresolved_failures,omitempty"`
|
||||
Approvals []taskApprovalState `json:"approvals,omitempty"`
|
||||
Artifacts []taskArtifactState `json:"artifacts,omitempty"`
|
||||
ResolvedFailureKeys []string `json:"-"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
@@ -100,7 +72,6 @@ const (
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
taskStateEventPermission
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
@@ -109,7 +80,6 @@ type taskStateEvent struct {
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
permission PermissionEvent
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
@@ -131,7 +101,6 @@ func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.snapshotValue.Constraints = extractExplicitConstraints(objective)
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
@@ -165,7 +134,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
state.observeToolEvidence(event.arguments, event.toolResult)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
@@ -192,10 +160,6 @@ func (state *taskState) observe(event taskStateEvent) bool {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventPermission:
|
||||
state.markActive()
|
||||
state.observePermission(event.permission)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
@@ -219,180 +183,9 @@ func (state *taskState) snapshot() taskStateSnapshot {
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
snapshot.Constraints = append([]string(nil), state.snapshotValue.Constraints...)
|
||||
snapshot.UnresolvedFailures = append([]taskFailureState(nil), state.snapshotValue.UnresolvedFailures...)
|
||||
snapshot.Approvals = append([]taskApprovalState(nil), state.snapshotValue.Approvals...)
|
||||
snapshot.Artifacts = append([]taskArtifactState(nil), state.snapshotValue.Artifacts...)
|
||||
snapshot.ResolvedFailureKeys = append([]string(nil), state.snapshotValue.ResolvedFailureKeys...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const (
|
||||
maxTaskEvidenceEntries = 8
|
||||
maxTaskEvidenceBytes = 320
|
||||
maxTaskConstraints = 10
|
||||
)
|
||||
|
||||
func (state *taskState) observeToolEvidence(arguments string, result ToolResult) {
|
||||
command := capTaskEvidence(commandFromArguments(arguments))
|
||||
key := taskFailureKey(result.Name, command, arguments)
|
||||
if result.Status == tools.StatusOK {
|
||||
if hasTaskFailure(state.snapshotValue.UnresolvedFailures, key) {
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.ResolvedFailureKeys = appendBoundedUnique(state.snapshotValue.ResolvedFailureKeys, key, maxTaskEvidenceEntries)
|
||||
}
|
||||
} else {
|
||||
state.snapshotValue.ResolvedFailureKeys = removeString(state.snapshotValue.ResolvedFailureKeys, key)
|
||||
state.snapshotValue.UnresolvedFailures = removeTaskFailure(state.snapshotValue.UnresolvedFailures, key)
|
||||
state.snapshotValue.UnresolvedFailures = appendBounded(state.snapshotValue.UnresolvedFailures, taskFailureState{
|
||||
Key: key, Tool: result.Name, Command: command, Summary: capTaskEvidence(firstLine(result.Output)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
for _, metaKey := range []string{"spill_path", "artifact_path"} {
|
||||
if path := strings.TrimSpace(result.Meta[metaKey]); path != "" {
|
||||
state.snapshotValue.Artifacts = appendBoundedUnique(state.snapshotValue.Artifacts,
|
||||
taskArtifactState{Tool: result.Name, Path: capTaskEvidence(path)}, maxTaskEvidenceEntries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func taskFailureKey(toolName, command, arguments string) string {
|
||||
identity := command
|
||||
if identity == "" {
|
||||
canonical := []byte(strings.TrimSpace(arguments))
|
||||
var value any
|
||||
if json.Unmarshal(canonical, &value) == nil {
|
||||
if encoded, err := json.Marshal(value); err == nil {
|
||||
canonical = encoded
|
||||
}
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
identity = fmt.Sprintf("args:%x", digest[:8])
|
||||
}
|
||||
return strings.TrimSpace(toolName) + "\x00" + identity
|
||||
}
|
||||
|
||||
func (state *taskState) observePermission(event PermissionEvent) {
|
||||
decision := event.DecisionAction
|
||||
if decision == "" {
|
||||
decision = PermissionDecisionAction(event.Action)
|
||||
}
|
||||
state.snapshotValue.Approvals = appendBoundedUnique(state.snapshotValue.Approvals, taskApprovalState{
|
||||
Tool: event.ToolName, Decision: decision, Scope: capTaskEvidence(event.Scope),
|
||||
Reason: capTaskEvidence(firstNonEmptyString(event.DecisionReason, event.Reason)),
|
||||
}, maxTaskEvidenceEntries)
|
||||
}
|
||||
|
||||
func commandFromArguments(arguments string) string {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"cmd", "command", "script", "shell"} {
|
||||
var command string
|
||||
if raw, ok := object[key]; ok && json.Unmarshal(raw, &command) == nil {
|
||||
return strings.TrimSpace(command)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstLine(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if index := strings.IndexByte(value, '\n'); index >= 0 {
|
||||
value = value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func capTaskEvidence(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= maxTaskEvidenceBytes {
|
||||
return value
|
||||
}
|
||||
limit := maxTaskEvidenceBytes - 3
|
||||
for limit > 0 && !utf8.RuneStart(value[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(value[:limit]) + "..."
|
||||
}
|
||||
|
||||
var explicitConstraintPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)\bprefer(?:s|red|ring)?\s+\w`),
|
||||
regexp.MustCompile(`(?i)\bdon'?t want\b`),
|
||||
regexp.MustCompile(`(?i)\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b`),
|
||||
regexp.MustCompile(`(?i)\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b`),
|
||||
regexp.MustCompile(`(?i)\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b`),
|
||||
regexp.MustCompile(`(?i)\b(?:style|format|language|naming)\s*[:=]\s*\S`),
|
||||
}
|
||||
|
||||
func extractExplicitConstraints(text string) []string {
|
||||
var constraints []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 5 || len(line) > 200 || strings.HasSuffix(line, "?") || strings.Contains(line, "?...") {
|
||||
continue
|
||||
}
|
||||
for _, pattern := range explicitConstraintPatterns {
|
||||
if pattern.MatchString(line) {
|
||||
constraints = appendBoundedUnique(constraints, line, maxTaskConstraints)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(constraints) == maxTaskConstraints {
|
||||
break
|
||||
}
|
||||
}
|
||||
return constraints
|
||||
}
|
||||
|
||||
func appendBounded[T any](values []T, value T, limit int) []T {
|
||||
values = append(values, value)
|
||||
if len(values) > limit {
|
||||
values = values[len(values)-limit:]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendBoundedUnique[T comparable](values []T, value T, limit int) []T {
|
||||
filtered := values[:0]
|
||||
for _, existing := range values {
|
||||
if existing != value {
|
||||
filtered = append(filtered, existing)
|
||||
}
|
||||
}
|
||||
return appendBounded(filtered, value, limit)
|
||||
}
|
||||
|
||||
func hasTaskFailure(values []taskFailureState, key string) bool {
|
||||
for _, value := range values {
|
||||
if value.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeTaskFailure(values []taskFailureState, key string) []taskFailureState {
|
||||
out := values[:0]
|
||||
for _, value := range values {
|
||||
if value.Key != key {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removeString(values []string, target string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value != target {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
|
||||
@@ -115,68 +115,6 @@ func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateRecordsDurableRuntimeEvidence(t *testing.T) {
|
||||
state := newTaskState("Please keep the change focused.\nNever commit generated reports.", nil)
|
||||
arguments := `{"cmd":"go test ./internal/agent"}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-1", Name: "exec_command", Status: tools.StatusError,
|
||||
Output: "Error: TestResumeRetainsState failed\nfull diagnostic",
|
||||
Meta: map[string]string{"spill_path": ".zero/artifacts/test-1.txt"},
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPermission, permission: PermissionEvent{
|
||||
ToolName: "exec_command", DecisionAction: PermissionDecisionAllowForSession,
|
||||
Scope: "/tmp", DecisionReason: "write a temporary fixture",
|
||||
}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if !reflect.DeepEqual(snapshot.Constraints, []string{"Please keep the change focused.", "Never commit generated reports."}) {
|
||||
t.Fatalf("unexpected explicit constraints: %#v", snapshot.Constraints)
|
||||
}
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Command != "go test ./internal/agent" || snapshot.UnresolvedFailures[0].Summary != "Error: TestResumeRetainsState failed" {
|
||||
t.Fatalf("unexpected failure evidence: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
if len(snapshot.Artifacts) != 1 || snapshot.Artifacts[0].Path != ".zero/artifacts/test-1.txt" {
|
||||
t.Fatalf("unexpected artifact evidence: %#v", snapshot.Artifacts)
|
||||
}
|
||||
if len(snapshot.Approvals) != 1 || snapshot.Approvals[0].Decision != PermissionDecisionAllowForSession || snapshot.Approvals[0].Scope != "/tmp" {
|
||||
t.Fatalf("unexpected approval evidence: %#v", snapshot.Approvals)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go is unavailable",
|
||||
}})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"b.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-b", Name: "read_file", Status: tools.StatusOK, Output: "package b",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 2 || failures[1].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("success for a different target resolved the wrong failure: %#v", failures)
|
||||
}
|
||||
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: arguments, toolResult: ToolResult{
|
||||
ToolCallID: "test-2", Name: "exec_command", Status: tools.StatusOK, Output: "ok",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 1 || failures[0].Summary != "Error: a.go is unavailable" {
|
||||
t.Fatalf("successful retry did not resolve only its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-2", Name: "read_file", Status: tools.StatusOK, Output: "package a",
|
||||
}})
|
||||
if failures := state.snapshot().UnresolvedFailures; len(failures) != 0 {
|
||||
t.Fatalf("successful non-command retry did not resolve its matching failure: %#v", failures)
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: `{"path":"a.go"}`, toolResult: ToolResult{
|
||||
ToolCallID: "read-a-3", Name: "read_file", Status: tools.StatusError, Output: "Error: a.go failed again",
|
||||
}})
|
||||
snapshot = state.snapshot()
|
||||
if len(snapshot.UnresolvedFailures) != 1 || snapshot.UnresolvedFailures[0].Summary != "Error: a.go failed again" {
|
||||
t.Fatalf("a failure recurring after success was hidden: %#v", snapshot.UnresolvedFailures)
|
||||
}
|
||||
for _, key := range snapshot.ResolvedFailureKeys {
|
||||
if key == snapshot.UnresolvedFailures[0].Key {
|
||||
t.Fatalf("recurring failure retained a stale resolved key: %#v", snapshot.ResolvedFailureKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
|
||||
+3
-24
@@ -79,7 +79,6 @@ type ToolResult struct {
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
Outcome tools.ToolOutcome
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
@@ -100,24 +99,6 @@ type ToolResult struct {
|
||||
RequestedModel string
|
||||
}
|
||||
|
||||
// ModelOutput returns the bounded provider-facing result while preserving
|
||||
// compatibility with synthetic and restored results created before outcomes
|
||||
// were finalized.
|
||||
func (result ToolResult) ModelOutput() string {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.ModelView
|
||||
}
|
||||
return result.Output
|
||||
}
|
||||
|
||||
// HumanDisplay returns the presentation intended for interactive surfaces.
|
||||
func (result ToolResult) HumanDisplay() tools.Display {
|
||||
if result.Outcome.Finalized() {
|
||||
return result.Outcome.HumanView
|
||||
}
|
||||
return result.Display
|
||||
}
|
||||
|
||||
// DenialCategory classifies why a tool call was blocked before it executed.
|
||||
type DenialCategory string
|
||||
|
||||
@@ -352,11 +333,9 @@ type Options struct {
|
||||
// specialist child process emits while running. The toolCallID identifies
|
||||
// which Task tool call the progress belongs to. nil is a no-op.
|
||||
OnToolProgress func(toolCallID string, event streamjson.Event)
|
||||
// OnContext, when set, is called for each main agent request with its
|
||||
// per-category context budget, including a replacement request after
|
||||
// compaction or a stall retry. Internal summarizer requests are excluded so
|
||||
// surfaces keep showing the active conversation budget. Opt-in like the other
|
||||
// callbacks; nil is a no-op.
|
||||
// OnContext, when set, is called once per turn with the per-category context
|
||||
// budget of the request about to be sent, so a surface (TUI/CLI) can show
|
||||
// context utilization. Opt-in like the other callbacks; nil is a no-op.
|
||||
OnContext func(ContextBreakdown)
|
||||
// ModelSwitcher, when set, lets a tool escalate the run to a stronger model
|
||||
// mid-run: the loop calls it with the requested model id and, on success,
|
||||
|
||||
@@ -142,15 +142,13 @@ func (writer *execEventWriter) checkpoint(event sessions.Event) {
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
modelOutput := result.ModelOutput()
|
||||
display := result.HumanDisplay()
|
||||
if writer.format == execOutputJSON {
|
||||
payload := map[string]any{
|
||||
"type": "tool_result",
|
||||
"tool_call_id": result.ToolCallID,
|
||||
"name": result.Name,
|
||||
"status": string(result.Status),
|
||||
"output": modelOutput,
|
||||
"output": result.Output,
|
||||
}
|
||||
if len(result.Meta) > 0 {
|
||||
payload["meta"] = result.Meta
|
||||
@@ -164,14 +162,14 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
if len(result.ChangedFiles) > 0 {
|
||||
payload["changed_files"] = result.ChangedFiles
|
||||
}
|
||||
if display.Summary != "" || display.Kind != "" {
|
||||
payload["display"] = map[string]string{"summary": display.Summary, "kind": display.Kind}
|
||||
if result.Display.Summary != "" || result.Display.Kind != "" {
|
||||
payload["display"] = map[string]string{"summary": result.Display.Summary, "kind": result.Display.Kind}
|
||||
}
|
||||
writer.writeJSON(payload)
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
output, surfaceTruncated := truncateForStreamJSONOutput(modelOutput)
|
||||
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
|
||||
truncated := result.Truncated || surfaceTruncated
|
||||
event := streamjson.Event{
|
||||
Type: streamjson.EventToolResult,
|
||||
@@ -188,13 +186,13 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
redacted := true
|
||||
event.Redacted = &redacted
|
||||
}
|
||||
if display.Summary != "" || display.Kind != "" {
|
||||
event.Display = &streamjson.Display{Summary: display.Summary, Kind: display.Kind}
|
||||
if result.Display.Summary != "" || result.Display.Kind != "" {
|
||||
event.Display = &streamjson.Display{Summary: result.Display.Summary, Kind: result.Display.Kind}
|
||||
}
|
||||
writer.writeStreamJSON(event)
|
||||
return
|
||||
}
|
||||
writer.writeStderr("[result] " + truncateForStatus(modelOutput) + "\n")
|
||||
writer.writeStderr("[result] " + truncateForStatus(result.Output) + "\n")
|
||||
}
|
||||
|
||||
func (writer *execEventWriter) permission(event agent.PermissionEvent) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type EventRef struct {
|
||||
@@ -38,7 +37,6 @@ type RewindPlan struct {
|
||||
type CompactionOptions struct {
|
||||
PreserveLast int
|
||||
MaxPromptChars int
|
||||
SkipPrompt bool
|
||||
}
|
||||
|
||||
type CompactionPlan struct {
|
||||
@@ -49,9 +47,8 @@ type CompactionPlan struct {
|
||||
CompactableEvents []EventRef `json:"compactableEvents"`
|
||||
PreservedEvents []EventRef `json:"preservedEvents"`
|
||||
SummaryPrompt string `json:"summaryPrompt"`
|
||||
// PromptChars counts prompt text content and excludes provider protocol framing.
|
||||
PromptChars int `json:"promptChars"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
PromptChars int `json:"promptChars"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
type RecordCompactionInput struct {
|
||||
@@ -167,11 +164,7 @@ func (store *Store) PlanCompaction(sessionID string, options CompactionOptions)
|
||||
}
|
||||
compactable := events[:split]
|
||||
preserved := events[split:]
|
||||
prompt := ""
|
||||
truncated := false
|
||||
if !options.SkipPrompt {
|
||||
prompt, truncated = buildCompactionPrompt(compactable, maxPromptChars)
|
||||
}
|
||||
prompt, truncated := buildCompactionPrompt(compactable, maxPromptChars)
|
||||
return CompactionPlan{
|
||||
SessionID: sessionID,
|
||||
PreserveLast: preserveLast,
|
||||
@@ -319,88 +312,6 @@ func buildCompactionPrompt(events []Event, maxChars int) (string, bool) {
|
||||
return prompt, false
|
||||
}
|
||||
|
||||
// CompactionMessages converts durable session events into the same normalized
|
||||
// message shape used by the running agent's compaction pipeline. Provider usage
|
||||
// and checkpoint blobs are omitted. Message/tool fields rely on the session
|
||||
// writer's invariant that tool output is scrubbed before persistence;
|
||||
// ask_user answers and generic event previews are redacted here.
|
||||
func CompactionMessages(events []Event) []zeroruntime.Message {
|
||||
messages := make([]zeroruntime.Message, 0, len(events))
|
||||
for _, event := range events {
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(event.Payload, &payload)
|
||||
stringField := func(key string) string {
|
||||
value, _ := payload[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
switch event.Type {
|
||||
case EventMessage:
|
||||
role := strings.ToLower(stringField("role"))
|
||||
content := stringField("content")
|
||||
switch role {
|
||||
case "user":
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: content})
|
||||
case "assistant":
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: content})
|
||||
case "ask_user_answers":
|
||||
if answers, ok := payload["answers"]; ok {
|
||||
encoded, _ := json.Marshal(answers)
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "User answers: " + redaction.RedactString(string(encoded), redaction.Options{})})
|
||||
}
|
||||
default:
|
||||
if content != "" {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: content})
|
||||
}
|
||||
}
|
||||
case EventToolCall:
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: firstSessionString(stringField("toolCallId"), stringField("id")), Name: firstSessionString(stringField("name"), stringField("toolName")), Arguments: stringField("arguments"),
|
||||
}}})
|
||||
case EventToolResult:
|
||||
status := strings.ToLower(stringField("status"))
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleTool, ToolCallID: firstSessionString(stringField("toolCallId"), stringField("id")),
|
||||
Content: stringField("output"), IsError: status != "" && status != "ok", ChangedFiles: sessionStringSlice(payload["changedFiles"]),
|
||||
})
|
||||
case EventCompaction:
|
||||
if summary := stringField("summary"); summary != "" {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "[Summary of earlier conversation]\n" + summary})
|
||||
}
|
||||
case EventProviderUsage, EventSessionCheckpoint:
|
||||
continue
|
||||
default:
|
||||
preview := shapedPayloadPreview(event)
|
||||
if strings.TrimSpace(preview) != "" && preview != "{}" {
|
||||
messages = append(messages, zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("[%s] %s", event.Type, preview)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func firstSessionString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sessionStringSlice(value any) []string {
|
||||
raw, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
|
||||
values = append(values, strings.TrimSpace(text))
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func shapedPayloadPreview(event Event) string {
|
||||
switch event.Type {
|
||||
case EventPermission, EventPermissionRequest, EventPermissionDecision:
|
||||
|
||||
@@ -5,27 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestCompactionMessagesPreservesInteractiveAndMutationEvidence(t *testing.T) {
|
||||
events := []Event{
|
||||
{Type: EventToolCall, Payload: json.RawMessage(`{"id":"ask","name":"ask_user","arguments":"{\"questions\":[{\"question\":\"Which database?\"}]}"}`)},
|
||||
{Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"ask","name":"ask_user","status":"ok","output":"Postgres only"}`)},
|
||||
{Type: EventToolCall, Payload: json.RawMessage(`{"id":"patch","name":"apply_patch","arguments":"{\"patch\":\"*** Update File: db.go\"}"}`)},
|
||||
{Type: EventToolResult, Payload: json.RawMessage(`{"toolCallId":"patch","name":"apply_patch","status":"ok","output":"Done!","changedFiles":["db.go"]}`)},
|
||||
}
|
||||
|
||||
messages := CompactionMessages(events)
|
||||
if len(messages) != 4 || messages[0].Role != zeroruntime.MessageRoleAssistant || messages[1].Role != zeroruntime.MessageRoleTool {
|
||||
t.Fatalf("unexpected normalized compaction messages: %#v", messages)
|
||||
}
|
||||
if messages[1].Content != "Postgres only" || len(messages[3].ChangedFiles) != 1 || messages[3].ChangedFiles[0] != "db.go" {
|
||||
t.Fatalf("interactive or mutation evidence was lost: %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePlansRewindBySequence(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: sequenceClock([]time.Time{
|
||||
time.Date(2026, 6, 6, 10, 0, 0, 0, time.UTC),
|
||||
@@ -149,26 +130,6 @@ func TestStorePlansCompactionWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCanPlanCompactionWithoutBuildingLegacyPrompt(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
session, err := store.Create(CreateInput{SessionID: "compactnoprompt"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, content := range []string{"one", "two", "three"} {
|
||||
if _, err := store.AppendEvent(session.SessionID, AppendEventInput{Type: EventMessage, Payload: map[string]string{"content": content}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
plan, err := store.PlanCompaction(session.SessionID, CompactionOptions{PreserveLast: 1, SkipPrompt: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.CompactableCount != 2 || plan.SummaryPrompt != "" || plan.PromptChars != 0 || plan.Truncated {
|
||||
t.Fatalf("unexpected prompt-free plan: %#v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCompactionShapesSensitivePermissionEvents(t *testing.T) {
|
||||
secret := "sk-proj-abcdefghijklmnopqrstuvwxyz"
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir()})
|
||||
|
||||
@@ -99,18 +99,15 @@ func selfManagedOutputBudget(toolName string, args map[string]any) outputBudget
|
||||
// newly combined result so hooks cannot bypass the established safety ceiling.
|
||||
func (registry *Registry) RebudgetAfterHook(toolName string, args map[string]any, result Result) Result {
|
||||
result = scrubResultSecrets(result)
|
||||
boundaryOutput := result.Output
|
||||
tool, _ := registry.Get(toolName)
|
||||
if _, ok := tool.(selfBudgeting); ok {
|
||||
// Match the primary registry boundary: self-managed tools keep their
|
||||
// call-specific capture/output budget instead of being tightened or
|
||||
// loosened to the generic registry ceiling after hook feedback.
|
||||
result = applySelfManagedOutputBudget(tool, toolName, args, result)
|
||||
return finalizeToolOutcome(result, boundaryOutput)
|
||||
return applySelfManagedOutputBudget(tool, toolName, args, result)
|
||||
}
|
||||
result = applyRegistryOutputBudget(tool, toolName, args, result)
|
||||
result = enforceOutputCeiling(toolName, result)
|
||||
return finalizeToolOutcome(result, boundaryOutput)
|
||||
return enforceOutputCeiling(toolName, result)
|
||||
}
|
||||
|
||||
func registryOutputBudget(toolName string) outputBudget {
|
||||
|
||||
@@ -143,7 +143,6 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
|
||||
var ok bool
|
||||
defer func() {
|
||||
result = scrubResultSecrets(result)
|
||||
boundaryOutput := result.Output
|
||||
result = reduceCommandOutput(name, args, result)
|
||||
if selfManagedOutput {
|
||||
result = applySelfManagedOutputBudget(tool, name, args, result)
|
||||
@@ -151,7 +150,6 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
|
||||
result = applyRegistryOutputBudget(tool, name, args, result)
|
||||
result = enforceOutputCeiling(name, result)
|
||||
}
|
||||
result = finalizeToolOutcome(result, boundaryOutput)
|
||||
if commitFileObservation {
|
||||
result = registry.CommitFileObservation(result, options.FileTracker)
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const humanOutcomePreviewBytes = 8 * 1024
|
||||
|
||||
type serializedToolOutcome struct {
|
||||
ModelView string `json:"modelView,omitempty"`
|
||||
HumanView Display `json:"humanView,omitempty"`
|
||||
Artifact *ToolArtifact `json:"artifact,omitempty"`
|
||||
Diagnostics OutcomeDiagnostics `json:"diagnostics,omitempty"`
|
||||
Finalized bool `json:"finalized,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON preserves the private finalized marker without making outcome
|
||||
// state mutable to callers.
|
||||
func (outcome ToolOutcome) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(serializedToolOutcome{
|
||||
ModelView: outcome.ModelView,
|
||||
HumanView: outcome.HumanView,
|
||||
Artifact: outcome.Artifact,
|
||||
Diagnostics: outcome.Diagnostics,
|
||||
Finalized: outcome.finalized,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalJSON restores the finalized marker used to select canonical views.
|
||||
func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error {
|
||||
var serialized serializedToolOutcome
|
||||
if err := json.Unmarshal(data, &serialized); err != nil {
|
||||
return err
|
||||
}
|
||||
outcome.ModelView = serialized.ModelView
|
||||
outcome.HumanView = serialized.HumanView
|
||||
outcome.Artifact = serialized.Artifact
|
||||
outcome.Diagnostics = serialized.Diagnostics
|
||||
outcome.finalized = serialized.Finalized
|
||||
return nil
|
||||
}
|
||||
|
||||
// finalizeToolOutcome is the single seam between tool execution and its three
|
||||
// consumers: provider context, human presentation, and recoverable artifacts.
|
||||
// boundaryOutput must already be redacted. It is the text seen immediately
|
||||
// before command reduction and semantic budgeting.
|
||||
func finalizeToolOutcome(result Result, boundaryOutput string) Result {
|
||||
previous := result.Outcome
|
||||
human := result.Display
|
||||
if human.Preview == "" && result.Meta["command_output_reduced"] == "true" {
|
||||
human.Preview = boundedHumanOutcomePreview(boundaryOutput, result.Meta["spill_path"])
|
||||
}
|
||||
// A tool-provided success preview (for example, a prospective file diff)
|
||||
// must not hide the actual error. Command reduction is different: its
|
||||
// preview is the redacted execution output containing that same failure plus
|
||||
// the evidence omitted from the model view.
|
||||
if result.Status == StatusError && result.Meta["command_output_reduced"] != "true" {
|
||||
human.Preview = ""
|
||||
}
|
||||
|
||||
originalBytes := len(boundaryOutput)
|
||||
originalTokens := estimateOutputTokens(boundaryOutput)
|
||||
if previous.Finalized() {
|
||||
originalBytes = previous.Diagnostics.OriginalBytes
|
||||
originalTokens = previous.Diagnostics.EstimatedOriginalTokens
|
||||
}
|
||||
modelBytes := len(result.Output)
|
||||
modelTokens := estimateOutputTokens(result.Output)
|
||||
|
||||
var artifact *ToolArtifact
|
||||
if previous.Finalized() {
|
||||
artifact = previous.Artifact
|
||||
}
|
||||
if path := strings.TrimSpace(result.Meta["spill_path"]); path != "" {
|
||||
if artifact == nil || artifact.Path != path {
|
||||
artifact = &ToolArtifact{
|
||||
Path: path,
|
||||
CompleteAtBoundary: result.Meta["command_output_reduced"] == "true" || result.Meta[outputBudgetSpillCreatedMeta] == "true",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Display = human
|
||||
result.Outcome = ToolOutcome{
|
||||
ModelView: result.Output,
|
||||
HumanView: human,
|
||||
Artifact: artifact,
|
||||
Diagnostics: OutcomeDiagnostics{
|
||||
Category: result.Meta[outputBudgetCategoryMeta],
|
||||
OriginalBytes: originalBytes,
|
||||
ModelBytes: modelBytes,
|
||||
EstimatedOriginalTokens: originalTokens,
|
||||
EstimatedModelTokens: modelTokens,
|
||||
Truncated: result.Truncated,
|
||||
Redacted: result.Redacted,
|
||||
Reason: result.Meta["truncation_reason"],
|
||||
},
|
||||
finalized: true,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func boundedHumanOutcomePreview(output string, artifactPath string) string {
|
||||
if len(output) <= humanOutcomePreviewBytes {
|
||||
return output
|
||||
}
|
||||
marker := "\n[zero] human preview shortened"
|
||||
if strings.TrimSpace(artifactPath) != "" {
|
||||
marker += "; exact output: " + artifactPath
|
||||
}
|
||||
marker += "\n"
|
||||
contentBudget := max(0, humanOutcomePreviewBytes-len(marker))
|
||||
return utf8Prefix(output, contentBudget*3/5) + marker + utf8Suffix(output, contentBudget*2/5)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type outcomeErrorTool struct {
|
||||
ceilingFakeTool
|
||||
display Display
|
||||
}
|
||||
|
||||
func (tool outcomeErrorTool) Run(context.Context, map[string]any) Result {
|
||||
return Result{Status: StatusError, Output: tool.output, Display: tool.display}
|
||||
}
|
||||
|
||||
func TestRegistryFinalizesSeparateModelHumanAndArtifactViews(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
lines := []string{"output:"}
|
||||
for index := 0; index < 24; index++ {
|
||||
lines = append(lines, "ok \texample.test/package\t0.01s")
|
||||
}
|
||||
lines = append(lines, "exit_code: 0")
|
||||
raw := strings.Join(lines, "\n")
|
||||
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool(ExecCommandToolName, raw))
|
||||
result := registry.Run(context.Background(), ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
|
||||
if !result.Outcome.Finalized() {
|
||||
t.Fatal("registry result did not finalize a tool outcome")
|
||||
}
|
||||
if result.ModelOutput() != result.Output || result.Outcome.ModelView != result.Output {
|
||||
t.Fatalf("model representations drifted: output=%q outcome=%q", result.Output, result.Outcome.ModelView)
|
||||
}
|
||||
if strings.Count(result.ModelOutput(), "ok \t") != 0 {
|
||||
t.Fatalf("model view retained repetitive passing-package lines: %q", result.ModelOutput())
|
||||
}
|
||||
if !strings.Contains(result.HumanDisplay().Preview, "ok \texample.test/package") {
|
||||
t.Fatalf("human preview lost the raw passing-package evidence: %q", result.HumanDisplay().Preview)
|
||||
}
|
||||
if result.Outcome.Artifact == nil || !result.Outcome.Artifact.CompleteAtBoundary {
|
||||
t.Fatalf("missing complete boundary artifact: %#v", result.Outcome.Artifact)
|
||||
}
|
||||
artifact, err := os.ReadFile(result.Outcome.Artifact.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read outcome artifact: %v", err)
|
||||
}
|
||||
if string(artifact) != raw {
|
||||
t.Fatal("outcome artifact differs from the exact boundary output")
|
||||
}
|
||||
diagnostics := result.Outcome.Diagnostics
|
||||
if diagnostics.OriginalBytes != len(raw) || diagnostics.ModelBytes != len(result.Output) {
|
||||
t.Fatalf("incorrect outcome byte diagnostics: %#v", diagnostics)
|
||||
}
|
||||
if diagnostics.EstimatedModelTokens >= diagnostics.EstimatedOriginalTokens {
|
||||
t.Fatalf("expected reduced model view: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryOutcomeRedactsModelHumanAndArtifactViews(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
secret := "ghp_" + strings.Repeat("s", 36)
|
||||
raw := "output:\n" + secret + "\n" + strings.Repeat("ok \texample.test/package\t0.01s\n", 24) + "exit_code: 0"
|
||||
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool(ExecCommandToolName, raw))
|
||||
result := registry.Run(context.Background(), ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
|
||||
if !result.Outcome.Diagnostics.Redacted {
|
||||
t.Fatal("outcome did not record redaction")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"model": result.ModelOutput(),
|
||||
"human": result.HumanDisplay().Preview,
|
||||
} {
|
||||
if strings.Contains(value, secret) {
|
||||
t.Fatalf("%s view leaked secret", name)
|
||||
}
|
||||
}
|
||||
artifact, err := os.ReadFile(result.Outcome.Artifact.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("read outcome artifact: %v", err)
|
||||
}
|
||||
if strings.Contains(string(artifact), secret) {
|
||||
t.Fatal("artifact leaked secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectToolResultUsesLegacyViewFallbacks(t *testing.T) {
|
||||
result := Result{
|
||||
Output: "direct output",
|
||||
Display: Display{Summary: "direct summary"},
|
||||
}
|
||||
if result.Outcome.Finalized() {
|
||||
t.Fatal("direct result unexpectedly finalized")
|
||||
}
|
||||
if result.ModelOutput() != result.Output || result.HumanDisplay() != result.Display {
|
||||
t.Fatalf("direct result fallbacks changed: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebudgetAfterHookPreservesExecutionArtifactAndRefreshesModelView(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
raw := "output:\n" + strings.Repeat("ok \texample.test/package\t0.01s\n", 24) + "exit_code: 0"
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool(ExecCommandToolName, raw))
|
||||
result := registry.Run(context.Background(), ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
artifact := result.Outcome.Artifact
|
||||
originalBytes := result.Outcome.Diagnostics.OriginalBytes
|
||||
|
||||
result.Output += "\nafter-hook diagnostic"
|
||||
result = registry.RebudgetAfterHook(ExecCommandToolName, map[string]any{"cmd": "go test ./..."}, result)
|
||||
|
||||
if result.Outcome.Artifact != artifact {
|
||||
t.Fatalf("rebudget replaced the execution artifact: before=%#v after=%#v", artifact, result.Outcome.Artifact)
|
||||
}
|
||||
if result.Outcome.Diagnostics.OriginalBytes != originalBytes {
|
||||
t.Fatalf("rebudget lost original execution size: before=%d after=%d", originalBytes, result.Outcome.Diagnostics.OriginalBytes)
|
||||
}
|
||||
if !strings.Contains(result.ModelOutput(), "after-hook diagnostic") {
|
||||
t.Fatalf("rebudget did not refresh model view: %q", result.ModelOutput())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolOutcomeCorpusMetrics(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
totalOriginalTokens := 0
|
||||
totalModelTokens := 0
|
||||
for _, testCase := range commandReducerCorpus() {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
registry.Register(newCeilingFakeTool(ExecCommandToolName, testCase.output))
|
||||
result := registry.Run(context.Background(), ExecCommandToolName, map[string]any{"cmd": testCase.command})
|
||||
|
||||
if !result.Outcome.Finalized() || result.Outcome.Artifact == nil {
|
||||
t.Fatalf("corpus result lacks a finalized recoverable outcome: %#v", result.Outcome)
|
||||
}
|
||||
if result.HumanDisplay().Preview != testCase.output {
|
||||
t.Fatal("bounded corpus output was not preserved exactly for the human view")
|
||||
}
|
||||
diagnostics := result.Outcome.Diagnostics
|
||||
totalOriginalTokens += diagnostics.EstimatedOriginalTokens
|
||||
totalModelTokens += diagnostics.EstimatedModelTokens
|
||||
t.Logf("original_tokens=%d model_tokens=%d human_bytes=%d artifact_complete=%t",
|
||||
diagnostics.EstimatedOriginalTokens,
|
||||
diagnostics.EstimatedModelTokens,
|
||||
len(result.HumanDisplay().Preview),
|
||||
result.Outcome.Artifact.CompleteAtBoundary,
|
||||
)
|
||||
})
|
||||
}
|
||||
if totalModelTokens >= totalOriginalTokens {
|
||||
t.Fatalf("outcome corpus did not reduce model context: original=%d model=%d", totalOriginalTokens, totalModelTokens)
|
||||
}
|
||||
t.Logf("aggregate original_tokens=%d model_tokens=%d reduction_pct=%d",
|
||||
totalOriginalTokens,
|
||||
totalModelTokens,
|
||||
100*(totalOriginalTokens-totalModelTokens)/totalOriginalTokens,
|
||||
)
|
||||
}
|
||||
|
||||
func TestErrorOutcomeUsesRawCommandEvidenceButDropsUnrelatedPreview(t *testing.T) {
|
||||
setTestTempDir(t)
|
||||
raw := "output:\n" + strings.Repeat("ok \texample.test/package\t0.01s\n", 24) +
|
||||
"--- FAIL: TestImportant\nexpected 7, got 9\nFAIL\nexit_code: 1"
|
||||
|
||||
registry := NewRegistry()
|
||||
registry.Register(outcomeErrorTool{ceilingFakeTool: newCeilingFakeTool(ExecCommandToolName, raw)})
|
||||
result := registry.Run(context.Background(), ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
if !strings.Contains(result.HumanDisplay().Preview, "TestImportant") ||
|
||||
!strings.Contains(result.HumanDisplay().Preview, "ok \texample.test/package") {
|
||||
t.Fatalf("error outcome lost raw command evidence: %q", result.HumanDisplay().Preview)
|
||||
}
|
||||
|
||||
registry = NewRegistry()
|
||||
registry.Register(outcomeErrorTool{
|
||||
ceilingFakeTool: newCeilingFakeTool("write_failure", "Error: permission denied"),
|
||||
display: Display{Preview: "prospective diff must not hide error"},
|
||||
})
|
||||
result = registry.Run(context.Background(), "write_failure", map[string]any{})
|
||||
if result.HumanDisplay().Preview != "" {
|
||||
t.Fatalf("ordinary error retained an unrelated preview: %q", result.HumanDisplay().Preview)
|
||||
}
|
||||
}
|
||||
@@ -126,73 +126,11 @@ type Result struct {
|
||||
ChangeSummaries []execution.Change
|
||||
// Display carries a short, structured summary for the TUI / stream.
|
||||
Display Display
|
||||
// Outcome is the finalized, typed representation produced at the registry
|
||||
// seam. ModelView, HumanView, and Artifact deliberately serve different
|
||||
// consumers; Output, Display, and spill metadata remain synchronized for
|
||||
// compatibility with direct tool callers and persisted sessions.
|
||||
Outcome ToolOutcome
|
||||
// pendingFileObservation is proposed by read_file and committed only after
|
||||
// the final model-visible output boundary confirms the exact content survived.
|
||||
pendingFileObservation *pendingFileObservation
|
||||
}
|
||||
|
||||
// ToolOutcome is the canonical post-execution representation of one tool
|
||||
// result. It separates the bounded provider payload from the human-facing
|
||||
// presentation and the recoverable output retained outside model context.
|
||||
type ToolOutcome struct {
|
||||
ModelView string
|
||||
HumanView Display
|
||||
Artifact *ToolArtifact
|
||||
Diagnostics OutcomeDiagnostics
|
||||
finalized bool
|
||||
}
|
||||
|
||||
// Finalized reports whether the outcome crossed the registry boundary. Direct
|
||||
// Tool.Run results deliberately return false and use their legacy fields.
|
||||
func (outcome ToolOutcome) Finalized() bool {
|
||||
return outcome.finalized
|
||||
}
|
||||
|
||||
// ToolArtifact identifies recoverable output saved by the tool boundary.
|
||||
// CompleteAtBoundary means the artifact contains every redacted byte received
|
||||
// by that boundary; an underlying process may already have applied its own
|
||||
// capture limit before producing the result.
|
||||
type ToolArtifact struct {
|
||||
Path string
|
||||
CompleteAtBoundary bool
|
||||
}
|
||||
|
||||
// OutcomeDiagnostics describes how the model-facing representation differs
|
||||
// from the redacted output received by the registry boundary.
|
||||
type OutcomeDiagnostics struct {
|
||||
Category string
|
||||
OriginalBytes int
|
||||
ModelBytes int
|
||||
EstimatedOriginalTokens int
|
||||
EstimatedModelTokens int
|
||||
Truncated bool
|
||||
Redacted bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ModelOutput returns the finalized provider-facing text, falling back to the
|
||||
// legacy field for direct Tool.Run callers that have not crossed the registry.
|
||||
func (result Result) ModelOutput() string {
|
||||
if result.Outcome.finalized {
|
||||
return result.Outcome.ModelView
|
||||
}
|
||||
return result.Output
|
||||
}
|
||||
|
||||
// HumanDisplay returns the finalized presentation, falling back to the legacy
|
||||
// display for direct Tool.Run callers.
|
||||
func (result Result) HumanDisplay() Display {
|
||||
if result.Outcome.finalized {
|
||||
return result.Outcome.HumanView
|
||||
}
|
||||
return result.Display
|
||||
}
|
||||
|
||||
// Display carries a short, structured summary of a tool result for the TUI/stream.
|
||||
type Display struct {
|
||||
Summary string
|
||||
|
||||
+7
-11
@@ -86,16 +86,16 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix fingerprints are emitted after counters in insertion (request)
|
||||
// Prefix fingerprints are emitted after counters in insertion (turn)
|
||||
// order. The order is the order EmitPrefixHash was called, which is the
|
||||
// order the agent computed each request's fingerprint, which is the
|
||||
// order a downstream consumer needs to reconstruct prefix changes across
|
||||
// main, compaction, and retry requests. Sorting by complete_prefix hash would
|
||||
// destroy that sequence, so we do not
|
||||
// order the agent loop computed each turn's fingerprint, which is the
|
||||
// order a downstream consumer needs to correlate a prefix_hash event
|
||||
// with the cached_input_tokens counter for that turn. Sorting by
|
||||
// complete_prefix hash would destroy that correlation, so we do not
|
||||
// sort. The slice is already a deep copy from Finish (see
|
||||
// Recorder.Finish) so it is safe to range over without copying.
|
||||
for _, p := range t.PrefixHashes {
|
||||
obj := map[string]any{
|
||||
if err := enc.Encode(map[string]any{
|
||||
"type": "prefix_hash",
|
||||
"system_prompt": p.SystemPromptHash,
|
||||
"base_instructions": p.BaseInstructionsHash,
|
||||
@@ -105,11 +105,7 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
|
||||
"tools": p.ToolsHash,
|
||||
"schema": p.SchemaHash,
|
||||
"complete_prefix": p.CompletePrefixHash,
|
||||
}
|
||||
if p.InvalidationReason != "" {
|
||||
obj["invalidation_reason"] = p.InvalidationReason
|
||||
}
|
||||
if err := enc.Encode(obj); err != nil {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
|
||||
ToolsHash: stringField(obj, "tools"),
|
||||
SchemaHash: stringField(obj, "schema"),
|
||||
CompletePrefixHash: stringField(obj, "complete_prefix"),
|
||||
InvalidationReason: stringField(obj, "invalidation_reason"),
|
||||
})
|
||||
case "output_budget":
|
||||
if !sawTraceHeader {
|
||||
|
||||
@@ -177,9 +177,9 @@ func (r *Recorder) StampFirstUsefulAction() {
|
||||
}
|
||||
|
||||
// EmitPrefixHash records one prompt-prefix fingerprint on the trace. Multiple
|
||||
// calls are allowed within a run (one per planned provider request) and
|
||||
// accumulate in request order. The first call after Finish is a no-op; later
|
||||
// calls are also no-ops because the trace has been sealed.
|
||||
// calls are allowed within a run (one per turn, typically) and accumulate in
|
||||
// order. The first call after Finish is a no-op; later calls are also no-ops
|
||||
// because the trace has been sealed.
|
||||
func (r *Recorder) EmitPrefixHash(p PrefixHash) {
|
||||
if r == nil {
|
||||
return
|
||||
|
||||
@@ -159,7 +159,6 @@ type PrefixHash struct {
|
||||
ToolsHash string `json:"tools"`
|
||||
SchemaHash string `json:"schema"`
|
||||
CompletePrefixHash string `json:"complete_prefix"`
|
||||
InvalidationReason string `json:"invalidation_reason,omitempty"`
|
||||
}
|
||||
|
||||
// WallDuration is the total traced wall time of the run.
|
||||
|
||||
@@ -382,7 +382,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
ToolsHash: "t1",
|
||||
SchemaHash: "x1",
|
||||
CompletePrefixHash: "complete1",
|
||||
InvalidationReason: "initial",
|
||||
})
|
||||
r.EmitPrefixHash(PrefixHash{
|
||||
SystemPromptHash: "system2",
|
||||
@@ -393,7 +392,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
ToolsHash: "t2",
|
||||
SchemaHash: "x2",
|
||||
CompletePrefixHash: "complete2",
|
||||
InvalidationReason: "tools",
|
||||
})
|
||||
original := r.Finish()
|
||||
|
||||
@@ -423,8 +421,8 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.FirstTokenAt.IsZero() {
|
||||
t.Fatal("first_token_at lost in round-trip")
|
||||
}
|
||||
// prefix_hash round-trip: two events, in insertion order, with all hash
|
||||
// fields and the invalidation reason preserved exactly.
|
||||
// prefix_hash round-trip: two events, in insertion order, with all
|
||||
// seven sub-hash fields preserved exactly.
|
||||
if len(parsed.PrefixHashes) != 2 {
|
||||
t.Fatalf("expected 2 prefix_hash events after round-trip, got %d", len(parsed.PrefixHashes))
|
||||
}
|
||||
@@ -437,38 +435,6 @@ func TestReadNDJSONRoundTrip(t *testing.T) {
|
||||
if parsed.PrefixHashes[1].SystemPromptHash != "system2" || parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" {
|
||||
t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1])
|
||||
}
|
||||
if parsed.PrefixHashes[0].InvalidationReason != "initial" || parsed.PrefixHashes[1].InvalidationReason != "tools" {
|
||||
t.Fatalf("prefix invalidation reasons lost: %+v", parsed.PrefixHashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteNDJSONOmitsEmptyPrefixInvalidationReason(t *testing.T) {
|
||||
recorder := NewRecorder("session", "run", "test")
|
||||
recorder.Start()
|
||||
recorder.EmitPrefixHash(PrefixHash{CompletePrefixHash: "stable"})
|
||||
tr := recorder.Finish()
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := WriteNDJSON(&output, tr); err != nil {
|
||||
t.Fatalf("WriteNDJSON: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
t.Fatalf("decode event: %v", err)
|
||||
}
|
||||
if event["type"] != "prefix_hash" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if _, exists := event["invalidation_reason"]; exists {
|
||||
t.Fatalf("empty invalidation reason should be omitted: %s", line)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("prefix_hash event not written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNDJSONRejectsNonTrace(t *testing.T) {
|
||||
|
||||
@@ -5666,11 +5666,10 @@ func (m model) sendAgentUsage(runID int, modelID string, event zeroruntime.Usage
|
||||
// (a code/diff preview) when present on a successful result, else the Output that
|
||||
// the model also saw. Error results keep their Output so the failure shows.
|
||||
func toolResultDetail(result agent.ToolResult) string {
|
||||
display := result.HumanDisplay()
|
||||
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
|
||||
return display.Preview
|
||||
if result.Status != tools.StatusError && strings.TrimSpace(result.Display.Preview) != "" {
|
||||
return result.Display.Preview
|
||||
}
|
||||
return result.ModelOutput()
|
||||
return result.Output
|
||||
}
|
||||
|
||||
func toolResultRowText(result agent.ToolResult) string {
|
||||
@@ -5678,5 +5677,5 @@ func toolResultRowText(result agent.ToolResult) string {
|
||||
if status == "" {
|
||||
status = tools.StatusOK
|
||||
}
|
||||
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, truncateTUIOutput(result.ModelOutput(), tuiToolOutputLimit))
|
||||
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, truncateTUIOutput(result.Output, tuiToolOutputLimit))
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
var responseStyles = []string{"balanced", "concise", "explanatory", "review"}
|
||||
|
||||
const tuiCompactionPreserveLast = 8
|
||||
const tuiCompactionMaxPromptChars = 8000
|
||||
const compactStatusRowID = "compact/status"
|
||||
|
||||
var compactFrames = []string{"⠂", "⠒", "⠲", "⠴"}
|
||||
@@ -841,8 +842,8 @@ func (m model) compactActiveSession() (model, CompactResult, error) {
|
||||
beforeEvents := append([]sessions.Event{}, m.sessionEvents...)
|
||||
beforeTokens := estimateTranscriptTokens(m.transcript)
|
||||
plan, err := m.sessionStore.PlanCompaction(m.activeSession.SessionID, sessions.CompactionOptions{
|
||||
PreserveLast: tuiCompactionPreserveLast,
|
||||
SkipPrompt: true,
|
||||
PreserveLast: tuiCompactionPreserveLast,
|
||||
MaxPromptChars: tuiCompactionMaxPromptChars,
|
||||
})
|
||||
if err != nil {
|
||||
return m, CompactResult{}, err
|
||||
@@ -856,26 +857,13 @@ func (m model) compactActiveSession() (model, CompactResult, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
rawEvents, err := m.sessionStore.ReadEvents(m.activeSession.SessionID)
|
||||
if err != nil {
|
||||
return m, CompactResult{}, fmt.Errorf("read session events for compaction: %w", err)
|
||||
}
|
||||
compactableEvents, err := sessionEventsForRefs(rawEvents, plan.CompactableEvents)
|
||||
summary, err := m.summarizeCompactionPlan(plan)
|
||||
if err != nil {
|
||||
return m, CompactResult{}, err
|
||||
}
|
||||
summaryResult, err := m.summarizeCompactionPlan(plan, sessions.CompactionMessages(compactableEvents))
|
||||
if err != nil {
|
||||
return m, CompactResult{}, err
|
||||
}
|
||||
// PromptChars counts the text content sent to the provider: the shared
|
||||
// system instruction plus projected message/tool payloads. Provider protocol
|
||||
// framing is intentionally excluded.
|
||||
plan.PromptChars = len(agent.CompactionSummaryInstructions) + summaryResult.ProjectedChars
|
||||
plan.Truncated = summaryResult.Truncated
|
||||
if _, err := m.sessionStore.RecordCompaction(m.activeSession.SessionID, sessions.RecordCompactionInput{
|
||||
Plan: plan,
|
||||
Summary: summaryResult.SummaryText,
|
||||
Summary: summary,
|
||||
}); err != nil {
|
||||
return m, CompactResult{}, err
|
||||
}
|
||||
@@ -897,47 +885,32 @@ func (m model) compactActiveSession() (model, CompactResult, error) {
|
||||
Compacted: len(events) < len(beforeEvents)+1,
|
||||
BeforeTokens: beforeTokens,
|
||||
AfterTokens: estimateTranscriptTokens(m.transcript),
|
||||
Summary: summaryResult.SummaryText,
|
||||
Summary: summary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sessionEventsForRefs(events []sessions.Event, refs []sessions.EventRef) ([]sessions.Event, error) {
|
||||
byID := make(map[string]sessions.Event, len(events))
|
||||
for _, event := range events {
|
||||
byID[event.ID] = event
|
||||
func (m model) summarizeCompactionPlan(plan sessions.CompactionPlan) (string, error) {
|
||||
if m.provider == nil {
|
||||
return deterministicCompactionSummary(plan), nil
|
||||
}
|
||||
selected := make([]sessions.Event, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
event, ok := byID[ref.ID]
|
||||
if !ok || event.Sequence != ref.Sequence || event.Type != ref.Type {
|
||||
return nil, fmt.Errorf("session changed while compaction was being planned; retry /compact")
|
||||
}
|
||||
selected = append(selected, event)
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func (m model) summarizeCompactionPlan(plan sessions.CompactionPlan, messages []zeroruntime.Message) (agent.CompactionSummaryResult, error) {
|
||||
return agent.SummarizeCompactionMessages(messages, func(projected []zeroruntime.Message) (string, error) {
|
||||
if m.provider == nil {
|
||||
return deterministicCompactionSummary(plan), nil
|
||||
}
|
||||
stream, streamErr := m.provider.StreamCompletion(m.ctx, zeroruntime.CompletionRequest{
|
||||
Messages: append([]zeroruntime.Message{{Role: zeroruntime.MessageRoleSystem, Content: agent.CompactionSummaryInstructions}}, projected...),
|
||||
})
|
||||
if streamErr != nil {
|
||||
return "", fmt.Errorf("summarize compacted session: %w", streamErr)
|
||||
}
|
||||
collected := zeroruntime.CollectStream(m.ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("summarize compacted session: %s", collected.Error)
|
||||
}
|
||||
result := strings.TrimSpace(collected.Text)
|
||||
if result == "" {
|
||||
return "", fmt.Errorf("summarize compacted session: empty summary")
|
||||
}
|
||||
return result, nil
|
||||
stream, err := m.provider.StreamCompletion(m.ctx, zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "Summarize compacted Zero session events for future coding context. Preserve user goals, decisions, files, tool outcomes, blockers, and exact next steps. Omit secrets and do not invent details."},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: plan.SummaryPrompt},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("summarize compacted session: %w", err)
|
||||
}
|
||||
collected := zeroruntime.CollectStream(m.ctx, stream)
|
||||
if collected.Error != "" {
|
||||
return "", fmt.Errorf("summarize compacted session: %s", collected.Error)
|
||||
}
|
||||
summary := strings.TrimSpace(collected.Text)
|
||||
if summary == "" {
|
||||
return "", fmt.Errorf("summarize compacted session: empty summary")
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func deterministicCompactionSummary(plan sessions.CompactionPlan) string {
|
||||
|
||||
@@ -481,15 +481,6 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, input := range []sessions.AppendEventInput{
|
||||
{Type: sessions.EventToolCall, Payload: map[string]any{"id": "ask", "name": "ask_user", "arguments": `{"questions":[{"question":"Which database should remain?"}]}`}},
|
||||
{Type: sessions.EventToolResult, Payload: map[string]any{"toolCallId": "ask", "name": "ask_user", "status": "ok", "output": "Postgres only"}},
|
||||
} {
|
||||
m, err = m.appendSessionEvent(input.Type, input.Payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, content := range []string{
|
||||
"old decision A",
|
||||
"old decision B",
|
||||
@@ -530,15 +521,6 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) {
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected one provider summarization request, got %d", len(provider.requests))
|
||||
}
|
||||
request := provider.requests[0]
|
||||
if len(request.Messages) != 2 || request.Messages[0].Content != agent.CompactionSummaryInstructions {
|
||||
t.Fatalf("manual compaction did not use the shared agent compaction prompt: %#v", request.Messages)
|
||||
}
|
||||
for _, want := range []string{"Which database should remain?", "Postgres only"} {
|
||||
if !strings.Contains(request.Messages[1].Content, want) {
|
||||
t.Fatalf("manual compaction projection lost %q: %q", want, request.Messages[1].Content)
|
||||
}
|
||||
}
|
||||
if next.lastCompactResult == nil || next.lastCompactResult.Summary != "Provider summary keeps the actual old decisions." {
|
||||
t.Fatalf("expected provider summary result, got %#v", next.lastCompactResult)
|
||||
}
|
||||
@@ -551,56 +533,6 @@ func TestCompactCommandUsesProviderSummaryWhenAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualCompactionPersistsStructuralTruncationMetadata(t *testing.T) {
|
||||
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
|
||||
provider := &fakeProvider{events: []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Bounded summary."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}
|
||||
m := newModel(context.Background(), Options{ModelName: "gpt-4.1", Provider: provider, SessionStore: store})
|
||||
var err error
|
||||
m, err = m.ensureActiveSession("compact a large session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := range 20 {
|
||||
content := strings.Repeat("contextword ", 256) + string(rune('a'+index))
|
||||
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{"role": "user", "content": content})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: content})
|
||||
}
|
||||
|
||||
next, result, err := m.compactActiveSession()
|
||||
if err != nil || !result.Compacted {
|
||||
t.Fatalf("compactActiveSession() = (%#v, %v)", result, err)
|
||||
}
|
||||
raw, err := store.ReadEvents(next.activeSession.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest := raw[len(raw)-1]
|
||||
if latest.Type != sessions.EventCompaction {
|
||||
t.Fatalf("latest event = %s, want compaction", latest.Type)
|
||||
}
|
||||
payload := sessionPayload(latest)
|
||||
if !payloadBool(payload, "truncated") {
|
||||
t.Fatalf("compaction did not persist structural truncation: %#v", payload)
|
||||
}
|
||||
request := provider.requests[0]
|
||||
wantPromptChars := len(agent.CompactionSummaryInstructions)
|
||||
for _, message := range request.Messages[1:] {
|
||||
wantPromptChars += len(message.Content)
|
||||
for _, call := range message.ToolCalls {
|
||||
wantPromptChars += len(call.Name) + len(call.Arguments)
|
||||
}
|
||||
}
|
||||
if promptChars, ok := payload["promptChars"].(float64); !ok || int(promptChars) != wantPromptChars {
|
||||
t.Fatalf("promptChars = %#v, want %d", payload["promptChars"], wantPromptChars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactCommandRecordsRequestWhenNoCompactorIsAvailable(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.input.SetValue("/compact")
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type tuiOutcomeErrorTool struct {
|
||||
output string
|
||||
}
|
||||
|
||||
func (tool tuiOutcomeErrorTool) Name() string { return tools.ExecCommandToolName }
|
||||
func (tool tuiOutcomeErrorTool) Description() string { return "test tool" }
|
||||
func (tool tuiOutcomeErrorTool) Parameters() tools.Schema { return tools.Schema{Type: "object"} }
|
||||
func (tool tuiOutcomeErrorTool) Safety() tools.Safety {
|
||||
return tools.Safety{Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (tool tuiOutcomeErrorTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: tool.output}
|
||||
}
|
||||
|
||||
func setToolOutcomeTempDir(t *testing.T) {
|
||||
t.Helper()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
t.Setenv("TMP", tempDir)
|
||||
t.Setenv("TEMP", tempDir)
|
||||
}
|
||||
|
||||
func TestToolResultDetailUsesFinalizedHumanEvidenceForReducedError(t *testing.T) {
|
||||
setToolOutcomeTempDir(t)
|
||||
raw := "output:\n" + strings.Repeat("ok \texample.test/package\t0.01s\n", 24) +
|
||||
"--- FAIL: TestImportant\nexpected 7, got 9\nFAIL\nexit_code: 1"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tuiOutcomeErrorTool{output: raw})
|
||||
result := registry.Run(context.Background(), tools.ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
|
||||
detail := toolResultDetail(agent.ToolResult{
|
||||
Name: tools.ExecCommandToolName,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Display: result.Display,
|
||||
Outcome: result.Outcome,
|
||||
})
|
||||
for _, want := range []string{"ok \texample.test/package", "TestImportant", "expected 7, got 9"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Fatalf("human error detail missing %q: %q", want, detail)
|
||||
}
|
||||
}
|
||||
if detail == result.ModelOutput() {
|
||||
t.Fatal("human detail collapsed back to the reduced model view")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolResultDetailSurvivesOutcomeJSONRoundTrip(t *testing.T) {
|
||||
setToolOutcomeTempDir(t)
|
||||
raw := "output:\n" + strings.Repeat("ok \texample.test/package\t0.01s\n", 24) +
|
||||
"--- FAIL: TestImportant\nexpected 7, got 9\nFAIL\nexit_code: 1"
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tuiOutcomeErrorTool{output: raw})
|
||||
toolResult := registry.Run(context.Background(), tools.ExecCommandToolName, map[string]any{"cmd": "go test ./..."})
|
||||
original := agent.ToolResult{
|
||||
Name: tools.ExecCommandToolName,
|
||||
Status: toolResult.Status,
|
||||
Output: toolResult.Output,
|
||||
Display: toolResult.Display,
|
||||
Outcome: toolResult.Outcome,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool result: %v", err)
|
||||
}
|
||||
var restored agent.ToolResult
|
||||
if err := json.Unmarshal(data, &restored); err != nil {
|
||||
t.Fatalf("unmarshal tool result: %v", err)
|
||||
}
|
||||
if !restored.Outcome.Finalized() {
|
||||
t.Fatal("restored outcome lost its finalized state")
|
||||
}
|
||||
detail := toolResultDetail(restored)
|
||||
for _, want := range []string{"ok \texample.test/package", "TestImportant", "expected 7, got 9"} {
|
||||
if !strings.Contains(detail, want) {
|
||||
t.Fatalf("restored human detail missing %q: %q", want, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,14 +93,12 @@ type ReasoningBlock struct {
|
||||
|
||||
// Message is a normalized conversation turn passed to providers.
|
||||
type Message struct {
|
||||
Role MessageRole
|
||||
Content string
|
||||
ToolCalls []ToolCall
|
||||
ToolCallID string
|
||||
IsError bool // tool-result status; ignored for non-tool messages
|
||||
ChangedFiles []string // durable tool-result mutation targets; ignored by providers
|
||||
Images []ImageBlock // optional; nil for text-only messages
|
||||
Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay
|
||||
Role MessageRole
|
||||
Content string
|
||||
ToolCalls []ToolCall
|
||||
ToolCallID string
|
||||
Images []ImageBlock // optional; nil for text-only messages
|
||||
Reasoning []ReasoningBlock // optional; preserved thinking blocks to replay
|
||||
}
|
||||
|
||||
// ToolDefinition describes a model-visible tool and its JSON-schema parameters.
|
||||
|
||||
Reference in new issue
Block a user