wcode-harness 0.13.1

Minimal, extensible agent harness kernel: event loop, tools, hooks, JSONL sessions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! Session titles (W031): a bounded, one-shot titler and its sanitiser.
//!
//! A session's **title** is the semantic name a human finds it by — a short
//! phrase ("fix flaky retry backoff") that beats the opaque `{millis}_{uuid8}`
//! file id and the first user line. It lives in the transcript, not the file
//! name: [`crate::session::SessionEntry::Title`], last write wins, read back by
//! [`crate::session::Session::title`].
//!
//! # Who calls this module (and who reads it)
//!
//! * [`crate::loop_`] — the auto-trigger (W031 **D1**, block-inline): at the turn
//!   boundary immediately after `AgentEvent::TurnEnd` is sent, when the session
//!   has no `Title` entry yet and `title_after_turns` assistant turns exist
//!   (**D2**), and at most once per run (the latch). It appends the entry through
//!   its own `&mut Session` and streams `AgentEvent::SessionTitle`.
//! * The CLI's `config.rs` reads [`DEFAULT_TITLE_AFTER_TURNS`] to resolve
//!   `[session] title_after_turns`, and `main.rs` reads
//!   [`COMPACTION_SUMMARY_PREFIX`] (re-exported from `message`) to keep the
//!   `--resume` picker from printing a compaction summary as a first user line.
//! * NOTHING calls [`generate`] to rename explicitly: `/rename` and `--title` go
//!   straight to `Agent::set_title` and never touch a model.
//!
//! So this module is a public surface with two external readers plus the loop —
//! it is not private to the loop.

use futures::StreamExt as _;

use crate::event::LlmStreamEvent;
use crate::message::{AgentMessage, ContentBlock};
use crate::streamfn::{LlmOpts, StreamFn};

/// Auto-title once this many assistant turns exist (**W031 D2**). `0` = never
/// auto-title. The CLI's `[session] title_after_turns` / `WCODE_TITLE_AFTER_TURNS`
/// override it; absent config resolves to this.
pub const DEFAULT_TITLE_AFTER_TURNS: usize = 2;

/// Hard cap on a produced title, in **chars** (not bytes). A title is a name, so
/// it is a hard cut with no ellipsis — see [`sanitize`].
pub const TITLE_CLAMP: usize = 60;

/// Per-half cap on the input excerpt (chars). §5.2: ~600 chars each ≈ 400 input
/// tokens, ~15 out, requested ONCE per session. PRIVATE: this is an
/// implementation detail of [`excerpt`]; nothing outside the module reads it.
/// (A `pub` const here would invite a caller to "configure" the titler's input,
/// which is exactly the unbounded-cost path §5.2 rejects.)
const TITLE_INPUT_CHARS: usize = 600;

/// The prefix [`AgentMessage::summary`] stamps on a compaction summary.
///
/// RE-EXPORTED, not owned: it lives in `crates/wcode-harness/src/message.rs` as
/// `pub const COMPACTION_SUMMARY_PREFIX`, used by `AgentMessage::summary`'s own
/// `format!` — one literal, one source. A summary is an ordinary `User` message
/// with no distinguishing field, so the prefix IS the discriminator, and a second
/// copy here would drift from the string the parser matches on.
///
/// Consumers: [`excerpt`] below (a resumed `ctx` starts with a summary —
/// `Session::messages` prepends one) and the CLI's picker (`main.rs`,
/// `first_user_line_of`). See W031 §2: without the skip, every compacted
/// session's picker row reads "Summary of the earlier conversation
/// (auto-generated by compaction):".
pub use crate::message::COMPACTION_SUMMARY_PREFIX;

/// System prompt for the titler. An **instruction-following** probe (§9.1): the
/// live check must run against the configured model, not a small local one, or a
/// failure here is indistinguishable from a bug in [`sanitize`].
const TITLE_PROMPT: &str = "\
You are naming a coding-agent session so its owner can find it again in a list of \
sessions. Reply with the name only: 3-6 words, no quotes, no trailing period, no \
'Title:' prefix, nothing else. Name what the session is ABOUT (its subject or \
goal), not what was said most recently. Prefer concrete nouns and file or \
component names that appear in the text.";

/// Ask the model for a session title and return the sanitized result.
///
/// The single model call is shaped exactly like
/// [`crate::compaction::summarize`] (`crates/wcode-harness/src/compaction.rs:628`):
/// one `stream_fn` invocation with an EMPTY tool list, the [`TITLE_PROMPT`] as
/// `system`, and one `AgentMessage::user_text` carrying both halves; then a fold
/// of `LlmStreamEvent::TextDelta` → `String` until `Done` (or `Error`).
///
/// # Contract
///
/// * **Input.** The first user text and the first assistant text in `ctx`, each
///   clamped to [`TITLE_INPUT_CHARS`], rendered as
///   `USER:\n<…>\n\nASSISTANT:\n<…>` in ONE user message — the two halves the
///   test name `generate_collects_text_and_sends_both_halves` asserts on.
///   `ctx[0]` is SKIPPED when it is a compaction summary (it starts with
///   [`COMPACTION_SUMMARY_PREFIX`]); see `Session::messages`
///   (`crates/wcode-harness/src/session.rs:186-188`) which prepends one on a
///   resume. Half-missing is not an error: a request with only the user half (an
///   assistant turn that produced no text) still gets asked.
/// * **Output.** `Ok(title)` where `title` is the output of [`sanitize`] — never
///   empty, never longer than [`TITLE_CLAMP`] chars.
/// * **Empty answer** (no `TextDelta` at all, only `Done`, or deltas that
///   `sanitize` rejects): `Err("titler produced no text")`. Mirrors
///   `summarize`'s `Err("summarizer produced no text")`
///   (`compaction.rs:660-662`).
/// * **Stream error** (`LlmStreamEvent::Error { message, .. }`): `Err(message)`
///   verbatim, first error wins, the fold stops. Mirrors `compaction.rs:654`.
/// * **No input at all** (no user text and no assistant text): `Err("no input to
///   title from")` WITHOUT a model call — a request that cannot help is not sent.
/// * **Does NOT promise:** a retry of its own (it inherits the adapter's
///   retry/idle policy exactly as `summarize` does), a usage report (the caller
///   does not bill it), a cancel path (v1: the call is bounded by the adapter's
///   idle backstop, and the auto-trigger waits for it — D1's accepted cost), or
///   that the model follows [`TITLE_PROMPT`] — that is [`sanitize`]'s job.
/// * **Never panics.**
pub async fn generate(
    stream_fn: &StreamFn,
    opts: &LlmOpts,
    ctx: &[AgentMessage],
) -> Result<String, String> {
    let body = excerpt(ctx);
    if body.is_empty() {
        return Err("no input to title from".to_string());
    }

    let mut stream = stream_fn(&[AgentMessage::user_text(body)], TITLE_PROMPT, &[], opts);
    let mut text = String::new();
    while let Some(event) = stream.next().await {
        match event {
            LlmStreamEvent::TextDelta(delta) => text.push_str(&delta),
            LlmStreamEvent::Done { .. } => break,
            LlmStreamEvent::Error { message, .. } => return Err(message),
            _ => {}
        }
    }

    sanitize(&text).ok_or_else(|| "titler produced no text".to_string())
}

/// Reduce a raw model answer to a storable title, or reject it.
///
/// Pure, total, and network-free, so it is the part of this module that a unit
/// test can pin exactly.
///
/// # The rules, in order (each one is load-bearing; the order is the contract)
///
/// 1. **Trim** leading/trailing whitespace.
/// 2. **First line only** — everything from the first `\n` (or `\r`) on is
///    dropped, so a chatty answer ("Sure!\n\nSession title: …") yields its first
///    line, not a paragraph.
/// 3. **Strip quotes and backticks** — remove every `"`, `'` and `` ` `` from
///    what remains (altogether, not just at the ends: a model often returns
///    `` `w031 session titles` ``). Interior apostrophes therefore go too; a
///    title is not prose and "don't" reads fine as "dont" in a one-line label.
/// 4. **Collapse whitespace** — every run of spaces/tabs becomes one space, so a
///    tab-separated answer cannot break the single-line picker row.
/// 5. **Trim again** (step 3/4 can expose new edge whitespace).
/// 6. **Reject empty** — `None`, never `Some("")`. This is the ONE place an
///    unusable answer is distinguished from a usable one; a caller that gets
///    `None` must treat it as "no title", not as an error to surface.
/// 7. **Clamp to [`TITLE_CLAMP`] chars**, on a char boundary, then trim once
///    more. **No ellipsis**: a truncated name is still a name, and `…` would be
///    stored in the transcript forever.
///
/// # Does not
///
/// * Strip a leading `Title:` / `Session:` label. Not in W031's rule list; a
///   model that emits one keeps it (see the module friction note in the loop
///   sketch). Adding it is a later, tested change.
/// * Reject a long answer — it clamps instead.
/// * Normalize case, punctuation, or emoji.
///
/// # Panics
///
/// Never. A string of any content (invalid UTF-8 cannot reach here: `&str`).
pub fn sanitize(raw: &str) -> Option<String> {
    // 1-2: trim, then keep only the first line.
    let first_line = raw.trim().split(['\n', '\r']).next().unwrap_or("");
    // 3: drop quotes and backticks outright, interior ones included.
    let unquoted: String = first_line
        .chars()
        .filter(|c| !matches!(c, '"' | '\'' | '`'))
        .collect();
    // 4-5: collapse whitespace runs to one space, trim what that exposed.
    let collapsed = unquoted.split_whitespace().collect::<Vec<_>>().join(" ");
    let collapsed = collapsed.trim();
    // 6: an unusable answer is `None`, never `Some("")`.
    if collapsed.is_empty() {
        return None;
    }
    // 7: hard cut on a char boundary, no ellipsis, then trim once more. The cut
    // can only expose a trailing space (`collapsed` starts with a non-space), so
    // it cannot resurrect the empty case rule 6 already rejected.
    let clamped: String = collapsed.chars().take(TITLE_CLAMP).collect();
    Some(clamped.trim().to_string())
}

/// Render the two halves of the titler's input: the first user text and the
/// first assistant text in `ctx`, each clamped to [`TITLE_INPUT_CHARS`].
///
/// Skips a leading compaction summary. Empty string when neither half exists
/// (the caller must not send a request for it).
fn excerpt(ctx: &[AgentMessage]) -> String {
    let mut body = String::new();
    if let Some(user) = first_user_text(ctx) {
        body.push_str("USER:\n");
        body.push_str(&user);
    }
    if let Some(assistant) = first_assistant_text(ctx) {
        if !body.is_empty() {
            body.push_str("\n\n");
        }
        body.push_str("ASSISTANT:\n");
        body.push_str(&assistant);
    }
    body
}

/// The first real user text in `ctx`, clamped. `None` when there is none, or
/// when the only user message is the synthetic compaction summary.
fn first_user_text(ctx: &[AgentMessage]) -> Option<String> {
    for message in ctx {
        if let AgentMessage::User { .. } = message {
            let text = message.as_text();
            let trimmed = text.trim();
            if !trimmed.is_empty() && !trimmed.starts_with(COMPACTION_SUMMARY_PREFIX) {
                return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
            }
        }
    }
    None
}

/// The first assistant `Text` block in `ctx` (any assistant message, in order),
/// clamped. `None` when no assistant message carried text — a thinking-only
/// answer yields `None` (the safe failure mode W031 §11.11 names).
fn first_assistant_text(ctx: &[AgentMessage]) -> Option<String> {
    for message in ctx {
        let AgentMessage::Assistant { content, .. } = message else {
            continue;
        };
        for block in content {
            if let ContentBlock::Text { text } = block {
                let trimmed = text.trim();
                if !trimmed.is_empty() {
                    return Some(clamp_chars(trimmed, TITLE_INPUT_CHARS));
                }
            }
        }
    }
    None
}

/// Hard-cut `text` to at most `max` chars on a char boundary (never mid-codepoint).
fn clamp_chars(text: &str, max: usize) -> String {
    if text.chars().count() <= max {
        return text.to_string();
    }
    text.chars().take(max).collect()
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use crate::message::{ContentBlock, StopReason};

    use super::*;

    /// Every request the double was handed, as `(ctx, system_prompt)`.
    type Calls = Arc<std::sync::Mutex<Vec<(Vec<AgentMessage>, String)>>>;

    /// A `StreamFn` that replays `script` and records every request it was given
    /// — the same double shape as `actor.rs`'s `Recorder`.
    fn scripted(script: Vec<LlmStreamEvent>) -> (StreamFn, Calls) {
        let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
        let seen = Arc::clone(&calls);
        let f: StreamFn = Arc::new(move |ctx, system, _tools, _opts| {
            seen.lock()
                .unwrap()
                .push((ctx.to_vec(), system.to_string()));
            Box::pin(futures::stream::iter(script.clone())) as crate::streamfn::LlmStream
        });
        (f, calls)
    }

    fn user(text: &str) -> AgentMessage {
        AgentMessage::user_text(text)
    }

    fn assistant(text: &str) -> AgentMessage {
        AgentMessage::Assistant {
            content: vec![ContentBlock::Text { text: text.into() }],
            stop_reason: StopReason::Stop,
            usage: None,
            model: None,
        }
    }

    fn done() -> LlmStreamEvent {
        LlmStreamEvent::Done {
            stop_reason: StopReason::Stop,
            usage: None,
        }
    }

    /// GATE (W031 §6 step 2). Both halves reach the model: the captured request's
    /// single user message contains the user text AND the assistant text, under
    /// the `USER:` / `ASSISTANT:` headings, in one tool-free call whose `system`
    /// is [`TITLE_PROMPT`].
    #[tokio::test]
    async fn generate_collects_text_and_sends_both_halves() {
        let (f, calls) = scripted(vec![
            LlmStreamEvent::TextDelta("retry".into()),
            LlmStreamEvent::TextDelta(" backoff".into()),
            done(),
        ]);
        let ctx = vec![user("add retry backoff"), assistant("done: 3 retries")];

        let got = generate(&f, &LlmOpts::default(), &ctx).await.unwrap();
        assert_eq!(got, "retry backoff", "the TextDeltas are folded");

        let calls = calls.lock().unwrap();
        assert_eq!(calls.len(), 1, "exactly one model call");
        let (sent, system) = &calls[0];
        assert_eq!(system, TITLE_PROMPT);
        assert_eq!(sent.len(), 1, "one user message carries both halves");
        let body = sent[0].as_text();
        assert!(body.contains("USER:\nadd retry backoff"), "{body}");
        assert!(body.contains("ASSISTANT:\ndone: 3 retries"), "{body}");
    }

    /// GATE (W031 §6 step 2) + amendment. An `excerpt` with nothing usable sends
    /// NO request at all — the cheapest possible failure.
    #[tokio::test]
    async fn generate_refuses_an_empty_excerpt_without_a_call() {
        let (f, calls) = scripted(vec![]);

        assert_eq!(
            generate(&f, &LlmOpts::default(), &[]).await.unwrap_err(),
            "no input to title from"
        );
        // A resumed ctx whose only user message is the compaction summary has no
        // real user half and no assistant half either.
        let summary_only = vec![AgentMessage::summary("did 1..3")];
        assert_eq!(
            generate(&f, &LlmOpts::default(), &summary_only)
                .await
                .unwrap_err(),
            "no input to title from"
        );

        assert!(
            calls.lock().unwrap().is_empty(),
            "no request for an empty excerpt"
        );
    }

    /// GATE — the §2 bug, in the titler's half. A resumed `ctx` begins with
    /// `AgentMessage::summary(..)`; the excerpt must start at the first REAL user
    /// text, not at the summary's constant prefix.
    #[test]
    fn excerpt_skips_a_compaction_summary() {
        let ctx = vec![
            AgentMessage::summary("did 1..3"),
            user("real question"),
            assistant("answer"),
        ];

        let body = excerpt(&ctx);
        assert!(
            !body.contains(COMPACTION_SUMMARY_PREFIX),
            "the synthetic summary must never reach the titler: {body}"
        );
        assert!(body.contains("real question"), "{body}");
        assert!(body.contains("answer"), "{body}");
        assert_eq!(body, "USER:\nreal question\n\nASSISTANT:\nanswer");
    }

    /// GATE (W031 §6 step 2). The sanitiser's whole rule list, one assertion per
    /// rule: quotes/backticks stripped, first line only, whitespace collapsed,
    /// clamped to exactly `TITLE_CLAMP` chars with no ellipsis.
    #[test]
    fn sanitize_strips_quotes_and_clamps() {
        // Quotes and backticks, at the ends and interior; surrounding space goes.
        assert_eq!(
            sanitize("  `\"retry backoff\"`  ").as_deref(),
            Some("retry backoff")
        );
        assert_eq!(sanitize("don't").as_deref(), Some("dont"));
        // First line only, trailing period kept (rule list does not strip it).
        assert_eq!(
            sanitize("Retry backoff.\nsecond line ignored").as_deref(),
            Some("Retry backoff.")
        );
        // Whitespace runs collapse to one space.
        assert_eq!(sanitize("a\t\tb   c").as_deref(), Some("a b c"));
        // The cap is a hard cut in chars, with no ellipsis.
        let long = "x".repeat(TITLE_CLAMP + 20);
        let got = sanitize(&long).unwrap();
        assert_eq!(got.chars().count(), TITLE_CLAMP);
        assert!(!got.ends_with('…'), "no ellipsis: the cap is a hard cut");
        // A cut that would end on a space is trimmed (no trailing blank).
        let padded = format!("{}  tail", "y".repeat(TITLE_CLAMP - 1));
        let got = sanitize(&padded).unwrap();
        assert_eq!(got.chars().count(), TITLE_CLAMP - 1);
        assert!(!got.ends_with(' '), "{got:?}");
    }

    /// GATE (W031 §6 step 2). An answer with no usable text is `None`, never an
    /// empty title: whitespace-only, newlines-only, and quote-only all reject.
    #[test]
    fn sanitize_rejects_an_empty_or_multiline_only_answer() {
        assert_eq!(sanitize(""), None);
        assert_eq!(sanitize("   \n\t\n  "), None);
        assert_eq!(sanitize("\"\"``''"), None);
    }

    /// GATE (W031 §6 step 2). A stream error is surfaced verbatim as `Err`, not
    /// swallowed into a `None` title — the caller (the loop) decides it is
    /// non-fatal, this module does not pretend the answer was empty.
    #[tokio::test]
    async fn generate_surfaces_a_stream_error() {
        let (f, calls) = scripted(vec![LlmStreamEvent::Error {
            message: "boom".into(),
            fatal: true,
        }]);
        let ctx = vec![user("q"), assistant("a")];

        assert_eq!(
            generate(&f, &LlmOpts::default(), &ctx).await.unwrap_err(),
            "boom"
        );
        assert_eq!(calls.lock().unwrap().len(), 1);
    }
}