Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ result, err := cogito.ExecuteTools(llm, fragment,
- When enabled, the sink state tool appears as an option in the tool selection enum
- The sink state tool receives a `reasoning` parameter containing the LLM's reasoning about why no tool is needed
- Custom sink state tools must accept a `reasoning` parameter in their arguments
- The sink state's arguments are generated under their own output cap, `DefaultSinkStateMaxTokens` (2048) by default. A model that never closes the `reasoning` argument is cut at the cap instead of running to the client's global limit, and a capped step is neither length-retried nor sent through argument correction; the loop then answers as usual. Set it with `cogito.WithSinkStateMaxTokens(n)` (`0` = default, a negative value restores the previous uncapped behaviour). Real tools are not affected by this cap.

#### Sub-Agent Spawning

Expand Down
84 changes: 84 additions & 0 deletions decision_output_cap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cogito

import (
"context"
"errors"
"testing"

"github.com/sashabaranov/go-openai"
)

// lengthCutLLM is a non-streaming fake whose reasoning call is always cut by
// the output cap, with the arguments left open, unless complete is set.
type lengthCutLLM struct {
complete bool
requests []openai.ChatCompletionRequest
}

func (m *lengthCutLLM) Ask(ctx context.Context, f Fragment) (Fragment, error) { return f, nil }

func (m *lengthCutLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (LLMReply, LLMUsage, error) {
m.requests = append(m.requests, req)
limit := req.MaxTokens
if limit == 0 {
limit = 16384
}
args := `{"reasoning":"a a a a`
if m.complete {
args = `{"reasoning":"short"}`
}
usage := LLMUsage{PromptTokens: 100, CompletionTokens: limit, TotalTokens: 100 + limit}
return LLMReply{
ChatCompletionResponse: openai.ChatCompletionResponse{Choices: []openai.ChatCompletionChoice{{
FinishReason: openai.FinishReasonLength,
Message: openai.ChatCompletionMessage{Role: "assistant", ToolCalls: []openai.ToolCall{{
ID: "c1", Type: openai.ToolTypeFunction,
Function: openai.FunctionCall{Name: "reasoning", Arguments: args},
}}},
}}},
MaxTokens: limit,
}, usage, nil
}

var outputCapConv = []openai.ChatCompletionMessage{{Role: "user", Content: "find x"}}

func TestDecisionCappedStopsAtItsOwnCap(t *testing.T) {
llm := &lengthCutLLM{}
_, err := decisionCapped(context.Background(), llm, outputCapConv, Tools{reasoningTool()}, "reasoning", 3, 512)
if !errors.Is(err, errOutputCapReached) {
t.Fatalf("expected errOutputCapReached, got %v", err)
}
if len(llm.requests) != 1 {
t.Fatalf("expected one attempt without a length retry, got %d", len(llm.requests))
}
if got := llm.requests[0].MaxTokens; got != 512 {
t.Fatalf("expected the request to carry max tokens 512, got %d", got)
}
}

func TestDecisionCappedKeepsACompleteCallCutAtTheCap(t *testing.T) {
llm := &lengthCutLLM{complete: true}
res, err := decisionCapped(context.Background(), llm, outputCapConv, Tools{reasoningTool()}, "reasoning", 3, 512)
if err != nil {
t.Fatalf("expected the complete call to be used, got %v", err)
}
if len(res.toolChoices) != 1 || res.toolChoices[0].Arguments["reasoning"] != "short" {
t.Fatalf("expected the reasoning call, got %+v", res.toolChoices)
}
}

// Counter-check: without a step cap the decision behaves as before, it sends
// no cap of its own and raises the cap once on a cut tool call.
func TestDecisionWithoutCapKeepsTheLengthRetry(t *testing.T) {
llm := &lengthCutLLM{}
_, err := decision(context.Background(), llm, outputCapConv, Tools{reasoningTool()}, "reasoning", 3)
if err == nil || errors.Is(err, errOutputCapReached) {
t.Fatalf("expected the uncapped truncation error, got %v", err)
}
if len(llm.requests) != 2 {
t.Fatalf("expected the original attempt plus one length retry, got %d", len(llm.requests))
}
if llm.requests[0].MaxTokens != 0 || llm.requests[1].MaxTokens != 32768 {
t.Fatalf("expected caps 0 then 32768, got %d then %d", llm.requests[0].MaxTokens, llm.requests[1].MaxTokens)
}
}
60 changes: 60 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type Options struct {
loopDetectionSteps int
forceReasoning bool
forceReasoningTool bool
reasoningMaxTokens int
sinkStateMaxTokens int
guidedTools bool
parallelToolExecution bool
toolImageForwarding bool
Expand Down Expand Up @@ -409,6 +411,64 @@ func WithForceReasoningTool() func(o *Options) {
}
}

// DefaultReasoningMaxTokens is the output cap of the forced reasoning steps
// (tool and parameter reasoning) when WithReasoningMaxTokens is not set.
const DefaultReasoningMaxTokens = 2048

// WithReasoningMaxTokens sets the output-token cap of the forced reasoning
// steps (see WithForceReasoning). These steps are optional: when the cap cuts
// one, the loop continues without its reasoning instead of letting a model
// that never closes the reasoning argument run to the client's global cap.
// 0 (or unset) uses DefaultReasoningMaxTokens; a negative value removes the
// step's own cap, leaving the request to the client's default as before.
func WithReasoningMaxTokens(n int) func(o *Options) {
return func(o *Options) {
o.reasoningMaxTokens = n
}
}

// reasoningOutputCap returns the cap the reasoning steps send, 0 for none.
func (o *Options) reasoningOutputCap() int {
switch {
case o.reasoningMaxTokens < 0:
return 0
case o.reasoningMaxTokens == 0:
return DefaultReasoningMaxTokens
default:
return o.reasoningMaxTokens
}
}

// DefaultSinkStateMaxTokens is the output cap of the sink state's parameter
// generation when WithSinkStateMaxTokens is not set.
const DefaultSinkStateMaxTokens = 2048

// WithSinkStateMaxTokens sets the output-token cap of the step that generates
// the sink state's arguments (see WithSinkState, WithForceReasoning). The sink
// ends the tool loop and its arguments are not executed — the answer comes
// from the closing completion after the loop — so when the cap cuts them the
// loop ends without them instead of letting a model that repeats itself inside
// the argument run to the client's global cap. Real tools are not affected.
// 0 (or unset) uses DefaultSinkStateMaxTokens; a negative value removes the
// step's own cap, leaving the request to the client's default as before.
func WithSinkStateMaxTokens(n int) func(o *Options) {
return func(o *Options) {
o.sinkStateMaxTokens = n
}
}

// sinkStateOutputCap returns the cap the sink's parameter step sends, 0 for none.
func (o *Options) sinkStateOutputCap() int {
switch {
case o.sinkStateMaxTokens < 0:
return 0
case o.sinkStateMaxTokens == 0:
return DefaultSinkStateMaxTokens
default:
return o.sinkStateMaxTokens
}
}

// WithToolImageForwarding, when true, forwards image blocks from an MCP tool
// result to the model as a follow-up user message (OpenAI tool-role messages
// cannot carry image parts). Default false.
Expand Down
6 changes: 6 additions & 0 deletions plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,12 @@ func convertOptionsToFunctions(o *Options) []Option {
if o.forceReasoning {
opts = append(opts, WithForceReasoning())
}
if o.reasoningMaxTokens != 0 {
opts = append(opts, WithReasoningMaxTokens(o.reasoningMaxTokens))
}
if o.sinkStateMaxTokens != 0 {
opts = append(opts, WithSinkStateMaxTokens(o.sinkStateMaxTokens))
}
if o.maxRetries > 0 {
opts = append(opts, WithMaxRetries(o.maxRetries))
}
Expand Down
Loading