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
4 changes: 3 additions & 1 deletion docs/EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,8 @@ odek can emit a structured runtime event stream: **one JSON object per line
`tool_call_started`, `tool_call_completed`, `tool_call_failed`,
`session_saved`, `context_trimmed`, `budget_exceeded`, `run_completed`,
`run_failed`, `plan_created`, `plan_updated`, `plan_blocked`, `subagent_denied`,
`subagent_spawned`, `subagent_completed`, `subagent_concurrency_wait`.
`subagent_spawned`, `subagent_completed`, `subagent_concurrency_wait`,
`side_call_usage`.
- `run_id` is a random 128-bit hex identifier generated per agent run and
stamped on every event of that run. `session_id` appears once the session
is known; earlier events omit it. `iteration` is the 1-based loop
Expand All @@ -190,6 +191,7 @@ Per-type `data` fields:
| `tool_call_failed` | `call_id`, `duration_ms`, `error_class` |
| `session_saved` | `message_count` |
| `context_trimmed` | `mode` (`proactive`/`survival`), `dropped_groups`, `truncated_results` |
| `side_call_usage` | `kind` (`compaction`/`progress_summary`/`main_partial`), `input_tokens`, `output_tokens`, `cache_read`, `cache_create` — `compaction`/`progress_summary` are side-call cost emitted separately from main-path token accounting; `main_partial` covers a failed main-path call's partial tokens (pre-existing charging, now observable) |
| `budget_exceeded` | `limit_name` (`runtime`/`tool_calls`/`input_tokens`/`output_tokens`/`cost_usd`), `observed`, `limit` |
| `run_completed` | `duration_ms` (run wall clock, tools included), `input_tokens`, `output_tokens` (run totals), `llm_duration_ms` (sum of main think-step LLM calls), `tokens_per_second` (think-step output / `llm_duration_ms`; omitted when unknown) |
| `run_failed` | `duration_ms`, `error_class`, plus the same `llm_duration_ms` / `tokens_per_second` as `run_completed` when at least one think step was measured before the failure |
Expand Down
1 change: 1 addition & 0 deletions internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const (
TypePlanBlocked = "plan_blocked"
TypeSubagentSpawned = "subagent_spawned"
TypeSubagentCompleted = "subagent_completed"
TypeSideCallUsage = "side_call_usage"
)

// Budget limit names carried in budget_exceeded events (data.limit_name).
Expand Down
53 changes: 38 additions & 15 deletions internal/loop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,7 @@
e.pendingDroppedCovered = 0
e.compactMu.Unlock()
if usage != nil {
e.recordSideCallUsage(usage)
e.recordSideCallUsage("compaction", usage)
}
if summary == "" {
return messages
Expand Down Expand Up @@ -1893,7 +1893,7 @@
func (e *Engine) summarizeDropped(ctx context.Context, dropped []session.Message) string {
summary, usage := e.summarizeDroppedWithUsage(ctx, dropped, e.compactDigest)
if usage != nil {
e.recordSideCallUsage(usage)
e.recordSideCallUsage("compaction", usage)
}
return summary
}
Expand Down Expand Up @@ -1957,9 +1957,12 @@
return strings.TrimSpace(res.Content), res
}

// sideCallPlanPrefix prepends remaining plan step IDs and statuses to a
// compaction or progress-summary user payload. Titles and notes stay out —
// they already live in the wrapped plan message on the main transcript.
// sideCallPlanPrefix prepends remaining plan steps to a compaction or
// progress-summary user payload, WITH titles: these payloads may be the
// only surviving context after a trim (the wrapped plan message itself can
// be dropped), so bare ids tell the summarizer nothing. Titles are
// model-authored and stored normalized — no new exposure. (Stall hints
// keep the title-free format; that pin is unchanged.)
func (e *Engine) sideCallPlanPrefix() string {
if e == nil || e.planStore == nil {
return ""
Expand All @@ -1968,11 +1971,11 @@
if !ok {
return ""
}
ids := formatRemainingPlanSteps(state)
ids := formatRemainingPlanStepsDetailed(state)
if ids == "" {
return ""
}
return "Remaining plan steps (ids and statuses only): " + ids + "\n\n"
return "Remaining plan steps: " + ids + "\n\n"
}

// ── Protected plan message (digest-pattern integration) ───────────────
Expand Down Expand Up @@ -2143,10 +2146,10 @@
// calls is not a summary (its content is pre-tool chatter), so treat it
// as a failure and keep the original error path.
if len(res.ToolCalls) > 0 || (res.Termination != "" && res.Termination != llmclient.TerminationComplete) {
e.recordSideCallUsage(res)
e.recordSideCallUsage("progress_summary", res)
return ""
}
e.recordSideCallUsage(res)
e.recordSideCallUsage("progress_summary", res)
return strings.TrimSpace(res.Content)
}

Expand Down Expand Up @@ -2198,21 +2201,41 @@
// per-run totals. Totals feed budget enforcement (max_input_tokens /
// max_output_tokens / cost caps) and usage reporting; a side call invisible
// to them silently exceeds the caps and under-reports consumption.
func (e *Engine) recordSideCallUsage(res *llmclient.CallResult) {
// recordSideCallUsage merges a compaction/progress-summary side call's
// tokens into the run totals and emits a side_call_usage signal so run
// events (--events-jsonl) and /api/usage consumers can quantify the
// side-call cost separately from the main conversation.
func (e *Engine) recordSideCallUsage(kind string, res *llmclient.CallResult) {
if res == nil {
return
}
e.externalChargeMu.Lock()
defer e.externalChargeMu.Unlock()
e.TotalInputTokens += res.InputTokens
e.TotalOutputTokens += res.OutputTokens
e.TotalCacheCreationTokens += res.CacheCreationTokens
e.TotalCacheReadTokens += res.CacheReadTokens
e.TotalCachedTokens += res.CachedTokens
e.TotalCacheReported = e.TotalCacheReported || res.CacheReported
// Note: side calls (compaction/budget summaries) deliberately do NOT
// update lastPromptTokens — their prompts are tiny {system, snippet},
// not the conversation window.
e.externalChargeMu.Unlock()
// Note: side calls deliberately do NOT update lastPromptTokens — their
// prompts are tiny {system, snippet}, not the conversation window.
e.emitSignal(SignalEvent{
Type: "side_call_usage",
Tool: kind,
Detail: fmt.Sprintf("in=%d out=%d cache_read=%d cache_create=%d",
res.InputTokens, res.OutputTokens, res.CacheReadTokens, res.CacheCreationTokens),
})
e.emitEvent(events.Event{
Type: events.TypeSideCallUsage,
Tool: kind,
Data: map[string]any{
"kind": kind,
"input_tokens": res.InputTokens,
"output_tokens": res.OutputTokens,
"cache_read": res.CacheReadTokens,
"cache_create": res.CacheCreationTokens,
},
})
}

// promptWindowTokens normalizes a call's provider-reported usage into the
Expand Down Expand Up @@ -2773,7 +2796,7 @@
result, err := e.callLLM(ctx, messages, tools)
if err != nil {
if result != nil {
e.recordSideCallUsage(result)
e.recordSideCallUsage("main_partial", result)
if result.Content != "" && !isContextLengthError(err) {
partial := "[Partial response: interrupted]\n\n" + result.Content
messages = append(messages, session.Message{Role: "assistant", Content: partial, ReasoningContent: result.ReasoningContent})
Expand Down Expand Up @@ -3859,7 +3882,7 @@
}

// isPow2 reports whether n is a positive power of two.
func isPow2(n int) bool {

Check failure on line 3885 in internal/loop/loop.go

View workflow job for this annotation

GitHub Actions / lint

func isPow2 is unused (unused)
return n > 0 && n&(n-1) == 0
}

Expand Down
24 changes: 16 additions & 8 deletions internal/loop/loop_trim_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ func TestCompactionSystemPrompt_SkeletonAndIPI(t *testing.T) {
}
}

func TestSummarizeDropped_IncludesRemainingPlanIDsNotTitles(t *testing.T) {
func TestSummarizeDropped_IncludesRemainingPlanIDsAndTitles(t *testing.T) {
var bodies []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, _ := io.ReadAll(r.Body)
Expand All @@ -854,21 +854,29 @@ func TestSummarizeDropped_IncludesRemainingPlanIDsNotTitles(t *testing.T) {
if !strings.Contains(body, "s2=in_progress") || !strings.Contains(body, "s3=pending") {
t.Errorf("remaining plan ids missing from summarizer input: %.400s", body)
}
if strings.Contains(body, secretPlanTitle) || strings.Contains(body, secretPlanNote) {
t.Errorf("plan titles/notes leaked into summarizer input: %.400s", body)
// Titles are model-authored context the summarizer needs (the plan
// message itself may be the dropped content). Notes stay out.
if !strings.Contains(body, secretPlanTitle) {
t.Errorf("remaining plan titles missing from summarizer input: %.400s", body)
}
if strings.Contains(body, secretPlanNote) {
t.Errorf("plan notes leaked into summarizer input: %.400s", body)
}
}

func TestExtractiveDigest_IncludesRemainingPlanIDsNotTitles(t *testing.T) {
func TestExtractiveDigest_IncludesRemainingPlanIDsAndTitles(t *testing.T) {
store := NewPlanStore(12, 2000)
seedPlanMessage(t, store)
engine := &Engine{planStore: store}
got := engine.extractiveDigest([]session.Message{{Role: "assistant", Content: "old work"}})
if !strings.Contains(got, "s2=in_progress") || !strings.Contains(got, "s3=pending") {
t.Errorf("remaining plan ids missing from extractive digest: %.400s", got)
}
if strings.Contains(got, secretPlanTitle) || strings.Contains(got, secretPlanNote) {
t.Errorf("plan titles/notes leaked into extractive digest: %.400s", got)
if !strings.Contains(got, secretPlanTitle) {
t.Errorf("remaining plan titles missing from extractive digest: %.400s", got)
}
if strings.Contains(got, secretPlanNote) {
t.Errorf("plan notes leaked into extractive digest: %.400s", got)
}
if !strings.Contains(got, "old work") {
t.Errorf("dropped content missing from extractive digest: %.400s", got)
Expand Down Expand Up @@ -900,8 +908,8 @@ func TestSummarizeProgress_IncludesRemainingPlanIDs(t *testing.T) {
if !strings.Contains(body, "s2=in_progress") {
t.Errorf("remaining plan ids missing from progress summarizer input: %.400s", body)
}
if strings.Contains(body, secretPlanTitle) {
t.Errorf("plan title leaked into progress summarizer input: %.400s", body)
if !strings.Contains(body, secretPlanTitle) {
t.Errorf("remaining plan titles missing from progress summarizer input: %.400s", body)
}
}

Expand Down
25 changes: 25 additions & 0 deletions internal/loop/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,31 @@ func formatRemainingPlanSteps(state PlanState) string {
return strings.Join(parts, " ")
}

// formatRemainingPlanStepsDetailed is the side-call variant: it appends the
// normalized step title ("s1=pending - Ship the parser"). Titles are
// model-authored, already on the main transcript, and stored normalized
// (newlines/em dashes flattened at create), so the grammar stays one step
// per token. The summarizer needs them — its payload may be the only
// context where the plan survives after a trim.
func formatRemainingPlanStepsDetailed(state PlanState) string {
var parts []string
for _, st := range state.Steps {
if st.Status == StepDone {
continue
}
if st.Title != "" {
// ';' is the list separator on this surface — strip it from titles
// (nothing machine-parses the list, but unambiguous tokens keep
// the summarizer from seeing phantom steps).
title := strings.ReplaceAll(st.Title, ";", ",")
parts = append(parts, st.ID+"="+string(st.Status)+" - "+title)
} else {
parts = append(parts, st.ID+"="+string(st.Status))
}
}
return strings.Join(parts, "; ")
}

// normalizePlanText flattens text so the rendered line grammar stays
// unambiguous: newlines become spaces (one step = one line) and em dashes
// become hyphens (the renderer reserves " — " as the title/note separator).
Expand Down
107 changes: 107 additions & 0 deletions internal/loop/sidecall_visibility_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package loop

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/BackendStack21/odek/internal/session"
"github.com/BackendStack21/odek/internal/tool"
)

// ── B3: side-call plan prefix carries step TITLES ────────────────────────
//
// sideCallPlanPrefix feeds the compaction digest and budget-summary
// payloads — contexts where the wrapped plan message itself may be the
// dropped content. "s1=pending" without a title tells the summarizer
// nothing about what remains. Titles are model-authored and already on
// the main transcript, so including them adds no new exposure. (The
// anti-title pin applies to stall HINTS — a different path, unchanged.)

func TestSideCallPlanPrefix_IncludesTitles(t *testing.T) {
engine := New(testChatClient(t, newIdleServer()), tool.NewRegistry(nil), 10, "sys", nil, 0)
store := NewPlanStore(0, 0)
if _, err := store.Execute(`{"verb":"create","steps":[` +
`{"id":"s1","title":"Ship the parser"},` +
`{"id":"s2","title":"Write the docs"}]}`); err != nil {
t.Fatalf("plan create: %v", err)
}
engine.planStore = store

prefix := engine.sideCallPlanPrefix()
if prefix == "" {
t.Fatal("sideCallPlanPrefix = empty, want remaining steps")
}
if !strings.Contains(prefix, "s1=pending") {
t.Errorf("prefix lost the id=status contract: %q", prefix)
}
if !strings.Contains(prefix, "Ship the parser") || !strings.Contains(prefix, "Write the docs") {
t.Errorf("prefix must include step titles (the summarizer cannot know what 's1' means): %q", prefix)
}
}

// ── Measurement: side-call usage is observable ───────────────────────────
//
// recordSideCallUsage merges compaction/summary tokens into the run totals
// with no separate accounting, so the side-call cost is invisible to
// /api/usage and events. Every side call must emit a side_call_usage
// signal carrying its token split so run events (--events-jsonl) can
// quantify it.

func TestSideCallUsageSignal_EmittedOnCompaction(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"choices":[{"message":{"content":"summary"}}],"usage":{"prompt_tokens":120,"completion_tokens":15}}`)
}))
defer server.Close()

client := testChatClient(t, server.URL)
engine := New(client, tool.NewRegistry(nil), 10, "sys", nil, 0)
engine.SetCompaction(true)

var usageSignals []SignalEvent
engine.SetSignalHandler(func(ev SignalEvent) {
if ev.Type == "side_call_usage" {
usageSignals = append(usageSignals, ev)
}
})

msgs := []session.Message{
{Role: "system", Content: "sys"},
{Role: "user", Content: "task"},
}
dropped := []session.Message{{Role: "tool", Content: "dropped output"}}
out := engine.refreshDigest(context.Background(), msgs, dropped)
engine.waitDigestSideCall(context.Background())
engine.applyPendingDigest(context.Background(), out)
_ = out

if len(usageSignals) == 0 {
t.Fatal("no side_call_usage signal after compaction side call — side-call cost is unobservable")
}
ev := usageSignals[0]
if ev.Tool != "compaction" {
t.Errorf("side_call_usage Tool = %q, want kind %q", ev.Tool, "compaction")
}
if !strings.Contains(ev.Detail, "in=") || !strings.Contains(ev.Detail, "out=") {
t.Errorf("side_call_usage Detail must carry the in/out token split: %q", ev.Detail)
}
}

func TestRecordSideCallUsage_NilResultNoSignal(t *testing.T) {
engine := New(testChatClient(t, newIdleServer()), tool.NewRegistry(nil), 10, "sys", nil, 0)
var n int
engine.SetSignalHandler(func(ev SignalEvent) {
if ev.Type == "side_call_usage" {
n++
}
})
engine.recordSideCallUsage("compaction", nil)
if n != 0 {
t.Errorf("nil result must not emit a signal, got %d", n)
}
}

var _ = session.Message{}
Loading