Skip to content

Commit 00669cc

Browse files
authored
feat: side-call context visibility (T3) — plan titles + side_call_usage events (#243)
* feat: side-call context visibility — plan titles in summarizer payloads + side_call_usage events (T3) B3: sideCallPlanPrefix now includes step titles (s2=in_progress - Title). The old IDs-only rationale was wrong for compaction/budget payloads: after a trim those payloads may be the only surviving context, and bare ids tell the summarizer nothing. Titles are model-authored, stored normalized, and already on the main transcript — no new exposure. Notes remain excluded. Measurement: recordSideCallUsage(kind, res) emits a side_call_usage runtime event (typed constant, docs/EXTENSIONS.md updated) plus an internal signal, so --events-jsonl can quantify compaction vs progress_summary vs interrupted_partial token cost. Pinned RED-first; loop_trim_test.go legacy pins updated. internal/loop, internal/events, full cmd/odek green under -race. * fix: main_partial kind + title separator hygiene (review round)
1 parent 53c52e7 commit 00669cc

6 files changed

Lines changed: 190 additions & 24 deletions

File tree

‎docs/EXTENSIONS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,8 @@ odek can emit a structured runtime event stream: **one JSON object per line
165165
`tool_call_started`, `tool_call_completed`, `tool_call_failed`,
166166
`session_saved`, `context_trimmed`, `budget_exceeded`, `run_completed`,
167167
`run_failed`, `plan_created`, `plan_updated`, `plan_blocked`, `subagent_denied`,
168-
`subagent_spawned`, `subagent_completed`, `subagent_concurrency_wait`.
168+
`subagent_spawned`, `subagent_completed`, `subagent_concurrency_wait`,
169+
`side_call_usage`.
169170
- `run_id` is a random 128-bit hex identifier generated per agent run and
170171
stamped on every event of that run. `session_id` appears once the session
171172
is known; earlier events omit it. `iteration` is the 1-based loop
@@ -190,6 +191,7 @@ Per-type `data` fields:
190191
| `tool_call_failed` | `call_id`, `duration_ms`, `error_class` |
191192
| `session_saved` | `message_count` |
192193
| `context_trimmed` | `mode` (`proactive`/`survival`), `dropped_groups`, `truncated_results` |
194+
| `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) |
193195
| `budget_exceeded` | `limit_name` (`runtime`/`tool_calls`/`input_tokens`/`output_tokens`/`cost_usd`), `observed`, `limit` |
194196
| `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) |
195197
| `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 |

‎internal/events/events.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const (
4646
TypePlanBlocked = "plan_blocked"
4747
TypeSubagentSpawned = "subagent_spawned"
4848
TypeSubagentCompleted = "subagent_completed"
49+
TypeSideCallUsage = "side_call_usage"
4950
)
5051

5152
// Budget limit names carried in budget_exceeded events (data.limit_name).

‎internal/loop/loop.go‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1738,7 +1738,7 @@ func (e *Engine) applyPendingDigest(ctx context.Context, messages []session.Mess
17381738
e.pendingDroppedCovered = 0
17391739
e.compactMu.Unlock()
17401740
if usage != nil {
1741-
e.recordSideCallUsage(usage)
1741+
e.recordSideCallUsage("compaction", usage)
17421742
}
17431743
if summary == "" {
17441744
return messages
@@ -1893,7 +1893,7 @@ func (e *Engine) protectDerivedContext(ctx context.Context, source, content stri
18931893
func (e *Engine) summarizeDropped(ctx context.Context, dropped []session.Message) string {
18941894
summary, usage := e.summarizeDroppedWithUsage(ctx, dropped, e.compactDigest)
18951895
if usage != nil {
1896-
e.recordSideCallUsage(usage)
1896+
e.recordSideCallUsage("compaction", usage)
18971897
}
18981898
return summary
18991899
}
@@ -1957,9 +1957,12 @@ func (e *Engine) summarizeDroppedWithUsage(ctx context.Context, dropped []sessio
19571957
return strings.TrimSpace(res.Content), res
19581958
}
19591959

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

19781981
// ── Protected plan message (digest-pattern integration) ───────────────
@@ -2143,10 +2146,10 @@ func (e *Engine) summarizeProgress(ctx context.Context, messages []session.Messa
21432146
// calls is not a summary (its content is pre-tool chatter), so treat it
21442147
// as a failure and keep the original error path.
21452148
if len(res.ToolCalls) > 0 || (res.Termination != "" && res.Termination != llmclient.TerminationComplete) {
2146-
e.recordSideCallUsage(res)
2149+
e.recordSideCallUsage("progress_summary", res)
21472150
return ""
21482151
}
2149-
e.recordSideCallUsage(res)
2152+
e.recordSideCallUsage("progress_summary", res)
21502153
return strings.TrimSpace(res.Content)
21512154
}
21522155

@@ -2198,21 +2201,41 @@ func (e *Engine) budgetExceeded(ctx context.Context, messages []session.Message,
21982201
// per-run totals. Totals feed budget enforcement (max_input_tokens /
21992202
// max_output_tokens / cost caps) and usage reporting; a side call invisible
22002203
// to them silently exceeds the caps and under-reports consumption.
2201-
func (e *Engine) recordSideCallUsage(res *llmclient.CallResult) {
2204+
// recordSideCallUsage merges a compaction/progress-summary side call's
2205+
// tokens into the run totals and emits a side_call_usage signal so run
2206+
// events (--events-jsonl) and /api/usage consumers can quantify the
2207+
// side-call cost separately from the main conversation.
2208+
func (e *Engine) recordSideCallUsage(kind string, res *llmclient.CallResult) {
22022209
if res == nil {
22032210
return
22042211
}
22052212
e.externalChargeMu.Lock()
2206-
defer e.externalChargeMu.Unlock()
22072213
e.TotalInputTokens += res.InputTokens
22082214
e.TotalOutputTokens += res.OutputTokens
22092215
e.TotalCacheCreationTokens += res.CacheCreationTokens
22102216
e.TotalCacheReadTokens += res.CacheReadTokens
22112217
e.TotalCachedTokens += res.CachedTokens
22122218
e.TotalCacheReported = e.TotalCacheReported || res.CacheReported
2213-
// Note: side calls (compaction/budget summaries) deliberately do NOT
2214-
// update lastPromptTokens — their prompts are tiny {system, snippet},
2215-
// not the conversation window.
2219+
e.externalChargeMu.Unlock()
2220+
// Note: side calls deliberately do NOT update lastPromptTokens — their
2221+
// prompts are tiny {system, snippet}, not the conversation window.
2222+
e.emitSignal(SignalEvent{
2223+
Type: "side_call_usage",
2224+
Tool: kind,
2225+
Detail: fmt.Sprintf("in=%d out=%d cache_read=%d cache_create=%d",
2226+
res.InputTokens, res.OutputTokens, res.CacheReadTokens, res.CacheCreationTokens),
2227+
})
2228+
e.emitEvent(events.Event{
2229+
Type: events.TypeSideCallUsage,
2230+
Tool: kind,
2231+
Data: map[string]any{
2232+
"kind": kind,
2233+
"input_tokens": res.InputTokens,
2234+
"output_tokens": res.OutputTokens,
2235+
"cache_read": res.CacheReadTokens,
2236+
"cache_create": res.CacheCreationTokens,
2237+
},
2238+
})
22162239
}
22172240

22182241
// promptWindowTokens normalizes a call's provider-reported usage into the
@@ -2773,7 +2796,7 @@ func (e *Engine) runLoop(ctx context.Context, in []session.Message) (answer stri
27732796
result, err := e.callLLM(ctx, messages, tools)
27742797
if err != nil {
27752798
if result != nil {
2776-
e.recordSideCallUsage(result)
2799+
e.recordSideCallUsage("main_partial", result)
27772800
if result.Content != "" && !isContextLengthError(err) {
27782801
partial := "[Partial response: interrupted]\n\n" + result.Content
27792802
messages = append(messages, session.Message{Role: "assistant", Content: partial, ReasoningContent: result.ReasoningContent})

‎internal/loop/loop_trim_test.go‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -832,7 +832,7 @@ func TestCompactionSystemPrompt_SkeletonAndIPI(t *testing.T) {
832832
}
833833
}
834834

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

862-
func TestExtractiveDigest_IncludesRemainingPlanIDsNotTitles(t *testing.T) {
867+
func TestExtractiveDigest_IncludesRemainingPlanIDsAndTitles(t *testing.T) {
863868
store := NewPlanStore(12, 2000)
864869
seedPlanMessage(t, store)
865870
engine := &Engine{planStore: store}
866871
got := engine.extractiveDigest([]session.Message{{Role: "assistant", Content: "old work"}})
867872
if !strings.Contains(got, "s2=in_progress") || !strings.Contains(got, "s3=pending") {
868873
t.Errorf("remaining plan ids missing from extractive digest: %.400s", got)
869874
}
870-
if strings.Contains(got, secretPlanTitle) || strings.Contains(got, secretPlanNote) {
871-
t.Errorf("plan titles/notes leaked into extractive digest: %.400s", got)
875+
if !strings.Contains(got, secretPlanTitle) {
876+
t.Errorf("remaining plan titles missing from extractive digest: %.400s", got)
877+
}
878+
if strings.Contains(got, secretPlanNote) {
879+
t.Errorf("plan notes leaked into extractive digest: %.400s", got)
872880
}
873881
if !strings.Contains(got, "old work") {
874882
t.Errorf("dropped content missing from extractive digest: %.400s", got)
@@ -900,8 +908,8 @@ func TestSummarizeProgress_IncludesRemainingPlanIDs(t *testing.T) {
900908
if !strings.Contains(body, "s2=in_progress") {
901909
t.Errorf("remaining plan ids missing from progress summarizer input: %.400s", body)
902910
}
903-
if strings.Contains(body, secretPlanTitle) {
904-
t.Errorf("plan title leaked into progress summarizer input: %.400s", body)
911+
if !strings.Contains(body, secretPlanTitle) {
912+
t.Errorf("remaining plan titles missing from progress summarizer input: %.400s", body)
905913
}
906914
}
907915

‎internal/loop/plan.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,31 @@ func formatRemainingPlanSteps(state PlanState) string {
507507
return strings.Join(parts, " ")
508508
}
509509

510+
// formatRemainingPlanStepsDetailed is the side-call variant: it appends the
511+
// normalized step title ("s1=pending - Ship the parser"). Titles are
512+
// model-authored, already on the main transcript, and stored normalized
513+
// (newlines/em dashes flattened at create), so the grammar stays one step
514+
// per token. The summarizer needs them — its payload may be the only
515+
// context where the plan survives after a trim.
516+
func formatRemainingPlanStepsDetailed(state PlanState) string {
517+
var parts []string
518+
for _, st := range state.Steps {
519+
if st.Status == StepDone {
520+
continue
521+
}
522+
if st.Title != "" {
523+
// ';' is the list separator on this surface — strip it from titles
524+
// (nothing machine-parses the list, but unambiguous tokens keep
525+
// the summarizer from seeing phantom steps).
526+
title := strings.ReplaceAll(st.Title, ";", ",")
527+
parts = append(parts, st.ID+"="+string(st.Status)+" - "+title)
528+
} else {
529+
parts = append(parts, st.ID+"="+string(st.Status))
530+
}
531+
}
532+
return strings.Join(parts, "; ")
533+
}
534+
510535
// normalizePlanText flattens text so the rendered line grammar stays
511536
// unambiguous: newlines become spaces (one step = one line) and em dashes
512537
// become hyphens (the renderer reserves " — " as the title/note separator).
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package loop
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/BackendStack21/odek/internal/session"
12+
"github.com/BackendStack21/odek/internal/tool"
13+
)
14+
15+
// ── B3: side-call plan prefix carries step TITLES ────────────────────────
16+
//
17+
// sideCallPlanPrefix feeds the compaction digest and budget-summary
18+
// payloads — contexts where the wrapped plan message itself may be the
19+
// dropped content. "s1=pending" without a title tells the summarizer
20+
// nothing about what remains. Titles are model-authored and already on
21+
// the main transcript, so including them adds no new exposure. (The
22+
// anti-title pin applies to stall HINTS — a different path, unchanged.)
23+
24+
func TestSideCallPlanPrefix_IncludesTitles(t *testing.T) {
25+
engine := New(testChatClient(t, newIdleServer()), tool.NewRegistry(nil), 10, "sys", nil, 0)
26+
store := NewPlanStore(0, 0)
27+
if _, err := store.Execute(`{"verb":"create","steps":[` +
28+
`{"id":"s1","title":"Ship the parser"},` +
29+
`{"id":"s2","title":"Write the docs"}]}`); err != nil {
30+
t.Fatalf("plan create: %v", err)
31+
}
32+
engine.planStore = store
33+
34+
prefix := engine.sideCallPlanPrefix()
35+
if prefix == "" {
36+
t.Fatal("sideCallPlanPrefix = empty, want remaining steps")
37+
}
38+
if !strings.Contains(prefix, "s1=pending") {
39+
t.Errorf("prefix lost the id=status contract: %q", prefix)
40+
}
41+
if !strings.Contains(prefix, "Ship the parser") || !strings.Contains(prefix, "Write the docs") {
42+
t.Errorf("prefix must include step titles (the summarizer cannot know what 's1' means): %q", prefix)
43+
}
44+
}
45+
46+
// ── Measurement: side-call usage is observable ───────────────────────────
47+
//
48+
// recordSideCallUsage merges compaction/summary tokens into the run totals
49+
// with no separate accounting, so the side-call cost is invisible to
50+
// /api/usage and events. Every side call must emit a side_call_usage
51+
// signal carrying its token split so run events (--events-jsonl) can
52+
// quantify it.
53+
54+
func TestSideCallUsageSignal_EmittedOnCompaction(t *testing.T) {
55+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+
fmt.Fprint(w, `{"choices":[{"message":{"content":"summary"}}],"usage":{"prompt_tokens":120,"completion_tokens":15}}`)
57+
}))
58+
defer server.Close()
59+
60+
client := testChatClient(t, server.URL)
61+
engine := New(client, tool.NewRegistry(nil), 10, "sys", nil, 0)
62+
engine.SetCompaction(true)
63+
64+
var usageSignals []SignalEvent
65+
engine.SetSignalHandler(func(ev SignalEvent) {
66+
if ev.Type == "side_call_usage" {
67+
usageSignals = append(usageSignals, ev)
68+
}
69+
})
70+
71+
msgs := []session.Message{
72+
{Role: "system", Content: "sys"},
73+
{Role: "user", Content: "task"},
74+
}
75+
dropped := []session.Message{{Role: "tool", Content: "dropped output"}}
76+
out := engine.refreshDigest(context.Background(), msgs, dropped)
77+
engine.waitDigestSideCall(context.Background())
78+
engine.applyPendingDigest(context.Background(), out)
79+
_ = out
80+
81+
if len(usageSignals) == 0 {
82+
t.Fatal("no side_call_usage signal after compaction side call — side-call cost is unobservable")
83+
}
84+
ev := usageSignals[0]
85+
if ev.Tool != "compaction" {
86+
t.Errorf("side_call_usage Tool = %q, want kind %q", ev.Tool, "compaction")
87+
}
88+
if !strings.Contains(ev.Detail, "in=") || !strings.Contains(ev.Detail, "out=") {
89+
t.Errorf("side_call_usage Detail must carry the in/out token split: %q", ev.Detail)
90+
}
91+
}
92+
93+
func TestRecordSideCallUsage_NilResultNoSignal(t *testing.T) {
94+
engine := New(testChatClient(t, newIdleServer()), tool.NewRegistry(nil), 10, "sys", nil, 0)
95+
var n int
96+
engine.SetSignalHandler(func(ev SignalEvent) {
97+
if ev.Type == "side_call_usage" {
98+
n++
99+
}
100+
})
101+
engine.recordSideCallUsage("compaction", nil)
102+
if n != 0 {
103+
t.Errorf("nil result must not emit a signal, got %d", n)
104+
}
105+
}
106+
107+
var _ = session.Message{}

0 commit comments

Comments
 (0)