- 1765
}; - 1766
if let Some(gate) = &self.config.spend_gate { - 1767
gate.record_settled_with_latency( - 1768
self.provider.name(), - 1769
&m.model, - 1770
&sid, - 1771
&m.usage, - 1772
ledger.receipt.attempts.iter().map(|a| a.latency_ms).sum(), - 1773
); - 1774
} - 1775
m - 1776
} - 1777
Err(LlmError::Aborted { .. }) => { - 1778
let _ = self - 1779
.session - 1780
.lock() - 1781
.await - 1782
.append_receipt(ledger.take_receipt()); - 1783
return TurnOutcome::Aborted { partial: None }; - 1784
} - 1785
Err(e) => { - 1786
let _ = self - 1787
.session - 1788
.lock() - 1789
.await - 1790
.append_receipt(ledger.take_receipt()); - 1791
return TurnOutcome::Failed { - 1792
error: LlmError::Network(format!("compaction call failed: {e}")), - 1793
}; - 1794
} - 1795
}; - 1796
let summary = summary_msg.text_content(); - 1797
if summary.trim().is_empty() { - 1798
return TurnOutcome::Failed { - 1799
error: LlmError::Network("compaction produced an empty summary".into()), - 1800
}; - 1801
} - 1802
{ - 1803
let mut session = self.session.lock().await; - 1804
if let Err(e) = session.append_incremental_compaction( - 1805
&first_turn_id, - 1806
&last_turn_id, - 1807
&model, - 1808
summary, - 1809
tokens_before, - 1810
) { - 1811
return TurnOutcome::Failed { - 1812
error: LlmError::Network(format!("compaction write failed: {e}")), - 1813
}; - 1814
} - 1815
} - 1816
let after_plan = self - 1817
.build_working_set_plan(&profile, prefix_tokens, tail_tokens) - 1818
.await; - 1819
let _ = events - 1820
.send(AgentEvent::ContextCompacted { - 1821
before_tokens: tokens_before, - 1822
after_tokens: after_plan.spent, - 1823
summarized_turns: 0, - 1824
}) - 1825
.await; - 1826
plan = after_plan; - 1827
// Compaction wrote a new ledger entry: the packeted - 1828
// range is now covered, so the frozen baseline must - 1829
// reflect it for the rest of this turn too. - 1830
turn_plan = Some(plan.clone()); - 1831
} - 1832
} - 1833
- 1834
// One model step = connect + stream + collect, wrapped with the - 1835
// full reliability machinery (watchdog, retries+backoff, - 1836
// circuit breaker, dispatch ceiling). Every dispatch is recorded - 1837
// into the work receipt, which lands in the ledger on every - 1838
// exit path. User aborts and partial-output aborts are never - 1839
// retried; they propagate for caller handling. - 1840
let mut ledger = StepLedger::new( - 1841
WorkPurpose::Execute, - 1842
self.provider.name(), - 1843
&model, - 1844
self.config.dispatch_ceiling, - 1845
); - 1846
// Recorded on every exit path below, success or failure: the - 1847
// digest describes what was SENT, not what came back - 1848
// (docs/design/68-context-engine.md §6/§7). - 1849
ledger.receipt.prefix_digest = - 1850
assemble::prefix_digest(&self.config.system_prefix, &tool_defs); - 1851
let base_request = { - 1852
let session = self.session.lock().await; - 1853
// `messages` is already the fidelity-selected projection - 1854
// (docs/design/68-context-engine.md §4/§10): retrieved-by- - 1855
// relevance turns ride at Full inside it, so there is no - 1856
// separate proactive-retrieval prepend step any more. - 1857
let (mut messages, directive_at) = session.derive_with_plan_and_directive(&plan); - 1858
// The tail is one final text block on the turn's directive - 1859
// (after any tool_result blocks the directive itself - 1860
// carries), never a separate consecutive user message and - 1861
// never re-homed onto a later step's tool result or nudge - 1862
// (docs/design/68-context-engine.md §6/§7). - 1863
attach_tail(&mut messages, &turn_tail, directive_at); - 1864
let session_key = session - 1865
.header() - 1866
.map(|header| header.session_id.clone()) - 1867
.unwrap_or_default(); - 1868
let cache = (!session_key.is_empty()).then(|| vak_llm::CacheHints { - 1869
session_key, - 1870
breakpoints: cache_breakpoints(&messages), - 1871
}); - 1872
ChatRequest { - 1873
model, - 1874
system: Some(self.config.system_prefix.clone()), - 1875
messages, - 1876
tools: tool_defs.clone(), - 1877
max_tokens: self.config.max_output as u32, - 1878
temperature: None, - 1879
cache, - 1880
previous_response_id: None, - 1881
think: None, - 1882
effort: None, - 1883
} - 1884
}; - 1885
let mut request = base_request.clone(); - 1886
- 1887
let mut response = { - 1888
// Run-level endurance: a sustained fault window (rate-limit - 1889
// burst, slow/hung upstream, truncating proxy) can outlast - 1890
// one step's retry budget. The ledger has not been touched, - 1891
// so re-attempting the whole turn is exact. Aborts, permanent - 1892
// errors, and ceiling exhaustion still fail/abort immediately. - 1893
let mut run_attempt: u32 = 0; - 1894
let mut backoff_ms = self.config.run_retry_base_backoff_ms.max(1); - 1895
// Over-length replan (docs/design/68-context-engine.md §5): - 1896
// a provider context-length rejection is a CapacityProfile - 1897
// contradiction, not a transient fault — retried once, with - 1898
// the horizon lowered and the request replanned smaller. A - 1899
// second rejection on the retry is the turn's failure. - 1900
let mut context_replan_used = false; - 1901
loop { - 1902
match self - 1903
.complete_with_reliability(&request, &cancel, &events, true, &mut ledger) - 1904
.await - 1905
{ - 1906
Ok(r) => break r, - 1907
Err(LlmError::Aborted { partial }) => { - 1908
let _ = self - 1909
.session - 1910
.lock() - 1911
.await - 1912
.append_receipt(ledger.take_receipt()); - 1913
let partial = partial.map(|boxed| *boxed); - 1914
if let Some(p) = &partial { - 1915
let _ = self.append_assistant(p).await; - 1916
} - 1917
return TurnOutcome::Aborted { partial }; - 1918
} - 1919
Err(e) if ledger.budget.remaining() == 0 => { - 1920
let _ = self - 1921
.session - 1922
.lock() - 1923
.await - 1924
.append_receipt(ledger.take_receipt()); - 1925
return TurnOutcome::Failed { - 1926
error: LlmError::Network(format!( - 1927
"dispatch ceiling of {} exhausted for this step; last error: {e}", - 1928
self.config.dispatch_ceiling - 1929
)), - 1930
}; - 1931
} - 1932
Err(LlmError::Context(reason)) if !context_replan_used => { - 1933
context_replan_used = true; - 1934
let request_tokens = - 1935
profile.estimate_tokens(chat_request_chars(&request)); - 1936
let mut lowered = profile.clone(); - 1937
lowered.observe_over_length(request_tokens); - 1938
self.config.capacity = Some(lowered.clone()); - 1939
let mut data = self.capacity_activity_data(&lowered); - 1940
data.insert("reason".into(), reason.clone()); - 1941
data.insert("request_tokens".into(), request_tokens.to_string()); - 1942
self.record_activity( - 1943
vak_session::ActivityKind::CapacityFeedback, - 1944
vak_session::ActivityStatus::Succeeded, - 1945
"Capacity horizon lowered by an over-length rejection".into(), - 1946
Some(reason), - 1947
data, - 1948
) - 1949
.await; - 1950
let new_prefix_tokens = lowered.estimate_tokens(prefix_chars( - 1951
&self.config.system_prefix, - 1952
&tool_defs, - 1953
)); - 1954
let new_tail_tokens = - 1955
lowered.estimate_tokens(turn_tail.chars().count() as u64); - 1956
plan = self - 1957
.build_working_set_plan( - 1958
&lowered, - 1959
new_prefix_tokens, - 1960
new_tail_tokens, - 1961
) - 1962
.await; - 1963
// The lowered plan is the new baseline for the - 1964
// rest of this turn, not just this retry. - 1965
turn_plan = Some(plan.clone()); - 1966
request = { - 1967
let session = self.session.lock().await; - 1968
let (mut messages, directive_at) = - 1969
session.derive_with_plan_and_directive(&plan); - 1970
attach_tail(&mut messages, &turn_tail, directive_at); - 1971
// This request just changed shape earlier - 1972
// than the open turn's own tail (a smaller - 1973
// plan): replaying a thinking block from a - 1974
// step already sent under the OLD shape is - 1975
// rejected outright by a provider that - 1976
// requires nothing earlier to have changed - 1977
// since it was produced (docs/design/68 - 1978
// §7), so it is dropped here instead. - 1979
strip_replayed_thinking(&mut messages); - 1980
let session_key = session - 1981
.header() - 1982
.map(|header| header.session_id.clone()) - 1983
.unwrap_or_default(); - 1984
let cache = - 1985
(!session_key.is_empty()).then(|| vak_llm::CacheHints { - 1986
session_key, - 1987
breakpoints: cache_breakpoints(&messages), - 1988
}); - 1989
ChatRequest { - 1990
model: self.config.model.clone(), - 1991
system: Some(self.config.system_prefix.clone()), - 1992
messages, - 1993
tools: tool_defs.clone(), - 1994
max_tokens: self.config.max_output as u32, - 1995
temperature: None, - 1996
cache, - 1997
previous_response_id: None, - 1998
think: None, - 1999
effort: None, - 2000
} - 2001
}; - 2002
continue; - 2003
} - 2004
Err(e) - 2005
if run_attempt < self.config.run_retry_attempts - 2006
&& is_transient_step_error(&e) => - 2007
{ - 2008
run_attempt += 1; - 2009
let delay = backoff_ms.min(30_000); - 2010
let reason = format!( - 2011
"step exhausted ({e}); run-level re-attempt {run_attempt}/{}", - 2012
self.config.run_retry_attempts - 2013
); - 2014
self.record_activity( - 2015
vak_session::ActivityKind::Retry, - 2016
vak_session::ActivityStatus::Running, - 2017
format!("Retry attempt {run_attempt}"), - 2018
Some(reason.clone()), - 2019
[ - 2020
("attempt".into(), run_attempt.to_string()), - 2021
("delay_ms".into(), delay.to_string()), - 2022
] - 2023
.into(), - 2024
) - 2025
.await; - 2026
let _ = events - 2027
.send(AgentEvent::RetryScheduled { - 2028
attempt: run_attempt, - 2029
delay_ms: delay, - 2030
reason, - 2031
}) - 2032
.await; - 2033
if tokio::select! { - 2034
_ = cancel.cancelled() => false, - 2035
_ = tokio::time::sleep(std::time::Duration::from_millis(delay)) => true, - 2036
} { - 2037
backoff_ms = backoff_ms.saturating_mul(2); - 2038
continue; - 2039
} - 2040
let _ = self - 2041
.session - 2042
.lock() - 2043
.await - 2044
.append_receipt(ledger.take_receipt()); - 2045
return TurnOutcome::Aborted { partial: None }; - 2046
} - 2047
Err(e) => { - 2048
let _ = self - 2049
.session - 2050
.lock() - 2051
.await - 2052
.append_receipt(ledger.take_receipt()); - 2053
return TurnOutcome::Failed { error: e }; - 2054
} - 2055
} - 2056
} - 2057
}; - 2058
- 2059
// Provider dialect quirks are normalized before the assistant - 2060
// message reaches the append-only ledger. Execution, replay, - 2061
// presentation projection, and the next model step must all see - 2062
// the same canonical call rather than a live-only repaired copy. - 2063
let mcp_index = self - 2064
.config - 2065
.mcp_tool_index - 2066
.lock() - 2067
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2068
.clone(); - 2069
normalize_response_tool_uses(&mut response, &self.config.tools, &mcp_index); - 2070
// Some small models cannot emit a structured `tool_use` block - 2071
// and spell one out as text instead: rewrite `response` BEFORE - 2072
// it reaches the ledger, so what is recorded is already a - 2073
// valid tool_use/tool_result pair rather than envelope text - 2074
// (docs/design/68-context-engine.md §5/§7). - 2075
let calls: Vec<PendingToolCall> = extract_tool_calls(&mut response, &self.config.tools) - 2076
.into_iter() - 2077
.map(normalize_tool_call) - 2078
.collect(); - 2079
- 2080
outcome_turns += 1; - 2081
- 2082
let usage = response.usage.clone(); - 2083
let mut settled_provider_slot: Option<String> = None; - 2084
let settled_session_id = { - 2085
let mut session = self.session.lock().await; - 2086
let sid = session - 2087
.header() - 2088
.map(|h| h.session_id.clone()) - 2089
.unwrap_or_default(); - 2090
let mut receipt = ledger.take_receipt(); - 2091
let settled_provider = receipt.provider.clone(); - 2092
if !receipt.prefix_digest.is_empty() { - 2093
// A digest that differs from the immediately preceding - 2094
// receipt's is a cache-breaking event, surfaced so a - 2095
// regression is visible in the ledger rather than only in - 2096
// the bill (docs/design/68-context-engine.md §7). - 2097
if let Some(previous) = last_prefix_digest(&session) - 2098
.filter(|previous| previous != &receipt.prefix_digest) - 2099
{ - 2100
let now = chrono::Utc::now(); - 2101
let activity = vak_session::ActivityRecord { - 2102
activity_id: format!( - 2103
"activity-{}", - 2104
now.timestamp_nanos_opt() - 2105
.unwrap_or_else(|| now.timestamp_micros() * 1_000) - 2106
), - 2107
turn: None, - 2108
kind: vak_session::ActivityKind::Diagnostic, - 2109
status: vak_session::ActivityStatus::Succeeded, - 2110
label: "prefix-changed".to_string(), - 2111
detail: None, - 2112
data: [ - 2113
("previous".to_string(), previous), - 2114
("current".to_string(), receipt.prefix_digest.clone()), - 2115
] - 2116
.into_iter() - 2117
.collect(), - 2118
}; - 2119
let _ = session.append_activity(activity); - 2120
} - 2121
// Measured once per digest: the provider's reported input - 2122
// tokens minus an estimate of the messages alone. A later - 2123
// request with the same digest reuses this measurement - 2124
// rather than re-deriving it from a cache-served step. - 2125
if !prefix_digest_seen(&session, &receipt.prefix_digest) { - 2126
let profile = self.effective_capacity_profile(); - 2127
let messages_tokens = - 2128
profile.estimate_tokens(messages_chars(&request.messages)); - 2129
receipt.prefix_tokens = - 2130
Some(usage.prompt_tokens().saturating_sub(messages_tokens)); - 2131
} - 2132
} - 2133
let _ = session.append_receipt(receipt); - 2134
settled_provider_slot.replace(settled_provider); - 2135
sid - 2136
}; - 2137
if let Some(gate) = &self.config.spend_gate { - 2138
let provider = settled_provider_slot.as_deref().unwrap_or_default(); - 2139
gate.record_settled_with_latency( - 2140
provider, - 2141
&response.model, - 2142
&settled_session_id, - 2143
&usage, - 2144
ledger.receipt.attempts.iter().map(|a| a.latency_ms).sum(), - 2145
); - 2146
} - 2147
self.record_capacity_usage_feedback(&request, &usage, ledger.last_first_token_ms) - 2148
.await; - 2149
let response_entry_id = self.append_assistant(&response).await; - 2150
let _ = events.send(AgentEvent::TurnEnd { usage }).await; - 2151
- 2152
// Model drift (docs/design/68-context-engine.md §7): the step - 2153
// served a different directive than the current one. Never a - 2154
// cut — the steering nudge is appended and the turn continues; - 2155
// only three CONSECUTIVE drift events end it. - 2156
if let Some(drift_reason) = self.detect_model_drift(&response, &calls).await { - 2157
self.drift_streak += 1; - 2158
if self.drift_streak >= MODEL_DRIFT_EXHAUSTION_THRESHOLD { - 2159
return self.degraded_drift_outcome(&drift_reason).await; - 2160
} - 2161
// A request near the horizon that also drifted is evidence - 2162
// the horizon itself is optimistic (§1, §6). - 2163
self.record_capacity_instruction_failure(response.usage.prompt_tokens()) - 2164
.await; - 2165
// Never quotes the directive back: an echo after a tool result - 2166
// reads as the user asking again (docs/design/68 §6). - 2167
let _ = self.session.lock().await.append_message(MessageRecord::control( - 2168
vak_intent::control::ControlKind::SteeringDrift, - 2169
format!( - 2170
"[steering-drift]: {drift_reason}. Refocus your next step on the user's latest message." - 2171
), - 2172
)); - 2173
if calls.is_empty() { - 2174
// A drifted final answer is not accepted as the turn's - 2175
// answer: redo it, same as the other repair nudges. - 2176
if turn + 1 >= self.config.max_turns { - 2177
return TurnOutcome::MaxTurnsReached; - 2178
} - 2179
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2180
turn += 1; - 2181
continue; - 2182
} - 2183
// A drifted tool call still needs its result dispatched - 2184
// (API validity requires the pair); the nudge above steers - 2185
// the NEXT step instead of interrupting this one. - 2186
} else { - 2187
self.drift_streak = 0; - 2188
} - 2189
- 2190
if calls.is_empty() { - 2191
// Empty-step enforcement: the response carried neither text - 2192
// nor a tool call — a thinking-only completion, which a - 2193
// model with a reasoning channel produces when it plans an - 2194
// action and then stops (observed live: "Final Plan: 1. Use - 2195
// tavily_search…" followed by end of turn, four runs out of - 2196
// six). That is not an answer; one bounded redo asks it to - 2197
// act on the plan it already made. A card emitted earlier in - 2198
// the run IS the answer, so a card-only turn is left alone. - 2199
if response.text_content().trim().is_empty() && !cards_emitted_this_run { - 2200
if empty_step_repair_attempted { - 2201
return TurnOutcome::Failed { - 2202
error: LlmError::Parse( - 2203
"model returned no visible answer or tool call after one retry" - 2204
.into(), - 2205
), - 2206
}; - 2207
} - 2208
empty_step_repair_attempted = true; - 2209
if turn + 1 >= self.config.max_turns { - 2210
return TurnOutcome::MaxTurnsReached; - 2211
} - 2212
let _ = self - 2213
.session - 2214
.lock() - 2215
.await - 2216
.append_message(MessageRecord::control( - 2217
vak_intent::control::ControlKind::EmptyStep, - 2218
format!( - 2219
"[empty-step]: Your last response had no visible answer and no tool call. \ - 2220
Complete this already-admitted target now (this is context, not a new request): {:?}. \ - 2221
Make the tool call you planned, or write the answer as text.", - 2222
prompt_owned.chars().take(600).collect::<String>() - 2223
), - 2224
)); - 2225
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2226
turn += 1; - 2227
continue; - 2228
} - 2229
// Freshness enforcement (docs/design/68 §7): the directive - 2230
// asked for a current value and nothing was retrieved in - 2231
// this run, so the answer — prose or card — can only be a - 2232
// repeat of an earlier turn's data. One bounded redo naming - 2233
// the gap; the model may decline by saying it has no live - 2234
// data, which the grounding phrases below already accept. - 2235
if wants_live_data && !observed_this_run { - 2236
let admits_no_data = admits_no_data(&response.text_content()); - 2237
if !admits_no_data && freshness_repair_attempted { - 2238
// Repaired once already and still nothing retrieved - 2239
// (a thinking-only end, or the same figure again): - 2240
// fail closed rather than accept a stale answer. - 2241
return self.stale_data_outcome().await; - 2242
} - 2243
if !admits_no_data { - 2244
freshness_repair_attempted = true; - 2245
if turn + 1 >= self.config.max_turns { - 2246
return TurnOutcome::MaxTurnsReached; - 2247
} - 2248
let available = freshness_retrieval_hint(&tool_defs); - 2249
let _ = self.session.lock().await.append_message(MessageRecord::control( - 2250
vak_intent::control::ControlKind::FreshnessCheck, - 2251
format!("[freshness-check]: This asks for a value as it stands now, but nothing was \ - 2252
retrieved on this turn — a number carried over from an earlier answer is \ - 2253
stale. {available} Retrieve a current reading and answer from what it \ - 2254
returns (a card is fine). If retrieval fails, say what failed."), - 2255
)); - 2256
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2257
turn += 1; - 2258
continue; - 2259
} - 2260
} - 2261
// Grounding enforcement: the immediately preceding step ran a - 2262
// retrieval and this final answer shows no sign of using what - 2263
// it returned. One bounded redo. "Uses" is judged from the - 2264
// result itself — a source, host or figure it contained — so - 2265
// a cited prose answer passes; a card emitted with the - 2266
// retrieval clears the check where it is recorded. - 2267
if !grounding_repair_attempted - 2268
&& let Some(tool_names) = pending_grounding_check.take() - 2269
&& !tool_names.is_empty() - 2270
{ - 2271
let text = response.text_content(); - 2272
let grounded = text.contains("\"semantic_type\"") - 2273
|| admits_no_data(&text) - 2274
|| last_evidence_snippet - 2275
.as_deref() - 2276
.is_none_or(|evidence| uses_evidence(&text, evidence)); - 2277
if !grounded { - 2278
grounding_repair_attempted = true; - 2279
if turn + 1 >= self.config.max_turns { - 2280
return TurnOutcome::MaxTurnsReached; - 2281
} - 2282
let tool_list = tool_names.join(", "); - 2283
let target = prompt_owned.chars().take(600).collect::<String>(); - 2284
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::GroundingCheck, format!( - 2285
"[grounding-check]: Your last answer does not use what {tool_list} just returned. \ - 2286
Complete this already-admitted target (this is context, not a new request): {target:?}. \ - 2287
Answer from those results and name the sources you used, or, if they do not \ - 2288
answer the target, say so plainly instead of answering from memory." - 2289
))); - 2290
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2291
turn += 1; - 2292
continue; - 2293
} - 2294
} - 2295
// Malformed-fence enforcement: the model emitted an explicit - 2296
// vak-tagged card fence, but its JSON body doesn't parse - 2297
// (mismatched brackets, an unquoted key, etc.) — a real, - 2298
// observed failure mode from small/local models. Rather - 2299
// than letting a broken card reach the user (where it - 2300
// degrades to a "could not be rendered" notice at best), - 2301
// give the model one bounded repair turn naming the exact - 2302
// parse error, mirroring the grounding-check pattern above. - 2303
if !malformed_fence_repair_attempted { - 2304
let text = response.text_content(); - 2305
if let Some(parse_error) = find_malformed_vak_fence(&text) { - 2306
malformed_fence_repair_attempted = true; - 2307
if turn + 1 >= self.config.max_turns { - 2308
return TurnOutcome::MaxTurnsReached; - 2309
} - 2310
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::FenceCheck, format!( - 2311
"[fence-check]: The vak-fence in your last answer has invalid JSON and failed to parse \ - 2312
({parse_error}). Resend the same answer with a syntactically valid JSON body this time — \ - 2313
double-check every object/array is closed and every key is quoted. If you can't produce \ - 2314
valid JSON for it, drop the fence and answer in plain prose instead." - 2315
))); - 2316
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2317
turn += 1; - 2318
continue; - 2319
} - 2320
} - 2321
// Duplicate-card enforcement: the model already emitted a - 2322
// card via `emit_*_card` this turn, then its own trailing - 2323
// text repeats the same semantic_type as a `vak` fence — - 2324
// that fence renders as a SECOND card (vak-server's - 2325
// projection and the client's own fence-parsing are - 2326
// independent paths; nothing dedupes across them). One - 2327
// bounded repair turn asking the model to drop the - 2328
// redundant fence, mirroring the two checks above. - 2329
if !duplicate_card_repair_attempted - 2330
&& let Some(emitted_types) = pending_duplicate_card_check.take() - 2331
&& !emitted_types.is_empty() - 2332
{ - 2333
let text = response.text_content(); - 2334
if let Some(dup_type) = find_duplicate_card_fence(&text, &emitted_types) { - 2335
duplicate_card_repair_attempted = true; - 2336
if turn + 1 >= self.config.max_turns { - 2337
return TurnOutcome::MaxTurnsReached; - 2338
} - 2339
let _ = self.session.lock().await.append_message(MessageRecord::control(vak_intent::control::ControlKind::DuplicateCardCheck, format!( - 2340
"[duplicate-card-check]: You already emitted a `{dup_type}` card via the matching \ - 2341
emit_*_card tool call above, and the user already sees it. Resend your answer \ - 2342
WITHOUT the ```vak fence that repeats it — just the short narration around the \ - 2343
card is needed, no restated JSON." - 2344
))); - 2345
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2346
turn += 1; - 2347
continue; - 2348
} - 2349
} - 2350
// Presentation check: the answer reads as something the app - 2351
// presents as a card (the app's own signal/recipe detection, - 2352
// supplied by Core), yet no card was emitted and none is - 2353
// written inline — the model answered in prose. One bounded - 2354
// nudge; the model may decline by resending unchanged. - 2355
if !presentation_repair_attempted - 2356
&& !cards_emitted_this_run - 2357
&& !file_delivered_this_run - 2358
&& let Some(check) = &self.config.presentation_check - 2359
{ - 2360
let text = response.text_content(); - 2361
if !text.trim().is_empty() && !text.contains("```vak") { - 2362
let offered: Vec<String> = self - 2363
.config - 2364
.tools - 2365
.iter() - 2366
.map(|t| t.name().to_string()) - 2367
.collect(); - 2368
if let Some(nudge) = check(&text, &offered) { - 2369
presentation_repair_attempted = true; - 2370
if let Some(tool) = - 2371
self.config.tools.iter().find(|t| t.name() == nudge.tool) - 2372
{ - 2373
self.config - 2374
.discovered_tools - 2375
.lock() - 2376
.unwrap_or_else(std::sync::PoisonError::into_inner) - 2377
.push(vak_llm::ToolDefinition::new( - 2378
tool.name(), - 2379
tool.description(), - 2380
tool.schema(), - 2381
)); - 2382
} - 2383
if turn + 1 >= self.config.max_turns { - 2384
return TurnOutcome::MaxTurnsReached; - 2385
} - 2386
// Required card not emitted: evidence the request - 2387
// may already be past this model's real - 2388
// instruction-following horizon (§1, §6). - 2389
self.record_capacity_instruction_failure( - 2390
response.usage.prompt_tokens(), - 2391
) - 2392
.await; - 2393
let _ = - 2394
self.session - 2395
.lock() - 2396
.await - 2397
.append_message(MessageRecord::control( - 2398
vak_intent::control::ControlKind::PresentationCheck, - 2399
nudge.text, - 2400
)); - 2401
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2402
turn += 1; - 2403
continue; - 2404
} - 2405
} - 2406
} - 2407
if let Some(hooks) = &self.config.hooks { - 2408
let session_id = self - 2409
.session - 2410
.lock() - 2411
.await - 2412
.header() - 2413
.map(|h| h.session_id.clone()) - 2414
.unwrap_or_default(); - 2415
let cwd = self - 2416
.session - 2417
.lock() - 2418
.await - 2419
.header() - 2420
.map(|h| h.contract_cwd()) - 2421
.unwrap_or_else(|| ".".into()); - 2422
let stop = vak_hooks::run_hooks_with_recorder( - 2423
hooks.clone(), - 2424
vak_hooks::HookEvent::Stop, - 2425
&session_id, - 2426
&cwd, - 2427
None, - 2428
Some(&response.text_content()), - 2429
&cancel, - 2430
self.config.hook_recorder.as_deref(), - 2431
) - 2432
.await; - 2433
if stop.blocked { - 2434
if turn + 1 >= self.config.max_turns { - 2435
return TurnOutcome::MaxTurnsReached; - 2436
} - 2437
let reason = stop - 2438
.reason - 2439
.unwrap_or_else(|| "continue required by hook".into()); - 2440
let _ = events - 2441
.send(AgentEvent::StopHookContinuation { - 2442
reason: reason.clone(), - 2443
}) - 2444
.await; - 2445
let _ = self - 2446
.session - 2447
.lock() - 2448
.await - 2449
.append_message(MessageRecord::control( - 2450
vak_intent::control::ControlKind::StopHook, - 2451
format!("[stop-hook]: {reason}\nPlease continue."), - 2452
)); - 2453
let _ = events.send(AgentEvent::DraftDiscarded { turn }).await; - 2454
turn += 1; - 2455
continue; - 2456
} - 2457
} - 2458
if let Some(reason) = self - 2459
.stop_gate( - 2460
&prompt_owned, - 2461
&response, - 2462
&receipts, - 2463
verification_stale, - 2464
&mut stop_blocks_left, - 2465
user_completion_released, - 2466
) - 2467
.await - 2468
{ - 2469
// Stop-policy block: the model tried to end the turn - 2470
// prematurely against an explicit completion - 2471
// requirement (§1, §6). - 2472
self.record_capacity_instruction_failure(response.usage.prompt_tokens()) - 2473
.await; - 2474
if self.guard_continue(reason, &events, turn).await { - 2475
turn += 1; - 2476
continue; - 2477
} - 2478
return TurnOutcome::MaxTurnsReached; - 2479
} - 2480
if let Some(rejection) = self.goal_gate(&response, &cancel, &events).await { - 2481
if self.guard_continue(rejection, &events, turn).await { - 2482
turn += 1; - 2483
continue; - 2484
} - 2485
return TurnOutcome::MaxTurnsReached; - 2486
} - 2487
if let Some(rejection) = self.managed_work_gate(&cancel, &events).await { - 2488
if self.guard_continue(rejection, &events, turn).await { - 2489
turn += 1; - 2490
continue; - 2491
} - 2492
return TurnOutcome::MaxTurnsReached; - 2493
} - 2494
// Fence-path presentations (docs/design/68-context-engine.md - 2495
// §10): only for the answer actually being accepted — every - 2496
// gate above has already passed, so this text will not be - 2497
// redone. A repair-nudged draft never reaches here. - 2498
if let Some(entry_id) = &response_entry_id { - 2499
self.write_fence_presentations(&response.text_content(), entry_id) - 2500
.await; - 2501
} - 2502
return TurnOutcome::Completed { response }; - 2503
} - 2504
- 2505
// A real tool call is forward progress and starts a new - 2506
// model-action boundary. Keep the empty-step retry bounded for - 2507
// consecutive thinking-only completions, but do not spend that - 2508
// retry forever: after a later tool result (including a - 2509
// correctable tool error with an exact repair schema), the model - 2510
// may legitimately need one fresh nudge to perform its next - 2511
// planned action. - 2512
empty_step_repair_attempted = false; - 2513
- 2514
for call in &calls { - 2515
receipts.total_tool_calls += 1; - 2516
if call.name == "bash" { - 2517
let is_subst = call - 2518
.input - 2519
.get("command") - 2520
.and_then(|v| v.as_str()) - 2521
.map(stop_policy::is_substantive_command) - 2522
.unwrap_or(true); - 2523
if is_subst { - 2524
receipts.substantive_bash_calls += 1; - 2525
} - 2526
} else if matches!( - 2527
call.name.as_str(), - 2528
"write" | "edit" | "patch" | "remember" | "propose_skill" - 2529
) || self.delivered_file(&call.name, &call.input).is_some() - 2530
{ - 2531
receipts.files_modified += 1; - 2532
let path = call.input.get("path").and_then(|v| v.as_str()); - 2533
if let Some(p) = path { - 2534
if stop_policy::is_code_path(p) { - 2535
receipts.code_files_modified += 1; - 2536
} else { - 2537
receipts.doc_files_modified += 1; - 2538
} - 2539
} else { - 2540
receipts.doc_files_modified += 1; - 2541
} - 2542
} else if matches!( - 2543
call.name.as_str(), - 2544
"read" - 2545
| "read_file" - 2546
| "glob" - 2547
| "grep" - 2548
| "inspect" - 2549
| "browse" - 2550
| "webfetch" - 2551
| "session_search" - 2552
| "search" - 2553
| "session_list" - 2554
) { - 2555
receipts.read_or_inspected += 1; - 2556
} - 2557
} - 2558
// Regression obligations (Phase H): commands proven GREEN this - 2559
// run must stay green before any completion claim. - 2560
let bash_pairs: Vec<(String, String)> = calls - 2561
.iter() - 2562
.filter(|c| c.name == "bash") - 2563
.filter_map(|c| { - 2564
c.input - 2565
.get("command") - 2566
.and_then(|v| v.as_str()) - 2567
.map(|cmd| (c.id.clone(), cmd.to_string())) - 2568
}) - 2569
.collect(); - 2570
let code_mutation_ids: Vec<String> = calls - 2571
.iter() - 2572
.filter(|call| matches!(call.name.as_str(), "edit" | "write" | "patch")) - 2573
.filter(|call| { - 2574
call.input - 2575
.get("path") - 2576
.and_then(|v| v.as_str()) - 2577
.map(stop_policy::is_code_path) - 2578
.unwrap_or(false) - 2579
}) - 2580
.map(|call| call.id.clone()) - 2581
.collect(); - 2582
let task_assignments: Vec<(String, String, String)> = calls - 2583
.iter() - 2584
.filter(|call| call.name == "task") - 2585
.filter_map(|call| { - 2586
Some(( - 2587
call.id.clone(), - 2588
call.input.get("contract_id")?.as_str()?.to_string(), - 2589
call.input.get("work_item_id")?.as_str()?.to_string(), - 2590
)) - 2591
}) - 2592
.collect(); - 2593
let call_names: HashMap<String, String> = calls - 2594
.iter() - 2595
.map(|c| (c.id.clone(), c.name.clone())) - 2596
.collect(); - 2597
let call_inputs: HashMap<String, serde_json::Value> = calls - 2598
.iter() - 2599
.map(|c| (c.id.clone(), c.input.clone())) - 2600
.collect(); - 2601
// The order the model actually issued these calls in, captured - 2602
// before `execute_batch` (which may run calls concurrently and - 2603
// return `results` in completion order, not issue order). - 2604
// `verification_stale` below needs issue order specifically: - 2605
// "ran bash after editing code" and "edited code after running - 2606
// bash" are different situations even if both calls land in the - 2607
// same batch and finish in the opposite order. - 2608
let call_issue_order: Vec<String> = calls.iter().map(|c| c.id.clone()).collect(); - 2609
// Two card gates at the earliest point, before execution - 2610
// (docs/design/68 §7). Freshness: a card in a live-data turn - 2611
// with nothing retrieved yet would show a carried-over figure. - 2612
// Topic mismatch: a card whose own payload shares no topic word - 2613
// with the directive is unrelated to what was asked, whatever - 2614
// it claims to be derived from — found live, a card correctly - 2615
// linked to a real, on-topic search result still carried an - 2616
// entirely different topic's payload, copied from an older - 2617
// turn's own tool call still sitting in context. Each gated - 2618
// call gets an error value naming which check refused it; - 2619
// either one gets exactly one repair before failing closed. - 2620
enum CardGate { - 2621
Fresh, - 2622
Topic, - 2623
} - 2624
let mut gated: Vec<(PendingToolCall, CardGate)> = Vec::new(); - 2625
let calls: Vec<PendingToolCall> = calls - 2626
.into_iter() - 2627
.filter(|call| { - 2628
if !self.tool_presents_cards(&call.name) { - 2629
return true; - 2630
} - 2631
if wants_live_data && !observed_this_run { - 2632
gated.push((call.clone(), CardGate::Fresh)); - 2633
return false; - 2634
} - 2635
// Scoped to "a retrieval actually succeeded this run": - 2636
// that is the one circumstance the real bug needs and - 2637
// the only one this check can safely judge. Many - 2638
// legitimate cards have sparse, structural payloads with - 2639
// no vocabulary of their own at all (an empty chart - 2640
// skeleton, a bare numeric metric) and share no word - 2641
// with any directive whether they are right or wrong — - 2642
// checked live, this exact shape broke a real, - 2643
// previously-passing test. Only when the model has just - 2644
// retrieved something is "the card is unrelated to - 2645
// both the question and what was found" a signal worth - 2646
// trusting; a card built from the model's own reasoning - 2647
// or from data already in the directive gets no such - 2648
// check; the evidence text (far richer than the terse - 2649
// question) is what lets a correctly-derived card that - 2650
// renames or paraphrases what was found still pass even - 2651
// with zero overlap against the directive alone. - 2652
if let Some(evidence) = &last_evidence_snippet { - 2653
let topic_context = format!("{prompt_owned} {evidence}"); - 2654
if !card_shares_a_topic_with(&topic_context, &call.name, &call.input) { - 2655
gated.push((call.clone(), CardGate::Topic)); - 2656
return false; - 2657
} - 2658
} - 2659
true - 2660
}) - 2661
.collect(); - 2662
if gated - 2663
.iter() - 2664
.any(|(_, gate)| matches!(gate, CardGate::Fresh)) - 2665
{ - 2666
if freshness_repair_attempted { - 2667
// The repair was another carried-over card (measured - 2668
// live: "New Delhi 29.1°C" gated, then "Noida 28°C" - 2669
// from an older turn offered instead). Fail closed: - 2670
// no stale figure is presented as current. - 2671
return self.stale_data_outcome().await; - 2672
} - 2673
freshness_repair_attempted = true; - 2674
} - 2675
if gated - 2676
.iter() - 2677
.any(|(_, gate)| matches!(gate, CardGate::Topic)) - 2678
{ - 2679
if topic_repair_attempted { - 2680
return self - 2681
.topic_mismatch_outcome(last_evidence_snippet.clone()) - 2682
.await; - 2683
} - 2684
topic_repair_attempted = true; - 2685
} - 2686
// A raw search-results HTML page is not a retrieved source for a - 2687
// current fact. When a genuine discovery route is offered, turn - 2688
// this mistaken fetch into a correctable tool result instead of - 2689
// spending context on script-heavy markup and treating it as - 2690
// fresh evidence. This identifies the URL shape, not a search - 2691
// vendor or an MCP server instance. - 2692
let discovery_offered = tool_defs - 2693
.iter() - 2694
.any(|definition| matches!(definition.name.as_str(), "mcp" | "find_tools")); - 2695
let mut search_page_fetches = Vec::new(); - 2696
let calls: Vec<PendingToolCall> = calls - 2697
.into_iter() - 2698
.filter(|call| { - 2699
if wants_live_data - 2700
&& discovery_offered - 2701
&& call.name == "webfetch" - 2702
&& is_search_results_fetch(&call.input) - 2703
{ - 2704
search_page_fetches.push(call.id.clone()); - 2705
false - 2706
} else { - 2707
true - 2708
} - 2709
}) - 2710
.collect(); - 2711
let inspection_ids = calls - 2712
.iter() - 2713
.filter(|call| { - 2714
matches!( - 2715
call.name.as_str(), - 2716
"read" - 2717
| "read_file" - 2718
| "glob" - 2719
| "grep" - 2720
| "inspect" - 2721
| "browse" - 2722
| "webfetch" - 2723
| "session_search" - 2724
| "search" - 2725
| "session_list" - 2726
) - 2727
}) - 2728
.map(|call| call.id.clone()) - 2729
.collect::<std::collections::HashSet<_>>(); - 2730
let mut results = self.execute_batch(calls, &cancel, &events).await; - 2731
let available = freshness_retrieval_hint(&tool_defs); - 2732
results.extend(search_page_fetches.into_iter().map(|id| { - 2733
( - 2734
id, - 2735
ToolRunOutput::Err(format!( - 2736
"[source-discovery]: webfetch retrieves a known page, not a search-results URL for a current fact. {available} Discover a search capability, then use its returned source URLs as evidence." - 2737
)), - 2738
) - 2739
})); - 2740
results.extend(gated.into_iter().map(|(call, gate)| { - 2741
let message = match gate { - 2742
CardGate::Fresh => { - 2743
format!("[freshness-check]: not shown — this asks for a value as it stands now and \ - 2744
nothing has been retrieved on this turn, so the card would carry a figure \ - 2745
from an earlier answer. {available} Retrieve current evidence first and \ - 2746
build the card from what it returns. If retrieval fails, say what failed.") - 2747
} - 2748
CardGate::Topic => { - 2749
"[topic-mismatch]: not shown — this card's own content has nothing to do \ - 2750
with what was asked. Build the card from what the current directive and \ - 2751
this turn's own tool results actually say, not from an earlier turn's data." - 2752
.to_string() - 2753
} - 2754
}; - 2755
(call.id, ToolRunOutput::Err(message)) - 2756
})); - 2757
self.record_worker_work(&task_assignments, &results).await; - 2758
// Identical-card repeat breaker: the no-op ack ("already - 2759
// displayed") is enough for a model that reads it; one that - 2760
// re-emits the same card anyway (measured live: up to nineteen - 2761
// times in one turn) would otherwise spend the whole turn budget - 2762
// on acks. The card it keeps re-emitting IS its answer, so after - 2763
// CARD_REPEAT_EXHAUSTION_THRESHOLD consecutive all-repeat batches - 2764
// the turn closes on that answer.
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.