Skip to content
Merged
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 docs/embedding.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ A response with no `subtype` at all is treated as success. Both are bounded by
| `result` | the final assistant text; for an error, `"Error: <readable reason>"` |
| `num_turns` | model calls in this turn (one per tool round-trip) |
| `stop_reason` | the last model call's stop reason |
| `trail_off_nudges` | optional; present when non-zero. Replies the turn refused to end on because they only announced a next step, each answered with a "do it now" nudge and counted in `num_turns` |
| `usage` | tokens for **this turn**, summed over its model calls, not cumulative for the session |
| `total_cost_usd` | priced from Klaudia's model table; `0` for an unpriced model (every OpenAI-compatible one). 0 means unpriced, not free; `usage` is the reliable figure |

Expand Down
45 changes: 45 additions & 0 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ type Options struct {
// model-aware MaxTokens default comes from Claude's table only on
// Anthropic; any other provider gets the conservative unknown-model cap.
ProviderName string
// ContinueOnTrailOff keeps going when a turn ends with no tool call and
// the text only announces the next step ("I'll read X next."). The loop
// appends a nudge and requests again, up to maxTrailOffNudges times, and
// counts each one on Result.TrailOffNudges. It is for runs where nobody
// is there to re-prompt — headless and the goal loop — and stays off for
// an interactive session, where ending the turn is how the user gets to
// answer. A question, and a reply carrying a completion marker, still end
// the turn.
ContinueOnTrailOff bool
// PartialMessages, if set, receives raw model stream events during the main
// answer turn (not compaction summaries). The CLI wires this to a
// stream_event emitter when --include-partial-messages is set. Nil by
Expand Down Expand Up @@ -231,6 +240,11 @@ type Result struct {
// by delegating — and the breakdown is here so /cost and the stream-json
// result can show it rather than only the total.
Children []ChildUsage
// TrailOffNudges counts the times this run refused to end on a reply that
// only announced the next step (see trailoff.go). It is a session-economics
// figure: each one is a model call the announcement would otherwise have
// cost a whole re-prompt to recover.
TrailOffNudges int
// Messages is the full conversation after the run (initial + this turn's
// exchanges), so a caller can carry it forward as InitialMessages for the
// next turn (used by the stream-json embedding frontend).
Expand Down Expand Up @@ -433,6 +447,10 @@ func (l *Loop) Run(ctx context.Context, opts Options, emit Emitter) (Result, err
// most once per Run, so a genuinely oversized request still surfaces.
var calib compaction.Calibration
overflowRecovered := false
// trailOffNudges counts how many times this run has already pushed back
// on a reply that announced its next step instead of taking it. It caps
// at maxTrailOffNudges, after which the announcement stands.
trailOffNudges := 0
for {
res.NumTurns++

Expand Down Expand Up @@ -595,6 +613,23 @@ func (l *Loop) Run(ctx context.Context, opts Options, emit Emitter) (Result, err
// Collect tool_use blocks from this turn.
toolUses := toolUseBlocks(assistant)
if len(toolUses) == 0 {
// A reply that only promises the next step has done nothing, and
// in a run with nobody watching it costs a whole re-prompt to
// recover. Nudge inside this loop instead, up to the cap. The
// assistant turn is recorded first, exactly as a real final
// answer is, so the transcript stays paired.
if opts.ContinueOnTrailOff && !halted && trailOff(finalText) && trailOffNudges < maxTrailOffNudges && withinLimits(opts, res) {
rec("assistant", assistant)
nudge := anthropic.NewBetaUserMessage(anthropic.NewBetaTextBlock(trailOffNudge))
messages = append(messages, nudge)
rec("user", nudge)
trailOffNudges++
res.TrailOffNudges = trailOffNudges
if emit != nil {
emit(Event{Type: "notice", Content: fmt.Sprintf("trail-off: the reply announced a next step without doing it — continuing (%d/%d)", trailOffNudges, maxTrailOffNudges)})
}
continue
}
// Final (tool-less) answer: structurally fine on its own, record now.
rec("assistant", assistant)
res.Messages = messages
Expand Down Expand Up @@ -706,6 +741,16 @@ func (l *Loop) Run(ctx context.Context, opts Options, emit Emitter) (Result, err
}
}

// withinLimits reports whether the run may make another request under its
// turn cap and budget. A trail-off nudge is another request, so it is bound by
// the same limits as a tool turn; past them the announcement is the answer.
func withinLimits(opts Options, res Result) bool {
if opts.MaxTurns > 0 && res.NumTurns >= opts.MaxTurns {
return false
}
return opts.MaxBudgetUSD <= 0 || res.CostUSD < opts.MaxBudgetUSD
}

// elisionSpiller gives microcompact somewhere to put the tool results it
// removes, so an elision stays recoverable: the placeholder names the file and
// the model can read it. It uses the same store as the per-result cap, under
Expand Down
109 changes: 109 additions & 0 deletions internal/agent/trailoff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package agent

import (
"regexp"
"strings"
)

// maxTrailOffNudges is how many times one Run will push a model that keeps
// ending its turn by announcing the next step instead of taking it. Each nudge
// is another model call, so the bound is small: past it the announcement is
// the answer and the turn ends.
const maxTrailOffNudges = 2

// trailOffNudge is the user message appended when a turn trails off. It is a
// user message rather than a system one because it has to land between two
// assistant turns, and it names the behaviour rather than the topic so it
// cannot be mistaken for new work.
const trailOffNudge = "You announced what you would do next but made no tool call, so nothing happened. Do that step now, in this turn, by calling the tool. If the work is actually finished, give the final answer instead of describing a next step — and if you are waiting on the user, ask them plainly."

// announcement patterns, matched against the reply's LAST sentence only: a
// trailing-off turn ends on its promise, while "I'll" earlier in a real
// answer ("I'll note the fix is in loop.go. Everything passes now.") is
// narration. The first pattern wants the sentence to open with the promise,
// allowing one lead word ("OK, I'll…", "Next, let me…"); the second catches
// the promise anywhere in the sentence when it ends on "next" or "now".
var trailOffPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)^(?:[\w']+[,:]?\s+)?(?:i(?:'ll|’ll| will)|let me|i'm going to|i am going to)\s+\S`),
regexp.MustCompile(`(?i)\b(?:i(?:'ll|’ll| will)|let me|i'm going to|i am going to)\s+\S.*\b(?:next|now)\W*$`),
}

// notNowPatterns mark a last sentence that promises something other than an
// immediate step: a conditional offer ("If you approve, I'll push") waits on
// the user, and a deferral ("in a follow-up PR") is out of this run's scope.
// A sign-off ("Let me know what you think", "I'll stop here", "I'll leave
// the rest to you") hands the turn back on purpose, so it is an ending too.
// Nudging any of these would tell the model to do now what it rightly did not —
// under -p, to push without the approval it asked for.
var notNowPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\b(?:if|once|when|whenever|unless|after|as soon as|should you|provided)\b`),
regexp.MustCompile(`(?i)\b(?:follow-?up|later|separate(?:ly)?|another (?:pr|change|commit|session)|next time|future|tomorrow|eventually)\b`),
regexp.MustCompile(`(?i)\blet me know\b|\b(?:stop|stopping|pause|pausing|end|ending|wrap(?:ping)? up|leave|leaving|hand(?:ing)? (?:it |this |the rest )?(?:back|over))\b`),
}

// doneMarkers are lines a reply uses to say it is finished. A reply carrying
// one is a final answer however it is phrased, so it is never a trail-off.
var doneMarkers = []string{
"autopilot: done",
"<goal-complete/>",
}

// trailOff reports whether text is a reply that only announces a next step.
// A question is the model waiting on the user, and a completion marker is the
// model declaring itself finished; neither is nudged.
func trailOff(text string) bool {
text = strings.TrimSpace(text)
if text == "" || hasDoneMarker(text) || endsWithQuestion(text) {
return false
}
last := strings.TrimLeft(lastSentence(text), "-*#>• \t")
for _, re := range notNowPatterns {
if re.MatchString(last) {
return false
}
}
for _, re := range trailOffPatterns {
if re.MatchString(last) {
return true
}
}
return false
}

func hasDoneMarker(text string) bool {
low := strings.ToLower(text)
for _, m := range doneMarkers {
if strings.Contains(low, m) {
return true
}
}
return false
}

// endsWithQuestion reports whether the reply's last sentence asks something.
// An earlier question followed by an announcement ("Shall I? I'll read it
// next.") is still an announcement.
func endsWithQuestion(text string) bool {
return strings.ContainsRune(lastSentence(text), '?')
}

// lastSentence returns the final non-empty sentence of text. Sentences break
// on a newline or after ". ", "! " or "? ", so a "?" ends its own sentence.
func lastSentence(text string) string {
last := strings.TrimSpace(text)
for {
cut := -1
for _, sep := range []string{"\n", ". ", "! ", "? "} {
if i := strings.LastIndex(last, sep); i >= 0 && i+len(sep) > cut {
cut = i + len(sep)
}
}
if cut < 0 {
return last
}
if tail := strings.TrimSpace(last[cut:]); tail != "" {
return tail
}
last = strings.TrimSpace(last[:cut])
}
}
161 changes: 161 additions & 0 deletions internal/agent/trailoff_loop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package agent

import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/anthropics/anthropic-sdk-go"

"github.com/greenthread-ai/klaudia/internal/api"
"github.com/greenthread-ai/klaudia/internal/tools"
)

// scripted is a Provider that returns canned assistant messages in order and
// records the text of every user message it was shown, so a test can see the
// nudge land.
type scripted struct {
replies []string
seen []string
}

func (s *scripted) StreamTurn(_ context.Context, params anthropic.BetaMessageNewParams, sink api.StreamSink) (anthropic.BetaMessage, error) {
for _, m := range params.Messages {
if m.Role != "user" {
continue
}
for _, b := range m.Content {
if b.OfText != nil {
s.seen = append(s.seen, b.OfText.Text)
}
}
}
text := "(script exhausted)"
if len(s.replies) > 0 {
text, s.replies = s.replies[0], s.replies[1:]
}
sink.OnText(text)
raw := fmt.Sprintf(`{"id":"m","type":"message","role":"assistant","model":"x","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1},"content":[{"type":"text","text":%q}]}`, text)
var msg anthropic.BetaMessage
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
return msg, err
}
return msg, nil
}

func runScript(t *testing.T, replies []string, cont bool) (Result, *scripted) {
t.Helper()
return runScriptOpts(t, replies, Options{ContinueOnTrailOff: cont})
}

func runScriptOpts(t *testing.T, replies []string, opts Options) (Result, *scripted) {
t.Helper()
s := &scripted{replies: replies}
opts.Prompt = "do the thing"
res, err := New(s, tools.NewRegistry()).Run(context.Background(), opts, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
return res, s
}

func TestTrailOffNudgeThenDone(t *testing.T) {
res, s := runScript(t, []string{"I'll read loop.go next.", "Read it. The exit is at the empty tool list."}, true)
if res.TrailOffNudges != 1 {
t.Fatalf("TrailOffNudges = %d, want 1", res.TrailOffNudges)
}
if res.NumTurns != 2 {
t.Fatalf("NumTurns = %d, want 2", res.NumTurns)
}
if !strings.Contains(res.Text, "Read it") {
t.Fatalf("final text = %q, want the second reply", res.Text)
}
if !strings.Contains(strings.Join(s.seen, "\n"), "made no tool call") {
t.Fatalf("the nudge never reached the model; seen %q", s.seen)
}
}

func TestTrailOffBounded(t *testing.T) {
res, _ := runScript(t, []string{
"I'll read loop.go next.",
"Next I'll run the tests.",
"Let me check the output now.",
}, true)
if res.TrailOffNudges != maxTrailOffNudges {
t.Fatalf("TrailOffNudges = %d, want the cap %d", res.TrailOffNudges, maxTrailOffNudges)
}
// The cap stops the loop: one turn per nudge plus the turn that stands.
if res.NumTurns != maxTrailOffNudges+1 {
t.Fatalf("NumTurns = %d, want %d", res.NumTurns, maxTrailOffNudges+1)
}
if res.Text != "Let me check the output now." {
t.Fatalf("the turn past the cap should stand, got %q", res.Text)
}
}

func TestTrailOffOffByDefault(t *testing.T) {
res, s := runScript(t, []string{"I'll read loop.go next."}, false)
if res.TrailOffNudges != 0 || res.NumTurns != 1 {
t.Fatalf("with the guard off: nudges %d turns %d, want 0 and 1", res.TrailOffNudges, res.NumTurns)
}
if strings.Contains(strings.Join(s.seen, "\n"), "made no tool call") {
t.Fatal("nudged a turn the guard was not asked to watch")
}
}

func TestTrailOffIgnoresARealEnding(t *testing.T) {
for _, reply := range []string{
"All done.\nAUTOPILOT: DONE",
"Which file should I read?",
"The tests pass.",
} {
res, _ := runScript(t, []string{reply}, true)
if res.TrailOffNudges != 0 || res.NumTurns != 1 {
t.Errorf("%q: nudges %d turns %d, want a plain ending", reply, res.TrailOffNudges, res.NumTurns)
}
}
}

// A nudge is another request, so it is bound by --max-turns like a tool turn.
// Before the fix MaxTurns=1 with three announcements gave three turns.
func TestTrailOffRespectsMaxTurns(t *testing.T) {
replies := []string{"I'll read loop.go next.", "Next I'll run the tests.", "Let me check the output now."}
res, _ := runScriptOpts(t, replies, Options{ContinueOnTrailOff: true, MaxTurns: 1})
if res.NumTurns != 1 || res.TrailOffNudges != 0 {
t.Fatalf("MaxTurns=1: turns %d nudges %d, want 1 and 0", res.NumTurns, res.TrailOffNudges)
}
res, _ = runScriptOpts(t, replies, Options{ContinueOnTrailOff: true, MaxTurns: 2})
if res.NumTurns != 2 || res.TrailOffNudges != 1 {
t.Fatalf("MaxTurns=2: turns %d nudges %d, want 2 and 1", res.NumTurns, res.TrailOffNudges)
}
}

func TestWithinLimits(t *testing.T) {
cases := []struct {
opts Options
res Result
want bool
}{
{Options{}, Result{NumTurns: 50, CostUSD: 9}, true},
{Options{MaxTurns: 3}, Result{NumTurns: 2}, true},
{Options{MaxTurns: 3}, Result{NumTurns: 3}, false},
{Options{MaxBudgetUSD: 1}, Result{CostUSD: 0.5}, true},
{Options{MaxBudgetUSD: 1}, Result{CostUSD: 1}, false},
}
for _, c := range cases {
if got := withinLimits(c.opts, c.res); got != c.want {
t.Errorf("withinLimits(%+v, turns %d cost %v) = %v, want %v", c.opts, c.res.NumTurns, c.res.CostUSD, got, c.want)
}
}
}

// The review's conditional offer must end the run: nudging it under -p would
// tell the model to push without the approval it asked for.
func TestTrailOffLeavesAConditionalOffer(t *testing.T) {
res, _ := runScript(t, []string{"Done. If you approve, I'll push the branch."}, true)
if res.TrailOffNudges != 0 || res.NumTurns != 1 {
t.Fatalf("nudges %d turns %d, want a plain ending", res.TrailOffNudges, res.NumTurns)
}
}
Loading
Loading