refactor(provider): remove the summary request flag and what only it served: the Request.Summary field and ErrSummaryToolsUnsupported, the adapters' no-tools branches, the broker's capability probe, and the test doubles and live test written for a summarizer call that no fold makes; the reported-usage effect test now detects a fold by its archive pointer and has a control arm

This commit is contained in:
yhh committed 2026-11-06 23:47:31 -08:00
1 parent 0f81082776
commit 14649962fc
67 files changed
+58 -653

No files matched your search

+8 -12
View File
@@ -44,16 +44,10 @@ func (p *bulkReplyProvider) lastRequestMessageWith(text string) string {
return ""
}
func (p *bulkReplyProvider) summaryRequests() int {
func (p *bulkReplyProvider) requestCount() int {
p.mu.Lock()
defer p.mu.Unlock()
n := 0
for _, req := range p.reqs {
if req.Summary {
n++
}
}
return n
return len(p.reqs)
}
// TestEffectCompactArchivesInsteadOfSummarizing pins the fold at the boundary a
@@ -92,6 +86,7 @@ model = "x"
}
}
before := rec.requestCount()
verdict, err := ctrl.Compact(context.Background(), agent.CompactRequest{})
if err != nil {
t.Fatalf("Compact: %v", err)
@@ -99,8 +94,8 @@ model = "x"
if !verdict.Compacted() {
t.Fatalf("verdict = %+v, want a fold", verdict)
}
if n := rec.summaryRequests(); n != 0 {
t.Fatalf("a manual compaction made %d summarizer request(s)", n)
if n := rec.requestCount() - before; n != 0 {
t.Fatalf("a manual compaction made %d model request(s)", n)
}
if err := ctrl.Run(context.Background(), "what is left to do"); err != nil {
@@ -169,6 +164,7 @@ model = "x"
t.Fatalf("first fold = %+v, %v", v, err)
}
say(4)
before := rec.requestCount()
again, err := ctrl.Compact(context.Background(), agent.CompactRequest{})
if err != nil {
t.Fatalf("second Compact: %v", err)
@@ -176,8 +172,8 @@ model = "x"
if !again.Compacted() {
t.Fatalf("a request by hand after a little more was said was declined: %+v (%s)", again, agent.CompactDeclineText(again.Reason))
}
if n := rec.summaryRequests(); n != 0 {
t.Fatalf("folding made %d summarizer request(s)", n)
if n := rec.requestCount() - before; n != 0 {
t.Fatalf("folding made %d model request(s)", n)
}
if _, detail := ctrl.ContextReport(); !strings.Contains(detail, "archived") || strings.Contains(detail, "summarized") {
t.Fatalf("the report does not name the fold an archive:\n%s", detail)
@@ -31,11 +31,8 @@ func (p *failedBatchProvider) Name() string { return "boot-failed-batch" }
func (p *failedBatchProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
summary := req.Summary
round := p.rounds
if !summary {
p.rounds++
}
p.rounds++
p.mu.Unlock()
ch := make(chan provider.Chunk, failedBatchReads+3)
call := func(id, name string, args any) {
@@ -43,8 +40,6 @@ func (p *failedBatchProvider) Stream(_ context.Context, req provider.Request) (<
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}}
}
switch {
case summary:
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Goal\n- read the packages"}
case round < 5:
for i := range failedBatchReads {
call(fmt.Sprintf("r%d-%d", round, i), "read_file", map[string]string{"path": fmt.Sprintf("src%d.txt", i)})
@@ -10,12 +10,9 @@ import (
"reasonix/internal/contract/provider"
)
func digestRequests(reqs []provider.Request) (summarizer, carrying int) {
// carryingProjection counts the requests that already hold a fold.
func carryingProjection(reqs []provider.Request) (carrying int) {
for _, req := range reqs {
if req.Summary {
summarizer++
continue
}
for _, m := range req.Messages {
if strings.Contains(m.Content, "<compaction-summary>") {
carrying++
@@ -23,7 +20,7 @@ func digestRequests(reqs []provider.Request) (summarizer, carrying int) {
}
}
}
return summarizer, carrying
return carrying
}
// modelSwitchFixtureChars is the first-request size this fixture is padded to:
@@ -91,7 +88,7 @@ context_window = 32500
t.Fatalf("Run(%q): %v", prompt, err)
}
}
if _, carrying := digestRequests(rec.requests()); carrying == 0 {
if carryingProjection(rec.requests()) == 0 {
t.Fatal("fixture never compacted")
}
if err := from.Snapshot(); err != nil {
@@ -110,16 +107,11 @@ context_window = 32500
t.Fatalf("the fixture's first request is %d characters, want %d: the padding did not reach it", got, modelSwitchFixtureChars)
}
before := len(rec.requests())
summBefore, _ := digestRequests(rec.requests())
if err := to.Run(context.Background(), "after the switch"); err != nil {
t.Fatalf("Run after switch: %v", err)
}
after := rec.requests()[before:]
summ, carrying := digestRequests(after)
if summ != 0 {
t.Fatalf("switch spent %d summarization call(s) (had %d before)", summ, summBefore)
}
if carrying == 0 {
if carryingProjection(after) == 0 {
t.Fatalf("request after the switch dropped the projection; messages=%s", messageDigest(after[len(after)-1].Messages))
}
}
@@ -39,16 +39,11 @@ func (p *progressEffectProvider) Name() string { return "boot-progress-effect" }
func (p *progressEffectProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.mu.Lock()
p.reqs = append(p.reqs, req)
digest := req.Summary
round := p.rounds
if !digest {
p.rounds++
}
p.rounds++
p.mu.Unlock()
ch := make(chan provider.Chunk, 2)
switch {
case digest:
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Files\n- notes.md was written"}
case round < len(p.script):
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: p.script[round]}
default:
@@ -19,7 +19,6 @@ import (
type promptSizeProvider struct {
mu sync.Mutex
reported int
digests int
mains []provider.Request
}
@@ -29,13 +28,6 @@ func (p *promptSizeProvider) Stream(_ context.Context, req provider.Request) (<-
p.mu.Lock()
defer p.mu.Unlock()
ch := make(chan provider.Chunk, 3)
if req.Summary {
p.digests++
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Summary\nSUMMARY of the earlier work."}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
p.mains = append(p.mains, req)
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: p.reported, TotalTokens: p.reported + 1}}
@@ -44,10 +36,26 @@ func (p *promptSizeProvider) Stream(_ context.Context, req provider.Request) (<-
return ch, nil
}
func (p *promptSizeProvider) counts() (digests, mains int) {
func (p *promptSizeProvider) counts() (folded, mains int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.digests, len(p.mains)
for _, req := range p.mains {
if carriesArchivePointer(req) {
folded++
}
}
return folded, len(p.mains)
}
// carriesArchivePointer is whether a request already holds a fold: the earlier
// conversation was moved to a file and a pointer stands in for it.
func carriesArchivePointer(req provider.Request) bool {
for _, m := range req.Messages {
if strings.Contains(m.Content, "archived, not summarized") {
return true
}
}
return false
}
// imageSession is about 70k estimated tokens that include one screenshot,
@@ -123,7 +131,15 @@ context_window = 100000
}
func TestEffectReportedUsageBelowTriggerLeavesContextAlone(t *testing.T) {
if digests, _ := foldsAfterTwoTurns(t, 60_000); digests != 0 {
t.Fatalf("digest requests = %d with the provider reporting 60k of 100k", digests)
if _, second := foldsAfterTwoTurns(t, 60_000); carriesArchivePointer(second) {
t.Fatal("a fold reached the request with the provider reporting 60k of 100k")
}
}
// The control arm: the same run with the provider reporting more than the
// trigger does fold, so the assertion above can fail.
func TestEffectReportedUsageAboveTriggerFolds(t *testing.T) {
if _, second := foldsAfterTwoTurns(t, 95_000); !carriesArchivePointer(second) {
t.Fatal("a provider reporting 95k of 100k left the context unfolded")
}
}
@@ -28,12 +28,8 @@ func (p *compactionEffectProvider) Stream(_ context.Context, req provider.Reques
p.mu.Lock()
p.reqs = append(p.reqs, req)
p.mu.Unlock()
text := p.bulk
if req.Summary {
text = "## Standing facts\n- none recorded"
}
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: text}
ch <- provider.Chunk{Type: provider.ChunkText, Text: p.bulk}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
@@ -47,7 +47,7 @@ func compactedSession(t *testing.T, rec *compactionEffectProvider, home, dir str
t.Fatalf("Run(%q): %v", prompt, err)
}
}
if _, carrying := digestRequests(rec.requests()); carrying == 0 {
if carryingProjection(rec.requests()) == 0 {
t.Fatal("fixture never compacted")
}
if err := from.Snapshot(); err != nil {
@@ -35,11 +35,6 @@ func (p *budgetEffectProvider) Stream(_ context.Context, req provider.Request) (
p.mu.Unlock()
ch := make(chan provider.Chunk, 2)
defer close(ch)
if req.Summary {
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Standing facts\n- none recorded"}
ch <- provider.Chunk{Type: provider.ChunkDone}
return ch, nil
}
asked, answered := "", ""
for _, m := range req.Messages {
switch {
+1 -2
View File
@@ -9,7 +9,6 @@ import (
"encoding/json"
"os"
"path/filepath"
"reasonix/internal/runtime/agent/testutil"
"reflect"
"strings"
"sync"
@@ -39,7 +38,7 @@ func (p *effectRecordingProvider) Stream(ctx context.Context, req provider.Reque
p.rawInputs = append(p.rawInputs, agent.RawUserInput(ctx, ""))
p.mu.Unlock()
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "ok")}
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
@@ -71,9 +71,6 @@ context_window = 32000
const contractMark = "Treat the user's goal as a task contract"
for i, req := range rec.requests() {
if isSummarizerRequest(req) {
continue
}
if n := strings.Count(userTextOf(req), contractMark); n > 2 {
t.Fatalf("request %d carried the full contract %d times; copies are piling up", i, n)
}
@@ -1,11 +0,0 @@
package boot
import (
"reasonix/internal/contract/provider"
)
// isSummarizerRequest tells the compaction summarizer's own call apart from the
// agent's. Only the agent's requests carry the projection under test.
func isSummarizerRequest(req provider.Request) bool {
return req.Summary
}
-8
View File
@@ -1,10 +1,5 @@
package provider
import "errors"
// ErrSummaryToolsUnsupported refuses retained schemas before an unsafe broker POST.
var ErrSummaryToolsUnsupported = errors.New("provider cannot guarantee disabled summary tools")
// Request is a single completion request.
type Request struct {
Messages []Message
@@ -19,9 +14,6 @@ type Request struct {
// Mode is the session's model mode id. An adapter sends only a mode its
// config declared for this endpoint, so an unknown one reaches no wire.
Mode string `json:"Mode,omitempty"`
// Summary asks for a compaction briefing with tool use disabled. It crosses
// the provider broker so remote and local summaries have the same contract.
Summary bool `json:"summary,omitempty"`
}
// ResponseFormat asks a provider to constrain its output shape.
+1 -1
View File
@@ -478,7 +478,7 @@ func (c *client) buildRequest(_ context.Context, req provider.Request) anthReque
r.Thinking = &thinkingConfig{Type: t}
}
}
return r.forSummary(req.Summary)
return r
}
// readStream parses the Messages API SSE stream into Chunks. Text deltas emit live;
@@ -3,10 +3,3 @@ package anthropic
type toolChoice struct {
Type string `json:"type"`
}
func (r anthRequest) forSummary(summary bool) anthRequest {
if summary && len(r.Tools) > 0 {
r.ToolChoice = &toolChoice{Type: "none"}
}
return r
}
@@ -1,20 +0,0 @@
package anthropic
import (
"context"
"reflect"
"testing"
"reasonix/internal/contract/provider"
)
func TestSummaryRetainsSchemasWithToolUseDisabled(t *testing.T) {
c := &client{model: "m", webSearch: true}
req := provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}}
normal := c.buildRequest(context.Background(), req)
req.Summary = true
summary := c.buildRequest(context.Background(), req)
if !reflect.DeepEqual(normal.Tools, summary.Tools) || summary.ToolChoice == nil || summary.ToolChoice.Type != "none" || normal.ToolChoice != nil {
t.Fatalf("summary changed the catalog or retained tool use: %+v", summary)
}
}
+1 -1
View File
@@ -758,7 +758,7 @@ func (c *client) buildRequest(req provider.Request) chatRequest {
// is left untouched for backends that also honour it.
out.Thinking = &thinkingMode{Type: c.thinkingType}
}
return out.forSummary(req.Summary)
return out
}
func (c *client) buildPrefixRequest(req provider.Request, content, reasoning string) chatRequest {
-8
View File
@@ -14,11 +14,3 @@ type chatRequest struct {
Thinking *thinkingMode `json:"thinking,omitempty"`
ExtraBody map[string]any `json:"-"`
}
func (r chatRequest) forSummary(summary bool) chatRequest {
if summary && len(r.Tools) > 0 {
r.ToolChoice = "none"
r.ExtraBody = omitExtraBodyFields(r.ExtraBody, "tool_choice")
}
return r
}
@@ -1,32 +0,0 @@
package openai
import (
"encoding/json"
"testing"
"reasonix/internal/contract/provider"
)
func TestSummaryKeepsSchemasButCannotChooseATool(t *testing.T) {
c := &client{model: "m", extraBody: map[string]any{"tool_choice": "required"}}
req := provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}, Summary: true}
raw, err := json.Marshal(c.buildRequest(req))
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body["tool_choice"] != "none" || len(body["tools"].([]any)) != 1 {
t.Fatalf("summary lost its schemas or allowed calls: %s", raw)
}
req.Summary = false
raw, err = json.Marshal(c.buildRequest(req))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &body); err != nil || body["tool_choice"] != "required" {
t.Fatalf("normal request no longer honors extra_body: %s, %v", raw, err)
}
}
-10
View File
@@ -165,16 +165,6 @@ func (b *brokered) Stream(ctx context.Context, req provider.Request) (<-chan pro
if err != nil {
return nil, err
}
if req.Summary && len(req.Tools) > 0 {
// Capability and POST use one authenticated target, never the picker's stale cache.
catalog, probeErr := b.client.fetchCatalogAt(ctx, base, token)
if ctx.Err() != nil {
return nil, ctx.Err()
}
if probeErr != nil || !catalog.SummaryToolChoiceNone {
return nil, provider.ErrSummaryToolsUnsupported
}
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+PathStream, bytes.NewReader(body))
if err != nil {
return nil, err
+1 -1
View File
@@ -61,7 +61,7 @@ func (s *Server) handleCatalog(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(catalogResponse{Providers: s.resolver.Catalog(), SummaryToolChoiceNone: true})
_ = json.NewEncoder(w).Encode(catalogResponse{Providers: s.resolver.Catalog()})
}
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
@@ -1,233 +0,0 @@
package providerbroker
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"reasonix/internal/contract/provider"
)
func TestSummaryCatalogRequiresFreshPolicySupport(t *testing.T) {
for _, mode := range []string{"old", "false", "invalid", "server error", "unauthorized", "timeout", "supported"} {
t.Run(mode, func(t *testing.T) {
var catalog, posts atomic.Int32
var behavior atomic.Value
behavior.Store("supported")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == PathCatalog {
catalog.Add(1)
switch behavior.Load().(string) {
case "old":
_, _ = w.Write([]byte(`{"providers":[]}`))
case "false":
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":false}`))
case "invalid":
_, _ = w.Write([]byte(`not json`))
case "server error":
w.WriteHeader(http.StatusInternalServerError)
case "unauthorized":
w.WriteHeader(http.StatusUnauthorized)
case "timeout":
<-r.Context().Done()
default:
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
}
return
}
posts.Add(1)
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
}))
defer srv.Close()
c := NewClient(srv.URL, testToken, srv.Client())
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
if err != nil {
t.Fatal(err)
}
behavior.Store(mode)
before := catalog.Load()
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
ch, err := p.Stream(ctx, provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
if mode == "supported" {
if err != nil {
t.Fatal(err)
}
drain(t, ch)
if posts.Load() != 1 {
t.Fatal("supported summary did not run")
}
} else if (mode != "timeout" && !errors.Is(err, provider.ErrSummaryToolsUnsupported)) || (mode == "timeout" && !errors.Is(err, context.DeadlineExceeded)) || posts.Load() != 0 {
t.Fatalf("unsupported summary sent: err=%v posts=%d", err, posts.Load())
}
if catalog.Load() != before+1 {
t.Fatal("summary relied on cached catalog capability")
}
behavior.Store("old")
before = catalog.Load()
ch, err = p.Stream(t.Context(), provider.Request{Summary: true})
if err != nil {
t.Fatal(err)
}
drain(t, ch)
ch, err = p.Stream(t.Context(), provider.Request{Tools: []provider.ToolSchema{{Name: "read_file"}}})
if err != nil {
t.Fatal(err)
}
drain(t, ch)
if catalog.Load() != before {
t.Fatal("ordinary and independent requests gained a capability dependency")
}
})
}
}
func TestSummaryCapabilityIsPinnedToTheAuthenticatedTarget(t *testing.T) {
var aPosts, bPosts atomic.Int32
var target atomic.Value
b := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(HeaderToken) != "token-b" {
t.Error("wrong B token")
}
if r.URL.Path == PathCatalog {
_, _ = w.Write([]byte(`{"providers":[]}`))
return
}
bPosts.Add(1)
}))
defer b.Close()
var switchDuringProbe atomic.Bool
a := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(HeaderToken) != "token-a" {
t.Error("wrong A token")
}
if r.URL.Path == PathCatalog {
if switchDuringProbe.Load() {
target.Store(b.URL)
}
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
return
}
aPosts.Add(1)
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
}))
defer a.Close()
target.Store(a.URL)
c := NewEndpointClient(func() (string, string, error) {
url := target.Load().(string)
token := "token-a"
if url == b.URL {
token = "token-b"
}
return url, token, nil
}, a.Client())
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
if err != nil {
t.Fatal(err)
}
req := provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}}
switchDuringProbe.Store(true)
ch, err := p.Stream(t.Context(), req)
if err != nil {
t.Fatal(err)
}
drain(t, ch)
if aPosts.Load() != 1 || bPosts.Load() != 0 {
t.Fatalf("probe and POST did not share target: A=%d B=%d", aPosts.Load(), bPosts.Load())
}
if _, err = p.Stream(t.Context(), req); err == nil || bPosts.Load() != 0 {
t.Fatal("positive A capability leaked to legacy B")
}
}
func TestCurrentServerAdvertisesSummaryPolicy(t *testing.T) {
p := &fakeProvider{name: "p"}
s, err := NewServer(staticResolver(p), testToken)
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, PathCatalog, nil)
r.Header.Set(HeaderToken, testToken)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
var body struct {
Summary bool `json:"summary_tool_choice_none"`
}
if json.Unmarshal(w.Body.Bytes(), &body) != nil || !body.Summary {
t.Fatal("new broker does not advertise policy")
}
}
func TestSummaryCapabilityPreservesCancellation(t *testing.T) {
probing := make(chan struct{})
var posts atomic.Int32
var block atomic.Bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == PathCatalog {
if block.Load() {
close(probing)
<-r.Context().Done()
return
}
_, _ = w.Write([]byte(`{"providers":[]}`))
return
}
posts.Add(1)
}))
defer srv.Close()
p, err := NewClient(srv.URL, testToken, srv.Client()).Resolve(provider.Selection{Ref: "p/m"})
if err != nil {
t.Fatal(err)
}
block.Store(true)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
_, err := p.Stream(ctx, provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
done <- err
}()
<-probing
cancel()
if err := <-done; !errors.Is(err, context.Canceled) || posts.Load() != 0 {
t.Fatalf("cancel err=%v posts=%d", err, posts.Load())
}
}
func TestSummaryCapabilityUsesRotatedTokenAtTheSameURL(t *testing.T) {
var token atomic.Value
token.Store("before")
var posts atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(HeaderToken) != token.Load().(string) {
t.Error("stale token")
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == PathCatalog {
_, _ = w.Write([]byte(`{"providers":[],"summary_tool_choice_none":true}`))
return
}
posts.Add(1)
_ = json.NewEncoder(w).Encode(encodeChunk(provider.Chunk{Type: provider.ChunkDone}))
}))
defer srv.Close()
c := NewEndpointClient(func() (string, string, error) { return srv.URL, token.Load().(string), nil }, srv.Client())
p, err := c.Resolve(provider.Selection{Ref: "p/m"})
if err != nil {
t.Fatal(err)
}
token.Store("after")
ch, err := p.Stream(t.Context(), provider.Request{Summary: true, Tools: []provider.ToolSchema{{Name: "read_file"}}})
if err != nil {
t.Fatal(err)
}
drain(t, ch)
if posts.Load() != 1 {
t.Fatal("rotated token summary did not reach provider")
}
}
+1 -2
View File
@@ -83,7 +83,6 @@ func TestATurnCompletesOverTheReverseForward(t *testing.T) {
}
ch, err := p.Stream(context.Background(), provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
Summary: true,
})
if err != nil {
t.Fatalf("Stream: %v", err)
@@ -98,7 +97,7 @@ func TestATurnCompletesOverTheReverseForward(t *testing.T) {
if got[2].Usage == nil || got[2].Usage.CacheHitTokens != 4 {
t.Fatalf("usage did not survive the tunnel: %+v", got[2])
}
if sent := <-seen; len(sent.Messages) != 1 || sent.Messages[0].Content != "hi" || !sent.Summary {
if sent := <-seen; len(sent.Messages) != 1 || sent.Messages[0].Content != "hi" {
t.Fatalf("the request did not reach the local provider intact: %+v", sent)
}
}
+1 -2
View File
@@ -19,8 +19,7 @@ const (
)
type catalogResponse struct {
Providers []provider.Descriptor `json:"providers"`
SummaryToolChoiceNone bool `json:"summary_tool_choice_none"`
Providers []provider.Descriptor `json:"providers"`
}
type streamRequest struct {
+2 -3
View File
@@ -256,7 +256,7 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
}
c.authed.Store(true)
out := make(chan provider.Chunk, 64)
go c.readStream(requestCtx, resp, out, wireMessages, downgrade, !req.Summary)
go c.readStream(requestCtx, resp, out, wireMessages, downgrade, true)
return out, nil
}
@@ -375,7 +375,6 @@ func (c *client) buildRequestBodyWithContinuation(req provider.Request, allowPre
})
}
body["tools"] = tools
applySummaryToolChoice(body, req.Summary)
}
instructions, rest := splitInstructions(messages)
if instructions != "" {
@@ -386,7 +385,7 @@ func (c *client) buildRequestBodyWithContinuation(req provider.Request, allowPre
previousID, expectedDigest := c.lastResponseID, c.expectedPrefixDigest
mode := c.effectiveModeLocked()
c.mu.Unlock()
if allowPrevious && !req.Summary && mode == "stateful" && previousID != "" && len(messages) > 0 &&
if allowPrevious && mode == "stateful" && previousID != "" && len(messages) > 0 &&
messages[len(messages)-1].Role == provider.RoleUser &&
c.conversationDigest(messages[:len(messages)-1]) == expectedDigest {
body["input"] = messages[len(messages)-1].Content
@@ -1,7 +0,0 @@
package responses
func applySummaryToolChoice(body map[string]any, summary bool) {
if summary {
body["tool_choice"] = "none"
}
}
@@ -1,65 +0,0 @@
package responses
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"reasonix/internal/contract/provider"
)
func TestSummaryKeepsSchemasWithoutAContinuationOrToolUse(t *testing.T) {
c := &client{model: "m", mode: "stateful", webSearch: true, lastResponseID: "main-response"}
prefix := []provider.Message{{Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "finding"}}
c.expectedPrefixDigest = c.conversationDigest(prefix)
req := provider.Request{Messages: append(prefix, provider.Message{Role: provider.RoleUser, Content: "summarize"}), Tools: []provider.ToolSchema{{Name: "read_file"}}}
normal, used, _ := c.buildRequestBody(req)
if !used {
t.Fatal("fixture did not exercise the normal continuation")
}
req.Summary = true
summary, used, _ := c.buildRequestBody(req)
if used || summary["previous_response_id"] != nil || summary["tool_choice"] != "none" || !reflect.DeepEqual(summary["tools"], normal["tools"]) {
t.Fatalf("summary changed tools or reused a live continuation: %+v", summary)
}
}
func TestSummaryDoesNotReplaceTheMainResponseContinuation(t *testing.T) {
for _, invalid := range []bool{false, true} {
t.Run(fmt.Sprint("invalid=", invalid), func(t *testing.T) {
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
bodies = append(bodies, body)
if invalid && len(bodies) == 2 {
writeEvents(w, `{"type":"response.completed","response":{"id":"poisoned","output":[{"type":"function_call","call_id":"c","name":"echo","arguments":{}}]}}`)
return
}
writeEvents(w, `{"type":"response.output_text.delta","item_id":"msg","delta":"answer"}`,
fmt.Sprintf(`{"type":"response.completed","response":{"id":"r%d"}}`, len(bodies)))
}))
defer srv.Close()
p := New(Config{Name: "relay", BaseURL: srv.URL, Model: "m"})
req := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "one"}}}
collect(t, p, req)
req.Messages = append(req.Messages, provider.Message{Role: provider.RoleAssistant, Content: "answer"}, provider.Message{Role: provider.RoleUser, Content: "summarize"})
req.Summary = true
chunks := collect(t, p, req)
if invalid {
assertTerminalFailure(t, chunks, provider.ErrInvalidFunctionCall)
}
req.Summary = false
req.Messages[len(req.Messages)-1].Content = "two"
collect(t, p, req)
if bodies[1]["previous_response_id"] != nil || bodies[2]["previous_response_id"] != "r1" {
t.Fatalf("summary replaced the main continuation: %+v", bodies)
}
})
}
}
@@ -1,145 +0,0 @@
//go:build live_compaction
package agent
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/model/openai"
"reasonix/internal/state/sessionstore"
)
type liveSummaryRecorder struct {
provider.Provider
requests []provider.Request
results []liveSummaryResult
}
type liveSummaryResult struct {
Kind string `json:"kind"`
Milliseconds int64 `json:"milliseconds"`
Digest bool `json:"digest"`
Grounded bool `json:"grounded"`
ToolCalls int `json:"tool_calls"`
Usage *provider.Usage `json:"usage"`
}
func (p *liveSummaryRecorder) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, req)
start := time.Now()
ch, err := p.Provider.Stream(ctx, req)
if err != nil {
return nil, err
}
out := make(chan provider.Chunk)
go func() {
defer close(out)
var text strings.Builder
var usage *provider.Usage
calls := 0
for c := range ch {
if c.Type == provider.ChunkToolCall {
calls++
}
if c.Type == provider.ChunkText {
text.WriteString(c.Text)
}
if c.Type == provider.ChunkUsage {
usage = c.Usage
}
select {
case out <- c:
case <-ctx.Done():
return
}
}
kind := "warm"
if req.Summary {
kind = "reuse"
if len(req.Tools) == 0 {
kind = "independent"
}
}
p.results = append(p.results, liveSummaryResult{Kind: kind, Milliseconds: time.Since(start).Milliseconds(), Digest: hasDigestHeading(text.String()), Grounded: strings.Contains(text.String(), "ParseDuration") && strings.Contains(strings.ToLower(text.String()), "zero"), ToolCalls: calls, Usage: usage})
}()
return out, nil
}
func TestLiveCompactionPrefix(t *testing.T) {
key := os.Getenv("DEEPSEEK_API_KEY")
if key == "" {
t.Skip("DEEPSEEK_API_KEY is unset")
}
model := os.Getenv("REASONIX_LIVE_MODEL")
if model == "" {
model = "deepseek-flash"
}
savedTransport := http.DefaultTransport
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = true
http.DefaultTransport = transport
t.Cleanup(func() { transport.CloseIdleConnections(); http.DefaultTransport = savedTransport })
p, err := openai.New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: model, APIKey: key, Extra: map[string]any{"thinking": "disabled"}})
if err != nil {
t.Fatal(err)
}
for _, rich := range []bool{false, true} {
for trial := range 3 {
rec := &liveSummaryRecorder{Provider: p}
messages := []provider.Message{
{Role: provider.RoleSystem, Content: "You are a coding agent. Preserve the public API. " + strings.Repeat("Keep changes narrow and run relevant tests. ", 100)},
{Role: provider.RoleUser, Content: "Investigate why ParseDuration accepts negative values. Preserve function names and return a typed error."},
{Role: provider.RoleAssistant, Content: "Inspected duration.go: ParseDuration lacks a negative-value guard. Plan: reject values below zero and add boundary tests."},
{Role: provider.RoleUser, Content: "Continue; ensure zero remains valid."},
}
if rich {
messages = append(messages[:2], provider.Message{Role: provider.RoleAssistant, ReasoningContent: "Inspect the input guard.", ToolCalls: []provider.ToolCall{{ID: "call_read", Name: "read_file", Arguments: `{"path":"duration.go","private_payload":"synthetic-argument-marker"}`}}}, provider.Message{Role: provider.RoleTool, Name: "read_file", ToolCallID: "call_read", Content: "func ParseDuration(v int) (int,error) { return v,nil }"}, messages[2], messages[3])
}
schemas := []provider.ToolSchema{{Name: "read_file", Description: "Read a file", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}`)}}
a := New(rec, tool.NewRegistry(), &sessionstore.Session{Messages: messages}, Options{ContextWindow: 128000, MaxOutputTokens: 1024}, event.Discard)
ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second)
ch, err := a.streamProviderRequest(ctx, freezeProviderRequest(provider.Request{Messages: messages, Tools: schemas, MaxTokens: 64}))
if err != nil {
cancel()
t.Fatal(err)
}
for c := range ch {
if c.Type == provider.ChunkError {
cancel()
t.Fatal(c.Err)
}
}
fold := messages[2 : len(messages)-1]
_, _, _, _, err = a.window().runCompactionSummary(ctx, fold, "")
if err != nil {
cancel()
t.Fatal(err)
}
_, _, err = a.window().summarizeAtMode(ctx, fold, "", 1024, 0, false)
cancel()
if err != nil {
t.Fatal(err)
}
for _, r := range rec.results {
if r.Kind != "warm" && (!r.Digest || !r.Grounded || r.ToolCalls != 0 || r.Usage == nil || r.Usage.FinishReason != "stop") {
t.Fatalf("summary contract failed: %+v", r)
}
}
data, _ := json.Marshal(struct {
Model string `json:"model"`
Rich bool `json:"rich"`
Trial int `json:"trial"`
Results []liveSummaryResult `json:"results"`
}{model, rich, trial + 1, rec.results})
t.Log(string(data))
}
}
}
@@ -3,7 +3,6 @@ package agent
import (
"context"
"fmt"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/contract/provider"
)
@@ -20,7 +19,7 @@ func (p *countingProvider) Name() string { return "counting" }
func (p *countingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.got = append(p.got, req)
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, fmt.Sprintf("%s %d", p.reply, len(p.got)))}
ch <- provider.Chunk{Type: provider.ChunkText, Text: fmt.Sprintf("%s %d", p.reply, len(p.got))}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path/filepath"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/state/sessionstore"
"slices"
"strings"
@@ -93,7 +92,7 @@ func (p *scriptedSummarizer) Stream(_ context.Context, req provider.Request) (<-
r := p.replies[at]
ch := make(chan provider.Chunk, 4)
if r.text != "" {
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, r.text)}
ch <- provider.Chunk{Type: provider.ChunkText, Text: r.text}
}
if r.usage != nil {
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: r.usage}
+1 -2
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/state/sessionstore"
"strings"
"testing"
@@ -44,7 +43,7 @@ func (f *fakeProvider) answer(req provider.Request) string {
if f.verbatim {
return f.reply
}
return testutil.SummaryReply(req, f.reply)
return f.reply
}
func (f *fakeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
@@ -95,7 +95,7 @@ func (p *MockProvider) Stream(ctx context.Context, req provider.Request) (<-chan
chunks = append(chunks, provider.Chunk{Type: provider.ChunkReasoning, Text: t.Reasoning})
}
if t.Text != "" {
chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: SummaryReply(req, t.Text)})
chunks = append(chunks, provider.Chunk{Type: provider.ChunkText, Text: t.Text})
}
for i := range t.ToolCalls {
tc := t.ToolCalls[i]
@@ -1,23 +0,0 @@
package testutil
import (
"strings"
"reasonix/internal/contract/provider"
)
// SummaryReply is what a double that follows the summary contract answers: the
// summarizer prompt requires headings, and the host refuses a reply with
// none. A plain reply to a summary request gets one, so a test that only cares
// about the digest's text keeps reading it.
func SummaryReply(req provider.Request, text string) string {
if !req.Summary {
return text
}
for line := range strings.SplitSeq(text, "\n") {
if hashes := len(line) - len(strings.TrimLeft(line, "#")); hashes >= 1 && hashes <= 6 && strings.HasPrefix(line[hashes:], " ") {
return text
}
}
return "## Summary\n" + text
}
+1 -2
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/state/sessionstore"
"reflect"
"slices"
@@ -65,7 +64,7 @@ func (p *scriptedProvider) Stream(ctx context.Context, req provider.Request) (<-
ch <- provider.Chunk{Type: provider.ChunkReasoning, Text: resp.reasoning}
}
if resp.text != "" {
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, resp.text)}
ch <- provider.Chunk{Type: provider.ChunkText, Text: resp.text}
}
if resp.usage != nil {
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: resp.usage}