Compare commits

..
32 changed files with 167 additions and 2297 deletions

No files matched your search

+4 -20
View File
@@ -25,7 +25,7 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
}
options := make([]PermissionOption, 0, len(actions))
for _, action := range actions {
kind, name := optionKindFor(action, req.PrefixApprovalEscalates)
kind, name := optionKindFor(action)
if kind == "" {
continue // skip actions that have no clean ACP option (e.g. cancel)
}
@@ -41,24 +41,18 @@ func buildPermissionOptions(req agent.PermissionRequest) []PermissionOption {
// optionKindFor maps a ZERO decision action to an ACP PermissionOptionKind and a
// human label. Returns an empty kind for actions that ACP expresses through the
// outcome rather than an option (cancel).
//
// escalates reports that approving a command prefix will also run the command
// outside the sandbox. The two prefix options say so in their label, since the
// label is all an ACP client shows and the option id carries no room for it. An
// editor user is deciding on exactly the same terms as a terminal user, so the
// disclosure cannot be TUI-only.
func optionKindFor(action agent.PermissionDecisionAction, escalates bool) (kind, name string) {
func optionKindFor(action agent.PermissionDecisionAction) (kind, name string) {
switch action {
case agent.PermissionDecisionAllow, agent.PermissionDecisionAllowStrict:
return PermAllowOnce, "Allow"
case agent.PermissionDecisionAllowForSession:
return PermAllowAlways, "Allow for this session"
case agent.PermissionDecisionAllowPrefix:
return PermAllowAlways, withSandboxEscalationNote("Allow this command for the session", escalates)
return PermAllowAlways, "Allow this command for the session"
case agent.PermissionDecisionAlwaysAllow:
return PermAllowAlways, "Always allow"
case agent.PermissionDecisionAlwaysAllowPrefix:
return PermAllowAlways, withSandboxEscalationNote("Always allow this command", escalates)
return PermAllowAlways, "Always allow this command"
case agent.PermissionDecisionDeny:
return PermRejectOnce, "Reject"
case agent.PermissionDecisionCancel:
@@ -129,13 +123,3 @@ func rawInputBytes(data []byte) json.RawMessage {
}
return json.RawMessage(data)
}
// withSandboxEscalationNote appends the escalation disclosure to a prefix
// option's label. Kept parallel to the TUI's wording so the same consequence is
// not described two different ways depending on which client the user runs.
func withSandboxEscalationNote(name string, escalates bool) string {
if !escalates {
return name
}
return name + " (runs outside the sandbox)"
}
@@ -1,68 +0,0 @@
package acp
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func escalationRequest(escalates bool) agent.PermissionRequest {
return agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
PrefixApprovalEscalates: escalates,
}
}
// An ACP client renders only Name, so if the escalation is not in the label the
// editor user approves it blind. They face exactly the outcome a terminal user
// faces, so the disclosure has to reach both clients or it is not a fix.
func TestACPPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
found := 0
for _, option := range buildPermissionOptions(escalationRequest(true)) {
action := agent.PermissionDecisionAction(option.OptionID)
if action != agent.PermissionDecisionAllowPrefix && action != agent.PermissionDecisionAlwaysAllowPrefix {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q carries the disclosure but does not escalate", option.Name)
}
continue
}
found++
if !strings.Contains(option.Name, "outside the sandbox") {
t.Errorf("prefix option %q does not say the command leaves the sandbox", option.Name)
}
}
if found != 2 {
t.Fatalf("expected both prefix options, saw %d", found)
}
}
func TestACPPrefixOptionsStaySilentWithoutEscalation(t *testing.T) {
for _, option := range buildPermissionOptions(escalationRequest(false)) {
if strings.Contains(option.Name, "sandbox") {
t.Errorf("option %q warns about the sandbox when approving would not leave it", option.Name)
}
}
}
// The disclosure must live in the label only. The option id is the decision
// action round-tripped verbatim, and decisionFromOutcome matches it against
// what was offered, so decorating it would fail every prefix approval closed.
func TestACPDisclosureDoesNotDisturbTheOptionIDRoundTrip(t *testing.T) {
request := escalationRequest(true)
for _, option := range buildPermissionOptions(request) {
decision := decisionFromOutcome(
RequestPermissionOutcome{Outcome: OutcomeSelected, OptionID: option.OptionID},
request.AvailableDecisions,
)
if string(decision.Action) != option.OptionID {
t.Errorf("option id %q round-tripped to %q (%s)", option.OptionID, decision.Action, decision.Reason)
}
}
}
+20 -81
View File
@@ -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
}
+2 -43
View File
@@ -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"},
+12 -131
View File
@@ -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) {
-315
View File
@@ -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
+5 -71
View File
@@ -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
-36
View File
@@ -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) {
-183
View File
@@ -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, ",")
}
-185
View File
@@ -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(&current)
current.CompletePrefixHash = "changed"
if got := explainPrefixChange(&baseline, current); got != test.want {
t.Fatalf("reason = %q, want %q", got, test.want)
}
})
}
}
+60 -91
View File
@@ -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.Output,
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 —
@@ -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)
@@ -940,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,
@@ -974,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, ""
}
@@ -2718,36 +2718,9 @@ func permissionRequestFromEvent(event PermissionEvent, args map[string]any, opti
Grant: event.Grant,
CommandPrefix: append([]string(nil), event.CommandPrefix...),
AvailableDecisions: availablePermissionDecisions(event, args, options),
// Computed from the SAME conditions shellExecutionArgsForApproval uses,
// so the prompt cannot claim an escalation that will not happen or stay
// silent about one that will. Keeping the two in step matters more than
// the wording: a label that overstates gets ignored, and one that
// understates is the bug being fixed.
PrefixApprovalEscalates: shellPrefixApprovalEscalates(event.ToolName, args, options),
}
}
// shellPrefixApprovalEscalates reports whether approving a command prefix would
// also take this command out of the sandbox.
//
// Deliberately mirrors shellExecutionArgsForApproval's guards rather than
// re-deriving them: same tool test, same sandbox availability test, and the same
// two opt-outs for a call that already asked for additional permissions or
// escalation. Those already carry their own disclosure, so labelling them again
// would be noise.
func shellPrefixApprovalEscalates(toolName string, args map[string]any, options Options) bool {
if !isShellCommandTool(toolName) {
return false
}
if options.Sandbox == nil || !options.Sandbox.UnsandboxedExecutionAllowed() {
return false
}
if shellCommandAdditionalPermissionsRequested(args) || shellCommandRequiresEscalated(args) {
return false
}
return true
}
func availablePermissionDecisions(event PermissionEvent, args map[string]any, options Options) []PermissionDecisionAction {
if event.Action != PermissionActionPrompt {
return nil
@@ -3305,7 +3278,6 @@ func appendAbortedToolResults(messages []Message, remaining []ToolCall) []Messag
Role: zeroruntime.MessageRoleTool,
Content: abortedToolResultNotice,
ToolCallID: call.ID,
IsError: true,
})
}
return messages
@@ -3336,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)
-44
View File
@@ -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
@@ -1,118 +0,0 @@
package agent
import (
"path/filepath"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
)
// escalatingEngine allows unsandboxed escalation: no denied reads, so
// UnsandboxedExecutionAllowed is true and a prefix approval would rewrite the
// call to require_escalated.
func escalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
policy := sandbox.DefaultPolicy()
policy.DenyRead = nil
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
}
// nonEscalatingEngine has a denied read, which is exactly the condition under
// which escalation is refused, so no disclosure should be made.
func nonEscalatingEngine(t *testing.T) *sandbox.Engine {
t.Helper()
root := t.TempDir()
policy := sandbox.DefaultPolicy()
policy.DenyRead = []string{filepath.Join(root, "secret.txt")}
return sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: root, Policy: policy})
}
// THE POINT OF THE FILE. The prompt's disclosure and the args rewrite must
// agree on every input, because the defect being fixed is precisely that the
// two disagreed: the rewrite escalated out of the sandbox while the prompt said
// only "allow command prefix for session".
//
// Asserting agreement rather than asserting each side's expected value is what
// makes this hold up. If someone later widens or narrows the rewrite's guards
// and forgets the flag, the two diverge here and this fails, which a pair of
// independent expected-value tests would not catch.
func TestPrefixEscalationDisclosureMatchesTheArgsRewrite(t *testing.T) {
escalating := Options{Sandbox: escalatingEngine(t)}
denied := Options{Sandbox: nonEscalatingEngine(t)}
cases := []struct {
name string
toolName string
args map[string]any
options Options
}{
{"plain bash command", "bash", map[string]any{"command": "go test ./..."}, escalating},
{"plain exec_command", "exec_command", map[string]any{"cmd": "go build ./..."}, escalating},
{"not a shell tool", "read_file", map[string]any{"path": "go.mod"}, escalating},
{"no sandbox configured", "bash", map[string]any{"command": "go test ./..."}, Options{}},
{"escalation refused by denied reads", "bash", map[string]any{"command": "go test ./..."}, denied},
{
"call already asks for escalation",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated)},
escalating,
},
{
"call already asks for additional permissions",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions)},
escalating,
},
// An unrecognized value is NOT an opt-out: the rewrite overwrites it, so
// the disclosure has to fire. Included because the obvious reading of
// the guards says otherwise, and a case asserting the wrong thing here
// would have passed silently.
{
"unrecognized sandbox_permissions value",
"bash",
map[string]any{"command": "go test ./...", "sandbox_permissions": "all"},
escalating,
},
{"empty args", "bash", map[string]any{}, escalating},
{"nil args", "bash", nil, escalating},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
disclosed := shellPrefixApprovalEscalates(testCase.toolName, testCase.args, testCase.options)
// What the user's approval would actually be turned into.
rewritten := shellExecutionArgsForApproval(testCase.toolName, testCase.args, PermissionDecisionAllowPrefix, testCase.options)
escalates := shellCommandRequiresEscalated(rewritten) && !shellCommandRequiresEscalated(testCase.args)
if disclosed != escalates {
t.Fatalf("prompt discloses escalation=%v but approving actually escalates=%v; the prompt and the rewrite disagree, which is the consent gap this guards", disclosed, escalates)
}
})
}
}
// The two prefix decisions are the only ones that escalate, so a disclosure
// attached to a request offering neither would be a false alarm. Checked
// against the same predicate the rewrite consults.
func TestOnlyPrefixDecisionsEscalate(t *testing.T) {
escalating := map[PermissionDecisionAction]bool{
PermissionDecisionAllowPrefix: true,
PermissionDecisionAlwaysAllowPrefix: true,
}
for _, action := range []PermissionDecisionAction{
PermissionDecisionAllow,
PermissionDecisionAllowStrict,
PermissionDecisionAllowForSession,
PermissionDecisionAllowPrefix,
PermissionDecisionAlwaysAllowPrefix,
PermissionDecisionAlwaysAllow,
PermissionDecisionDeny,
PermissionDecisionCancel,
} {
if got := shellPrefixApprovalBypassesSandbox(action); got != escalating[action] {
t.Errorf("decision %q bypasses sandbox=%v, want %v", action, got, escalating[action])
}
}
}
+10 -217
View File
@@ -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
-62
View File
@@ -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 -21
View File
@@ -176,22 +176,6 @@ type PermissionRequest struct {
Grant *sandbox.Grant `json:"grant,omitempty"`
CommandPrefix []string `json:"commandPrefix,omitempty"`
AvailableDecisions []PermissionDecisionAction `json:"availableDecisions,omitempty"`
// PrefixApprovalEscalates reports that approving a command prefix will also
// run the command OUTSIDE the sandbox, not merely stop asking about it.
//
// It exists because that consequence was real but invisible. Approving a
// prefix rewrites the call to sandbox_permissions: require_escalated, which
// resolves to a nil engine and genuinely unsandboxed execution, and the
// engine's own escalation prompt is then satisfied by the approval just
// given for the sandboxed form. So the operator authorized one thing and got
// a wider one, having been shown only "allow command prefix for session".
//
// The escalation itself is deliberate and guarded: proposedCommandPrefix
// refuses to offer a prefix while any other segment of the command is not
// known-safe, precisely so an unreviewed segment cannot ride out of the
// sandbox on it. What was missing was telling the person deciding, so this
// surfaces it rather than removing it.
PrefixApprovalEscalates bool `json:"prefixApprovalEscalates,omitempty"`
}
type PermissionDecision struct {
@@ -349,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,
+3 -92
View File
@@ -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:
-39
View File
@@ -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()})
+7 -11
View File
@@ -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
}
}
-1
View File
@@ -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 {
+3 -3
View File
@@ -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
-1
View File
@@ -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.
+2 -36
View File
@@ -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) {
+2 -20
View File
@@ -43,9 +43,9 @@ func permissionOptions(request agent.PermissionRequest) []permissionOption {
case agent.PermissionDecisionAllowForSession:
options = append(options, permissionOption{label: "allow for session", hotkey: "s", choice: permissionDecisionAllowForSession})
case agent.PermissionDecisionAllowPrefix:
options = append(options, permissionOption{label: prefixApprovalLabel("allow command prefix for session", request), hotkey: "p", choice: permissionDecisionAllowPrefix})
options = append(options, permissionOption{label: "allow command prefix for session", hotkey: "p", choice: permissionDecisionAllowPrefix})
case agent.PermissionDecisionAlwaysAllowPrefix:
options = append(options, permissionOption{label: prefixApprovalLabel("always allow command prefix", request), hotkey: "y", choice: permissionDecisionAlwaysAllowPrefix})
options = append(options, permissionOption{label: "always allow command prefix", hotkey: "y", choice: permissionDecisionAlwaysAllowPrefix})
case agent.PermissionDecisionAlwaysAllow:
options = append(options, permissionOption{label: "always", hotkey: "y", choice: permissionDecisionAlwaysAllow})
case agent.PermissionDecisionDeny:
@@ -155,21 +155,3 @@ func (m model) cancelPermissionTyping() (tea.Model, tea.Cmd) {
m.pendingPermission.savedDraft = ""
return m, nil
}
// prefixApprovalLabel spells out that approving a prefix also takes the command
// out of the sandbox.
//
// Approving a prefix rewrites the call to require_escalated, which resolves to
// unsandboxed execution, and the engine's escalation prompt is then satisfied by
// the approval just given for the sandboxed form. The operator was shown only
// "allow command prefix for session" and got something wider, which is a consent
// gap rather than a bug in the escalation itself.
//
// Only appended when it is true. The backend computes that from the same guards
// the rewrite uses, so a prompt that says this always means it.
func prefixApprovalLabel(base string, request agent.PermissionRequest) string {
if !request.PrefixApprovalEscalates {
return base
}
return base + " (runs outside the sandbox)"
}
@@ -1,70 +0,0 @@
package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
)
func prefixOptionLabels(request agent.PermissionRequest) []string {
labels := []string{}
for _, option := range permissionOptions(request) {
if option.choice == permissionDecisionAllowPrefix || option.choice == permissionDecisionAlwaysAllowPrefix {
labels = append(labels, option.label)
}
}
return labels
}
func prefixRequest(escalates bool) agent.PermissionRequest {
return agent.PermissionRequest{
ToolName: "bash",
AvailableDecisions: []agent.PermissionDecisionAction{
agent.PermissionDecisionAllow,
agent.PermissionDecisionAllowPrefix,
agent.PermissionDecisionAlwaysAllowPrefix,
agent.PermissionDecisionDeny,
},
PrefixApprovalEscalates: escalates,
}
}
// Approving a prefix also runs the command outside the sandbox, and the label
// is the only place the person deciding could learn that. Both prefix options
// escalate, so both must say so; covering only one would leave the other as a
// silent path to the same outcome.
func TestPrefixOptionsDiscloseLeavingTheSandbox(t *testing.T) {
labels := prefixOptionLabels(prefixRequest(true))
if len(labels) != 2 {
t.Fatalf("expected both prefix options, got %#v", labels)
}
for _, label := range labels {
if !strings.Contains(label, "outside the sandbox") {
t.Errorf("prefix option %q does not say the command leaves the sandbox, so approving it is uninformed consent", label)
}
}
}
// The disclosure has to be conditional. When escalation will not happen, saying
// it will is a false warning, and a prompt that cries wolf on every prefix is
// one the user learns to ignore, which costs the disclosure its whole value.
func TestPrefixOptionsStaySilentWhenNoEscalationHappens(t *testing.T) {
for _, label := range prefixOptionLabels(prefixRequest(false)) {
if strings.Contains(label, "sandbox") {
t.Errorf("prefix option %q warns about the sandbox when approving would not leave it", label)
}
}
}
// The non-prefix options do not escalate, so the flag must not bleed into them.
func TestNonPrefixOptionsNeverCarryTheDisclosure(t *testing.T) {
for _, option := range permissionOptions(prefixRequest(true)) {
if option.choice == permissionDecisionAllowPrefix || option.choice == permissionDecisionAlwaysAllowPrefix {
continue
}
if strings.Contains(option.label, "sandbox") {
t.Errorf("option %q carries the prefix escalation disclosure but does not escalate", option.label)
}
}
}
+26 -53
View File
@@ -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 {
-68
View File
@@ -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")
+6 -8
View File
@@ -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.