Add the thinking cap to Client::chat
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -65,12 +65,39 @@ impl Client {
|
|||||||
let mut body = conn.body(&head).map_err(map_http)?;
|
let mut body = conn.body(&head).map_err(map_http)?;
|
||||||
let mut events = Events::new(&mut body, MAX_SSE_LINE);
|
let mut events = Events::new(&mut body, MAX_SSE_LINE);
|
||||||
let mut assembler = Assembler::new();
|
let mut assembler = Assembler::new();
|
||||||
|
// Where the cap fired, if it has: the overrun allowance is measured from here.
|
||||||
|
let mut cap_at: Option<u64> = None;
|
||||||
loop {
|
loop {
|
||||||
match events.next_item() {
|
match events.next_item() {
|
||||||
Ok(Some(SseItem::Data(text))) => {
|
Ok(Some(SseItem::Data(text))) => {
|
||||||
for event in assembler.push(&text)? {
|
for event in assembler.push(&text)? {
|
||||||
on_event(&event);
|
on_event(&event);
|
||||||
}
|
}
|
||||||
|
if assembler.in_reasoning() {
|
||||||
|
let tokens = assembler.reasoning_tokens();
|
||||||
|
match cap_at {
|
||||||
|
None if tokens >= self.cfg.limits.thinking_cap => {
|
||||||
|
let Some(id) = assembler.id() else {
|
||||||
|
return Err(InferError::Protocol(
|
||||||
|
"a reasoning chunk carried no completion id".to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match self.end_reasoning(id) {
|
||||||
|
Ok(true) => {
|
||||||
|
cap_at = Some(tokens);
|
||||||
|
on_event(&ChatEvent::ThinkingCapped { tokens });
|
||||||
|
}
|
||||||
|
Ok(false) | Err(_) => {
|
||||||
|
return Err(InferError::ThinkingOverrun);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(at) if tokens >= at + self.cfg.limits.thinking_overrun => {
|
||||||
|
return Err(InferError::ThinkingOverrun);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(Some(SseItem::Done)) | Ok(None) => break,
|
Ok(Some(SseItem::Done)) | Ok(None) => break,
|
||||||
Err(SseError::Timeout) => return Err(InferError::Stalled),
|
Err(SseError::Timeout) => return Err(InferError::Stalled),
|
||||||
@@ -81,7 +108,7 @@ impl Client {
|
|||||||
|
|
||||||
// 5. Finish. A stream that ended without a finish_reason is a server that died between two
|
// 5. Finish. A stream that ended without a finish_reason is a server that died between two
|
||||||
// events.
|
// events.
|
||||||
assembler.finish(false)
|
assembler.finish(cap_at.is_some())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,4 +142,24 @@ impl Client {
|
|||||||
.map(Vec::len)
|
.map(Vec::len)
|
||||||
.ok_or_else(|| InferError::Protocol("tokenize response has no tokens".to_string()))
|
.ok_or_else(|| InferError::Protocol("tokenize response has no tokens".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn end_reasoning(&self, completion_id: &str) -> Result<bool, InferError> {
|
||||||
|
let body = serde_json::to_string(&serde_json::json!({
|
||||||
|
"id": completion_id,
|
||||||
|
"action": "reasoning_end",
|
||||||
|
"model": self.cfg.infer.model,
|
||||||
|
}))
|
||||||
|
.map_err(|_| InferError::Protocol("could not encode reasoning_end request".to_string()))?;
|
||||||
|
let bytes = self.call(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions/control",
|
||||||
|
Some(body.as_bytes()),
|
||||||
|
)?;
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|_| InferError::Protocol("control response is not JSON".to_string()))?;
|
||||||
|
value
|
||||||
|
.get("success")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.ok_or_else(|| InferError::Protocol("control response has no success".to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Tests for the thinking cap. Do not edit.
|
||||||
|
//!
|
||||||
|
//! The "capped" recording has 63 reasoning chunks (63 reasoning tokens) and then an answer. The
|
||||||
|
//! fake server plays it regardless of the control call, so the cap and the overrun allowance
|
||||||
|
//! decide what the client makes of it.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use loopd::llama::{ChatEvent, ChatMessage, ChatRequest, Client, Completion, InferError};
|
||||||
|
use support::{FakeServer, Reply};
|
||||||
|
|
||||||
|
const CHAT: &str = "/v1/chat/completions";
|
||||||
|
const CONTROL: &str = "/v1/chat/completions/control";
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
server: &FakeServer,
|
||||||
|
cap: u64,
|
||||||
|
overrun: u64,
|
||||||
|
) -> (Result<Completion, InferError>, Vec<ChatEvent>) {
|
||||||
|
let mut cfg = support::test_config(&server.socket);
|
||||||
|
cfg.limits.thinking_cap = cap;
|
||||||
|
cfg.limits.thinking_overrun = overrun;
|
||||||
|
let req = ChatRequest {
|
||||||
|
slot: 0,
|
||||||
|
messages: vec![ChatMessage::User {
|
||||||
|
content: "think".to_string(),
|
||||||
|
}],
|
||||||
|
tools: vec![],
|
||||||
|
thinking: true,
|
||||||
|
};
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let result = Client::new(cfg).chat(&req, &mut |e| events.push(e.clone()));
|
||||||
|
(result, events)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped_events(events: &[ChatEvent]) -> Vec<u64> {
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| match e {
|
||||||
|
ChatEvent::ThinkingCapped { tokens } => Some(*tokens),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn under_the_cap_nothing_happens() {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("capped")]);
|
||||||
|
let (result, events) = run(&server, 64, 10);
|
||||||
|
let done = result.unwrap();
|
||||||
|
assert!(!done.thinking_capped);
|
||||||
|
assert_eq!(done.reasoning_tokens, 63);
|
||||||
|
assert!(capped_events(&events).is_empty());
|
||||||
|
assert!(
|
||||||
|
server.requests_to(CONTROL).is_empty(),
|
||||||
|
"63 tokens is under a cap of 64"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn at_the_cap_the_control_call_is_sent_once() {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("capped")]);
|
||||||
|
server.route(CONTROL, vec![Reply::fixture("control")]);
|
||||||
|
let (result, events) = run(&server, 20, 100);
|
||||||
|
let done = result.unwrap();
|
||||||
|
assert!(done.thinking_capped);
|
||||||
|
assert_eq!(
|
||||||
|
done.reasoning_tokens, 63,
|
||||||
|
"the count keeps running after the cap"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
done.content.as_deref(),
|
||||||
|
support::expected("capped")["content"].as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
capped_events(&events),
|
||||||
|
vec![20],
|
||||||
|
"one event, at the token count that hit the cap"
|
||||||
|
);
|
||||||
|
|
||||||
|
let control = server.requests_to(CONTROL);
|
||||||
|
assert_eq!(
|
||||||
|
control.len(),
|
||||||
|
1,
|
||||||
|
"the control call is made once, not on every later chunk"
|
||||||
|
);
|
||||||
|
let id = support::expected("capped")["id"].clone();
|
||||||
|
assert_eq!(
|
||||||
|
control[0].json(),
|
||||||
|
serde_json::json!({"id": id, "action": "reasoning_end", "model": "test-model"})
|
||||||
|
);
|
||||||
|
|
||||||
|
// The event sits between the reasoning that hit the cap and what came after.
|
||||||
|
let at = events
|
||||||
|
.iter()
|
||||||
|
.position(|e| matches!(e, ChatEvent::ThinkingCapped { .. }))
|
||||||
|
.unwrap();
|
||||||
|
let before = events[..at]
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, ChatEvent::Reasoning(_)))
|
||||||
|
.count();
|
||||||
|
assert_eq!(before, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thinking_on_past_the_allowance_is_an_overrun() {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("capped")]);
|
||||||
|
server.route(CONTROL, vec![Reply::fixture("control")]);
|
||||||
|
// Capped at 20; 63 reasoning tokens is more than 20 + 30.
|
||||||
|
let (result, events) = run(&server, 20, 30);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(InferError::ThinkingOverrun)),
|
||||||
|
"{result:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(capped_events(&events), vec![20]);
|
||||||
|
let reasoning = events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| matches!(e, ChatEvent::Reasoning(_)))
|
||||||
|
.count();
|
||||||
|
assert_eq!(reasoning, 50, "the client stops reading at 20 + 30 tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_allowance_is_exact() {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("capped")]);
|
||||||
|
server.route(CONTROL, vec![Reply::fixture("control")]);
|
||||||
|
// 63 is not 20 + 44, so this is allowed; with 43 it would be an overrun at token 63.
|
||||||
|
assert!(run(&server, 20, 44).0.is_ok());
|
||||||
|
assert!(matches!(
|
||||||
|
run(&server, 20, 43).0,
|
||||||
|
Err(InferError::ThinkingOverrun)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_control_call_that_fails_is_an_overrun() {
|
||||||
|
for reply in [
|
||||||
|
Reply::json(200, r#"{"success":false,"message":"no such completion"}"#),
|
||||||
|
Reply::json(500, r#"{"error":"boom"}"#),
|
||||||
|
Reply::json(200, "not json"),
|
||||||
|
] {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("capped")]);
|
||||||
|
server.route(CONTROL, vec![reply]);
|
||||||
|
let (result, events) = run(&server, 20, 100);
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(InferError::ThinkingOverrun)),
|
||||||
|
"{result:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
capped_events(&events).is_empty(),
|
||||||
|
"the cap did not take effect, so no event"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_completion_without_reasoning_is_never_capped() {
|
||||||
|
let server = FakeServer::start();
|
||||||
|
server.route(CHAT, vec![Reply::fixture("plain")]);
|
||||||
|
let (result, _) = run(&server, 1, 0);
|
||||||
|
assert!(!result.unwrap().thinking_capped);
|
||||||
|
assert!(server.requests_to(CONTROL).is_empty());
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
| M2a/08-llama-info | 2026-09-18 | done | 2 | fail | none | Wrote crates/loopd/src/llama/info.rs and registered `pub mod info;`. `call` is one exchange: open socket, set read timeout to `liveness_ms`, send, read head, `read_capped` with `MAX_BODY`; non-200 returns `InferError::Http { status, error_text(&bytes) }`, everything else maps through `map_http` (Connect->Connect, Timeout->Stalled, Closed->StreamClosedEarly, else->Protocol). `error_text` reads the full body via `from_utf8_lossy` then backs up from byte 4096 to a char boundary, so a cut mid-character does not panic. `props` reads `chat_template`, `total_slots`, and `default_generation_settings.n_ctx` from the JSON (unknown fields ignored); `slots` deserializes `Vec<SlotInfo>`; `tokenize` POSTs `{"model","content"}` via `serde_json` and returns `tokens.len()`. `cache_outcome` uses `saturating_add` and `current.cache_n + CACHE_TOLERANCE >= expected`. All 7 tests pass; first gate run failed on rustfmt import order, fixed with `cargo fmt --all`. | ? |
|
| M2a/08-llama-info | 2026-09-18 | done | 2 | fail | none | Wrote crates/loopd/src/llama/info.rs and registered `pub mod info;`. `call` is one exchange: open socket, set read timeout to `liveness_ms`, send, read head, `read_capped` with `MAX_BODY`; non-200 returns `InferError::Http { status, error_text(&bytes) }`, everything else maps through `map_http` (Connect->Connect, Timeout->Stalled, Closed->StreamClosedEarly, else->Protocol). `error_text` reads the full body via `from_utf8_lossy` then backs up from byte 4096 to a char boundary, so a cut mid-character does not panic. `props` reads `chat_template`, `total_slots`, and `default_generation_settings.n_ctx` from the JSON (unknown fields ignored); `slots` deserializes `Vec<SlotInfo>`; `tokenize` POSTs `{"model","content"}` via `serde_json` and returns `tokens.len()`. `cache_outcome` uses `saturating_add` and `current.cache_n + CACHE_TOLERANCE >= expected`. All 7 tests pass; first gate run failed on rustfmt import order, fixed with `cargo fmt --all`. | ? |
|
||||||
| M2a/11-llama-gate-retry | 2026-09-18 | stopped | 0 | n/a | none | The prerequisite `chat` is missing, so this task is impossible as written. The branch is at M2a/07 (assemble); task 09 (llama-chat) has not been done and `crates/loopd/src/llama/chat.rs` does not exist. The retry.rs test calls `client.chat()` and `chat_with_retry()`, and the task says to *modify* chat.rs and add the gate inside `chat` — all of which require a `chat` that was never implemented. The gate and retry pieces are independent of chat, but the 13-test suite cannot pass (it does not even compile) without it. Did not read task 09 per AGENTS.md and did not implement chat, which is another task and would be improvising. Committed only this log row; the copied tests/retry.rs was removed. | ? |
|
| M2a/11-llama-gate-retry | 2026-09-18 | stopped | 0 | n/a | none | The prerequisite `chat` is missing, so this task is impossible as written. The branch is at M2a/07 (assemble); task 09 (llama-chat) has not been done and `crates/loopd/src/llama/chat.rs` does not exist. The retry.rs test calls `client.chat()` and `chat_with_retry()`, and the task says to *modify* chat.rs and add the gate inside `chat` — all of which require a `chat` that was never implemented. The gate and retry pieces are independent of chat, but the 13-test suite cannot pass (it does not even compile) without it. Did not read task 09 per AGENTS.md and did not implement chat, which is another task and would be improvising. Committed only this log row; the copied tests/retry.rs was removed. | ? |
|
||||||
| M2a/09-llama-chat | 2026-09-18 | done | 1 | pass | none | Wrote crates/loopd/src/llama/chat.rs (`chat`, with the head wait in a separate `wait_for_head`) and registered `pub mod chat;`. `chat` builds the body (build error -> Protocol), opens and POSTs, then `wait_for_head` loops `read_head` at `poll_ms`: a Timeout is classified Idle/Busy/Unavailable by `received_any` then a `slots()` poll, emits `Waiting { slot_busy }` on every poll, keeps a per-state `since` that resets on state change, and returns `WaitTimeout`/`LoadTimeout`/`Stalled` at the right limits; 200 streams via `Events`+`Assembler` mapping `Timeout->Stalled`, `Truncated->StreamClosedEarly`, else `Protocol`, then `finish(false)`; non-200 returns `Http { status, error_text }`. All 13 chat tests pass five runs in a row. Table-to-test map: state (table 1) Busy -> a_busy_slot_is_waited_out / a_slot_that_stays_busy_is_a_wait_timeout, Idle-not-processing -> only_the_requests_own_slot_counts_as_busy, Unavailable -> an_unavailable_server_is_a_load_timeout, Idle-received_any -> a_slow_but_steady_stream_is_not_a_stall (turn1 head is 738 B, pieces are 3140/8=392 B, so the head-wait times out with a partial head); thresholds (table 2) -> a_slot_that_stays_busy_is_a_wait_timeout / an_unavailable_server_is_a_load_timeout / only_the_requests_own_slot_counts_as_busy; stream (table 3) Data/Done/None -> the recorded completion and trickle tests, Timeout -> silence_in_mid_stream_is_a_stall, Truncated -> a_stream_that_is_cut_is_closed_early_wherever_the_cut_falls, other -> garbage_in_the_stream_is_a_protocol_error; clock-restart -> the_wait_clocks_restart_when_the_state_changes. One path fix: info and request live under crate::llama, not crate::, so the imports use crate::llama::{info::..., request::...}. | ? |
|
| M2a/09-llama-chat | 2026-09-18 | done | 1 | pass | none | Wrote crates/loopd/src/llama/chat.rs (`chat`, with the head wait in a separate `wait_for_head`) and registered `pub mod chat;`. `chat` builds the body (build error -> Protocol), opens and POSTs, then `wait_for_head` loops `read_head` at `poll_ms`: a Timeout is classified Idle/Busy/Unavailable by `received_any` then a `slots()` poll, emits `Waiting { slot_busy }` on every poll, keeps a per-state `since` that resets on state change, and returns `WaitTimeout`/`LoadTimeout`/`Stalled` at the right limits; 200 streams via `Events`+`Assembler` mapping `Timeout->Stalled`, `Truncated->StreamClosedEarly`, else `Protocol`, then `finish(false)`; non-200 returns `Http { status, error_text }`. All 13 chat tests pass five runs in a row. Table-to-test map: state (table 1) Busy -> a_busy_slot_is_waited_out / a_slot_that_stays_busy_is_a_wait_timeout, Idle-not-processing -> only_the_requests_own_slot_counts_as_busy, Unavailable -> an_unavailable_server_is_a_load_timeout, Idle-received_any -> a_slow_but_steady_stream_is_not_a_stall (turn1 head is 738 B, pieces are 3140/8=392 B, so the head-wait times out with a partial head); thresholds (table 2) -> a_slot_that_stays_busy_is_a_wait_timeout / an_unavailable_server_is_a_load_timeout / only_the_requests_own_slot_counts_as_busy; stream (table 3) Data/Done/None -> the recorded completion and trickle tests, Timeout -> silence_in_mid_stream_is_a_stall, Truncated -> a_stream_that_is_cut_is_closed_early_wherever_the_cut_falls, other -> garbage_in_the_stream_is_a_protocol_error; clock-restart -> the_wait_clocks_restart_when_the_state_changes. One path fix: info and request live under crate::llama, not crate::, so the imports use crate::llama::{info::..., request::...}. | ? |
|
||||||
|
| M2a/10-llama-cap | 2026-09-18 | done | 1 | pass | none | Added `Client::end_reasoning` to info.rs: POSTs `{"id","action":"reasoning_end","model"}` to `/v1/chat/completions/control` via `call`, reads `success` as a bool from the server's JSON (ignoring `message`), non-200 stays an Err through `call`, a missing/non-bool `success` is Protocol. Threaded the cap into `chat` step 4: after passing a chunk's events on, when `assembler.in_reasoning()`, a local `cap_at: Option<u64>` holds where the cap fired (None while it has not fired); on `tokens >= thinking_cap` it calls `end_reasoning(assembler.id())` once, remembers `tokens` and emits `ThinkingCapped` on `Ok(true)`, returns `ThinkingOverrun` on `Ok(false)`/`Err`, and after firing returns `ThinkingOverrun` once `tokens >= at + thinking_overrun`; `finish(cap_at.is_some())`. The `the_allowance_is_exact` test passes with `>=` in both rows (63 is not `20+44`, and is `>= 20+43`). One guard: a reasoning chunk with no id at cap time is Protocol rather than a panic. 6 cap tests + 13 chat tests pass; `make gate` prints `gate: ok`. | ? |
|
||||||
|
|
||||||
|
|
||||||
## Reviews
|
## Reviews
|
||||||
|
|||||||
Reference in New Issue
Block a user