Finish long-running agent parity
This commit is contained in:
136
PLAN.md
136
PLAN.md
@@ -23,106 +23,36 @@ execution targets one self-contained Mac.
|
|||||||
continuation, malformed DSML recovery, transient prefix-aware KV reuse,
|
continuation, malformed DSML recovery, transient prefix-aware KV reuse,
|
||||||
disconnect/queue cancellation, and shared model scheduling. The full
|
disconnect/queue cancellation, and shared model scheduling. The full
|
||||||
automated C/Rust differential corpus remains open.
|
automated C/Rust differential corpus remains open.
|
||||||
- Local sessions persist user, assistant, reasoning, and tool roles. The agent
|
- Local sessions now match the long-running `ds4_agent.c` baseline: the full
|
||||||
executes the complete `ds4_agent.c` starting tool set and reinjects the same
|
starting tool set, unlimited tool rounds, queued user guidance between tool
|
||||||
model-specific tool contract when sessions are opened or continued.
|
rounds, session date/time context, periodic tool-contract reminders,
|
||||||
- The remaining baseline gaps are long-running agent behavior and tool safety.
|
cooperative Stop, and explicit activity/failure states are implemented.
|
||||||
SSD streaming, speculative decoding, steering, GLM 5.2 execution, and
|
- Context compaction uses the reference soft and exact token-counted hard
|
||||||
DeepSeek V4 Pro execution are not implemented in the Rust executor. Some
|
triggers, private live-model summaries, bounded summary and tool-result
|
||||||
related catalog, validation, and preference plumbing already exists but must
|
retries, a recent verbatim tail, running-job observations, and compatible KV
|
||||||
not be treated as runtime support.
|
checkpoints. Every summary is a visible durable history marker carrying its
|
||||||
|
tail boundary; the full chat remains scrollable, while missing or
|
||||||
|
incompatible KV state rebuilds from the last marker, its tail, and later
|
||||||
|
messages. Manual compact is available after new chat following the latest
|
||||||
|
marker, alongside a checkpoint-discard/rebuild action.
|
||||||
|
- Focused coverage exercises triggers, summary bounds and sanitizing, tail
|
||||||
|
selection, queued guidance, checkpoint identity, running jobs, durable
|
||||||
|
compaction markers, relaunch, and continued tool work after rebuild.
|
||||||
|
- The next baseline gap is tool hardening and safety. SSD streaming,
|
||||||
|
speculative decoding, steering, GLM 5.2 execution, and DeepSeek V4 Pro
|
||||||
|
execution are not implemented in the Rust executor. Related catalog,
|
||||||
|
validation, and preference plumbing must not be treated as runtime support.
|
||||||
|
|
||||||
## Delivery order
|
## Delivery order
|
||||||
|
|
||||||
1. **Next:** long-running agent stability and remaining `ds4_agent.c` parity.
|
1. **Next:** tool hardening, approvals, and productive tool presentation.
|
||||||
2. Tool hardening, approvals, and productive tool presentation.
|
2. Remaining DS4 execution technology, starting with SSD streaming, then
|
||||||
3. Remaining DS4 execution technology, starting with SSD streaming, then
|
|
||||||
speculative decoding and the other Metal/runtime parity work.
|
speculative decoding and the other Metal/runtime parity work.
|
||||||
4. Additional model execution: GLM 5.2 and DeepSeek V4 Pro.
|
3. Additional model execution: GLM 5.2 and DeepSeek V4 Pro.
|
||||||
5. Product completion, exhaustive parity verification, and distribution.
|
4. Product completion, exhaustive parity verification, and distribution.
|
||||||
6. Optional extensions: Dev Brain and A2UI.
|
5. Optional extensions: Dev Brain and A2UI.
|
||||||
|
|
||||||
## 1. Next — long-running agent stability and `ds4_agent.c` parity
|
## 1. Next — tool hardening and safety
|
||||||
|
|
||||||
Goal: a local agent session must be able to run long read/edit/test loops,
|
|
||||||
cross the context limit repeatedly, survive interruption or relaunch, and
|
|
||||||
continue with the same durable task state as `ds4-agent`.
|
|
||||||
|
|
||||||
### Context compaction
|
|
||||||
|
|
||||||
- Port the reference soft trigger: compact before a user turn or tool
|
|
||||||
continuation at 85% context use, or when at most 8192 tokens remain, with the
|
|
||||||
free-token threshold capped to one eighth of small contexts.
|
|
||||||
- Port the hard trigger: before appending a tool result that would leave
|
|
||||||
insufficient answer room, compact once and retry. If it still does not fit,
|
|
||||||
return a bounded tool error that tells the model to request less output.
|
|
||||||
- Use the live model to generate an internal durable task-state summary. The
|
|
||||||
prompt and generated summary are private compaction work, never ordinary
|
|
||||||
user/assistant messages, and may not execute tools or retain thinking/DSML
|
|
||||||
control markup.
|
|
||||||
- Preserve goals, constraints, files touched, commands and important results,
|
|
||||||
decisions, known failures, and next steps. Prefer reloadable paths, ranges,
|
|
||||||
and commands over copying bulky data into the summary.
|
|
||||||
- Rebuild the model context exactly as the reference does: current system/tool
|
|
||||||
contract, durable summary, then a recent verbatim tail. Keep up to 10% of the
|
|
||||||
configured context as the tail, capped at 50000 tokens, and align it to a
|
|
||||||
user-turn boundary when possible.
|
|
||||||
- Generate at most the reference summary budget, stop at model control or tool
|
|
||||||
markers, and never let the private compaction exchange become the reusable
|
|
||||||
session prefix.
|
|
||||||
|
|
||||||
### Durable transition and recovery
|
|
||||||
|
|
||||||
- Treat transcript replacement and the new KV checkpoint as one logical
|
|
||||||
transition. Persist the compacted semantic transcript and checkpoint
|
|
||||||
metadata only after the rebuilt prefix is valid.
|
|
||||||
- On cancellation, summary failure, prefill failure, or application exit, keep
|
|
||||||
the previous durable transcript, invalidate any KV state contaminated by the
|
|
||||||
private compaction prompt, and make the next turn rebuild safely.
|
|
||||||
- Reopen a compacted session with the same summary and recent verbatim turns.
|
|
||||||
If its checkpoint is absent or incompatible, rebuild it from persisted
|
|
||||||
messages without changing the visible conversation.
|
|
||||||
- Preserve relevant live tool state across compaction. In particular, append a
|
|
||||||
compact observation for running shell jobs so the model can still inspect or
|
|
||||||
stop them after the context rebuild.
|
|
||||||
- Add a user-visible action equivalent to `/compact`, plus the reference
|
|
||||||
strip/rebuild behavior: discard a session KV payload without discarding its
|
|
||||||
transcript, then rebuild on the next use.
|
|
||||||
- Bind checkpoint compatibility to the model identity, quantization, context,
|
|
||||||
rendered transcript, and payload ABI. A model/configuration change must
|
|
||||||
rebuild rather than reuse an invalid prefix.
|
|
||||||
|
|
||||||
### Long-turn behavior
|
|
||||||
|
|
||||||
- Accept user input while an assistant/tool loop is active. Queue it visibly
|
|
||||||
and inject it after the current tool result, before the next assistant
|
|
||||||
continuation, matching `ds4_agent.c` instead of starting a competing turn.
|
|
||||||
- Preserve the reference rule that there is no arbitrary maximum tool-round
|
|
||||||
count. Completion, Stop, context pressure, or a real error ends the loop.
|
|
||||||
- Match the reference date/time context injection and periodic system/tool
|
|
||||||
prompt reminder so long or reopened sessions do not drift away from the tool
|
|
||||||
contract.
|
|
||||||
- Keep Stop cooperative across summarization, compacted-prefix prefill,
|
|
||||||
generation, and active tool work. A stop must always leave a transcript that
|
|
||||||
can be reopened.
|
|
||||||
|
|
||||||
### Presentation and verification
|
|
||||||
|
|
||||||
- Show explicit `Compacting`, rebuilding/prefill, queued-input, stopped, and
|
|
||||||
failed states without blocking the Iced event loop. Keep the compacted
|
|
||||||
summary inspectable without presenting the private prompt as user history.
|
|
||||||
- Add focused tests for soft and hard triggers, tail selection, tool-result
|
|
||||||
retry, cancellation rollback, checkpoint invalidation, queued input between
|
|
||||||
tool rounds, running-job preservation, and reopen after compaction.
|
|
||||||
- Add a reference fixture that runs a long tool loop through compaction, saves,
|
|
||||||
relaunches, and continues without losing the active task.
|
|
||||||
|
|
||||||
Exit criterion: repeat the reference `ds4-agent` long-context scenarios,
|
|
||||||
including compaction forced by a large tool result, stop during compaction, and
|
|
||||||
restart after a successful compaction. The same task state, recent turns, tool
|
|
||||||
contract, and running-job awareness must remain available.
|
|
||||||
|
|
||||||
## 2. Tool hardening and safety
|
|
||||||
|
|
||||||
Goal: make the existing tool set safe and clear enough for productive daily
|
Goal: make the existing tool set safe and clear enough for productive daily
|
||||||
use without weakening its ability to inspect, edit, build, and test a project.
|
use without weakening its ability to inspect, edit, build, and test a project.
|
||||||
@@ -159,13 +89,13 @@ Exit criterion: use the agent for a real inspect/edit/test cycle while every
|
|||||||
side effect is visible, risky actions require consent, Stop works at every
|
side effect is visible, risky actions require consent, Stop works at every
|
||||||
stage, and no file tool can escape the selected project.
|
stage, and no file tool can escape the selected project.
|
||||||
|
|
||||||
## 3. DS4 execution technology parity
|
## 2. DS4 execution technology parity
|
||||||
|
|
||||||
Goal: finish the model-independent Metal/runtime capabilities in `ds4.c`
|
Goal: finish the model-independent Metal/runtime capabilities in `ds4.c`
|
||||||
before adding larger model families. Every capability must be shared by local
|
before adding larger model families. Every capability must be shared by local
|
||||||
chat and the HTTP endpoint through the single process-wide model owner.
|
chat and the HTTP endpoint through the single process-wide model owner.
|
||||||
|
|
||||||
### 3.1 SSD streaming — first runtime priority
|
### 2.1 SSD streaming — first runtime priority
|
||||||
|
|
||||||
SSD streaming is the capacity prerequisite for larger models and therefore
|
SSD streaming is the capacity prerequisite for larger models and therefore
|
||||||
comes before GLM 5.2 and DeepSeek V4 Pro execution.
|
comes before GLM 5.2 and DeepSeek V4 Pro execution.
|
||||||
@@ -186,7 +116,7 @@ comes before GLM 5.2 and DeepSeek V4 Pro execution.
|
|||||||
the cache and I/O layer model-aware so later GLM/Pro milestones add policy and
|
the cache and I/O layer model-aware so later GLM/Pro milestones add policy and
|
||||||
graph support rather than a second streaming subsystem.
|
graph support rather than a second streaming subsystem.
|
||||||
|
|
||||||
### 3.2 Speculative decoding: legacy MTP and DSpark
|
### 2.2 Speculative decoding: legacy MTP and DSpark
|
||||||
|
|
||||||
- Load and validate the optional Flash legacy-MTP or DSpark support GGUF without
|
- Load and validate the optional Flash legacy-MTP or DSpark support GGUF without
|
||||||
treating either as a standalone model. Preserve exact support-kind and target
|
treating either as a standalone model. Preserve exact support-kind and target
|
||||||
@@ -206,7 +136,7 @@ comes before GLM 5.2 and DeepSeek V4 Pro execution.
|
|||||||
- GLM's in-model MTP path belongs to the GLM milestone, but it should reuse the
|
- GLM's in-model MTP path belongs to the GLM milestone, but it should reuse the
|
||||||
verifier/session machinery established here.
|
verifier/session machinery established here.
|
||||||
|
|
||||||
### 3.3 Remaining Metal execution controls
|
### 2.3 Remaining Metal execution controls
|
||||||
|
|
||||||
- Port directional steering files and exact FFN/attention application,
|
- Port directional steering files and exact FFN/attention application,
|
||||||
including DS4 defaults, validation, zero-scale behavior, and checkpoint/model
|
including DS4 defaults, validation, zero-scale behavior, and checkpoint/model
|
||||||
@@ -218,7 +148,7 @@ comes before GLM 5.2 and DeepSeek V4 Pro execution.
|
|||||||
- Add hardware-backed token/activation fixtures for each mode and keep the
|
- Add hardware-backed token/activation fixtures for each mode and keep the
|
||||||
ordinary resident Flash path unchanged when optional features are off.
|
ordinary resident Flash path unchanged when optional features are off.
|
||||||
|
|
||||||
### 3.4 Single-machine server batching
|
### 2.4 Single-machine server batching
|
||||||
|
|
||||||
- Port DS4's resident multi-session batching and server scheduling only after
|
- Port DS4's resident multi-session batching and server scheduling only after
|
||||||
the serialized path remains the correctness oracle. Preserve per-request
|
the serialized path remains the correctness oracle. Preserve per-request
|
||||||
@@ -233,7 +163,7 @@ resident, SSD-streamed, MTP, DSpark, steering, and batched-server
|
|||||||
configurations, with optional modes off producing the same baseline behavior
|
configurations, with optional modes off producing the same baseline behavior
|
||||||
as today.
|
as today.
|
||||||
|
|
||||||
## 4. Additional model execution
|
## 3. Additional model execution
|
||||||
|
|
||||||
Start these only after the shared capacity and execution technology above is
|
Start these only after the shared capacity and execution technology above is
|
||||||
stable. Catalog entries, settings, tokenizer work, or GGUF validation alone do
|
stable. Catalog entries, settings, tokenizer work, or GGUF validation alone do
|
||||||
@@ -269,7 +199,7 @@ Exit criterion: each advertised model passes the same local-agent, checkpoint,
|
|||||||
HTTP, SSD-capacity, cancellation, and deterministic token-output matrix as
|
HTTP, SSD-capacity, cancellation, and deterministic token-output matrix as
|
||||||
DeepSeek V4 Flash.
|
DeepSeek V4 Flash.
|
||||||
|
|
||||||
## 5. Product completion and verification
|
## 4. Product completion and verification
|
||||||
|
|
||||||
### Reference parity and regression coverage
|
### Reference parity and regression coverage
|
||||||
|
|
||||||
@@ -301,7 +231,7 @@ Exit criterion: a notarized build can be installed on a clean supported Mac,
|
|||||||
run the full local-agent and endpoint smoke matrix, restart into its previous
|
run the full local-agent and endpoint smoke matrix, restart into its previous
|
||||||
sessions, and update without losing projects, transcripts, models, or KV data.
|
sessions, and update without losing projects, transcripts, models, or KV data.
|
||||||
|
|
||||||
## 6. Optional future extensions
|
## 5. Optional future extensions
|
||||||
|
|
||||||
These are not DS4 baseline parity and must not delay the milestones above.
|
These are not DS4 baseline parity and must not delay the milestones above.
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ persisted as transcript roles and automatically continue the same model turn.
|
|||||||
File tools stay inside the selected project. Web tools ask before starting a
|
File tools stay inside the selected project. Web tools ask before starting a
|
||||||
visible Chrome profile.
|
visible Chrome profile.
|
||||||
|
|
||||||
|
Long sessions compact automatically while retaining the complete scrollable
|
||||||
|
chat. Each compaction appears in history with its durable summary, and a missing
|
||||||
|
or incompatible KV checkpoint rebuilds from the latest summary and subsequent
|
||||||
|
chat. An idle session's `…` menu can force compaction after new chat has been
|
||||||
|
added since the latest marker, or discard its checkpoint for a clean rebuild.
|
||||||
|
|
||||||
The app also listens on `127.0.0.1:4000` by default for Models, Chat
|
The app also listens on `127.0.0.1:4000` by default for Models, Chat
|
||||||
Completions, Completions, Anthropic Messages, and Responses APIs. The listener,
|
Completions, Completions, Anthropic Messages, and Responses APIs. The listener,
|
||||||
port, and opt-in CORS are configurable in Preferences. The
|
port, and opt-in CORS are configurable in Preferences. The
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE messages DROP COLUMN compaction_tail_start;
|
||||||
|
ALTER TABLE messages DROP COLUMN compaction;
|
||||||
2
migrations/20260726220000_add_compaction_messages/up.sql
Normal file
2
migrations/20260726220000_add_compaction_messages/up.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE messages ADD COLUMN compaction BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
ALTER TABLE messages ADD COLUMN compaction_tail_start INTEGER;
|
||||||
@@ -14,6 +14,7 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after context compaction.";
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
struct WebConfig {
|
struct WebConfig {
|
||||||
@@ -297,7 +298,7 @@ impl Tools {
|
|||||||
}
|
}
|
||||||
running.sort();
|
running.sort();
|
||||||
Some(format!(
|
Some(format!(
|
||||||
"Bash job update after context compaction. Running jobs still need explicit bash_status or bash_stop if relevant.\n{}",
|
"{COMPACTION_OBSERVATION_PREFIX} Running jobs still need explicit bash_status or bash_stop if relevant.\n{}",
|
||||||
running.concat()
|
running.concat()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -988,10 +989,10 @@ pub(crate) fn system_prompt(model: ModelChoice, extra: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn system_prompt_reminder(model: ModelChoice, extra: &str) -> String {
|
pub(crate) fn system_prompt_reminder(model: ModelChoice) -> String {
|
||||||
format!(
|
format!(
|
||||||
"[System prompt reminder follows.]\n{}\n[End system prompt reminder.]",
|
"[System prompt reminder follows.]\n{}\n[End system prompt reminder.]",
|
||||||
system_prompt(model, extra)
|
system_prompt(model, "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,7 +1257,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(prompt.ends_with("extra"));
|
assert!(prompt.ends_with("extra"));
|
||||||
assert!(
|
assert!(
|
||||||
system_prompt_reminder(ModelChoice::DeepSeekV4Flash, "extra")
|
system_prompt_reminder(ModelChoice::DeepSeekV4Flash)
|
||||||
.contains("[System prompt reminder follows.]")
|
.contains("[System prompt reminder follows.]")
|
||||||
);
|
);
|
||||||
assert!(datetime_context().starts_with("Current local date and time at session start:"));
|
assert!(datetime_context().starts_with("Current local date and time at session start:"));
|
||||||
|
|||||||
18
src/app.rs
18
src/app.rs
@@ -97,6 +97,8 @@ pub(crate) struct App {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
active_compaction: Option<generation::CompactionRequest>,
|
active_compaction: Option<generation::CompactionRequest>,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
active_tool_check: Option<generation::ToolResultCheck>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
|
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
active_tools: Option<crate::agent::ActiveTools>,
|
active_tools: Option<crate::agent::ActiveTools>,
|
||||||
@@ -110,6 +112,7 @@ pub(crate) struct App {
|
|||||||
pub(super) activity: Option<String>,
|
pub(super) activity: Option<String>,
|
||||||
stop_requested: bool,
|
stop_requested: bool,
|
||||||
system_prompt_seen_at: u32,
|
system_prompt_seen_at: u32,
|
||||||
|
manual_compaction_queued: bool,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: bool,
|
skip_compaction_once: bool,
|
||||||
}
|
}
|
||||||
@@ -307,6 +310,7 @@ impl App {
|
|||||||
active_generation: None,
|
active_generation: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
active_compaction: None,
|
active_compaction: None,
|
||||||
|
active_tool_check: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
agent_tools: None,
|
agent_tools: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -330,6 +334,7 @@ impl App {
|
|||||||
activity: None,
|
activity: None,
|
||||||
stop_requested: false,
|
stop_requested: false,
|
||||||
system_prompt_seen_at: 0,
|
system_prompt_seen_at: 0,
|
||||||
|
manual_compaction_queued: false,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: false,
|
skip_compaction_once: false,
|
||||||
}
|
}
|
||||||
@@ -404,6 +409,7 @@ impl App {
|
|||||||
active_generation: None,
|
active_generation: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
active_compaction: None,
|
active_compaction: None,
|
||||||
|
active_tool_check: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
agent_tools: None,
|
agent_tools: None,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -421,6 +427,7 @@ impl App {
|
|||||||
activity: None,
|
activity: None,
|
||||||
stop_requested: false,
|
stop_requested: false,
|
||||||
system_prompt_seen_at: 0,
|
system_prompt_seen_at: 0,
|
||||||
|
manual_compaction_queued: false,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: false,
|
skip_compaction_once: false,
|
||||||
}
|
}
|
||||||
@@ -790,6 +797,10 @@ impl App {
|
|||||||
if let Some(compaction) = &self.active_compaction {
|
if let Some(compaction) = &self.active_compaction {
|
||||||
compaction.active.cancel.store(true, Ordering::Relaxed);
|
compaction.active.cancel.store(true, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if let Some(check) = &self.active_tool_check {
|
||||||
|
check.active.cancel.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Message::GenerationTick => {
|
Message::GenerationTick => {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -945,8 +956,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::CompactSession(session_id) => {
|
Message::CompactSession(session_id) => {
|
||||||
self.session_menu = None;
|
self.session_menu = None;
|
||||||
if self.generating || self.selected_session != Some(session_id) {
|
if !self.can_compact_session(session_id) {
|
||||||
self.error = Some("Open an idle session before compacting it.".into());
|
self.error =
|
||||||
|
Some("Open an idle session with new chat before compacting it.".into());
|
||||||
} else {
|
} else {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let Err(error) = self.start_compaction(
|
if let Err(error) = self.start_compaction(
|
||||||
@@ -1617,6 +1629,8 @@ mod tests {
|
|||||||
user: false,
|
user: false,
|
||||||
tool: false,
|
tool: false,
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
reasoning: Some(String::new()),
|
reasoning: Some(String::new()),
|
||||||
reasoning_complete: false,
|
reasoning_complete: false,
|
||||||
reasoning_open: true,
|
reasoning_open: true,
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ pub(super) enum PendingContinuation {
|
|||||||
pub(super) struct CompactionRequest {
|
pub(super) struct CompactionRequest {
|
||||||
pub(super) active: ActiveGeneration,
|
pub(super) active: ActiveGeneration,
|
||||||
pending: PendingContinuation,
|
pending: PendingContinuation,
|
||||||
|
message_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ToolCheckStage {
|
||||||
|
Initial,
|
||||||
|
AfterCompaction,
|
||||||
|
BoundedError,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) struct ToolResultCheck {
|
||||||
|
pub(super) active: ActiveGeneration,
|
||||||
|
result: String,
|
||||||
|
stage: ToolCheckStage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -37,6 +53,8 @@ pub(crate) struct ChatMessage {
|
|||||||
pub(super) user: bool,
|
pub(super) user: bool,
|
||||||
pub(super) tool: bool,
|
pub(super) tool: bool,
|
||||||
pub(super) system: bool,
|
pub(super) system: bool,
|
||||||
|
pub(super) compaction: bool,
|
||||||
|
pub(super) compaction_tail_start: Option<i32>,
|
||||||
pub(super) reasoning: Option<String>,
|
pub(super) reasoning: Option<String>,
|
||||||
pub(super) reasoning_complete: bool,
|
pub(super) reasoning_complete: bool,
|
||||||
pub(super) reasoning_open: bool,
|
pub(super) reasoning_open: bool,
|
||||||
@@ -74,6 +92,8 @@ impl From<StoredMessage> for ChatMessage {
|
|||||||
user: message.user,
|
user: message.user,
|
||||||
tool: message.tool,
|
tool: message.tool,
|
||||||
system: message.system,
|
system: message.system,
|
||||||
|
compaction: message.compaction,
|
||||||
|
compaction_tail_start: message.compaction_tail_start,
|
||||||
reasoning: message.reasoning,
|
reasoning: message.reasoning,
|
||||||
reasoning_complete: message.reasoning_complete,
|
reasoning_complete: message.reasoning_complete,
|
||||||
reasoning_open: false,
|
reasoning_open: false,
|
||||||
@@ -85,7 +105,75 @@ impl From<StoredMessage> for ChatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn chat_turn(message: &ChatMessage) -> ChatTurn {
|
||||||
|
ChatTurn {
|
||||||
|
user: message.user,
|
||||||
|
tool: message.tool,
|
||||||
|
system: message.system,
|
||||||
|
skip_previous_eos: false,
|
||||||
|
reasoning: message.reasoning.clone(),
|
||||||
|
reasoning_complete: message.reasoning_complete,
|
||||||
|
content: message.content.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queued_prompt(messages: impl IntoIterator<Item = String>) -> Option<String> {
|
||||||
|
let messages = messages.into_iter().collect::<Vec<_>>();
|
||||||
|
match messages.as_slice() {
|
||||||
|
[] => None,
|
||||||
|
[message] => Some(message.clone()),
|
||||||
|
_ => Some(
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, message)| format!("Queued user message {}:\n{message}", index + 1))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compacted_context_start(messages: &[ChatMessage]) -> usize {
|
||||||
|
let Some(marker_index) = messages.iter().rposition(|message| message.compaction) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
messages[marker_index]
|
||||||
|
.compaction_tail_start
|
||||||
|
.and_then(|id| {
|
||||||
|
messages[..marker_index]
|
||||||
|
.iter()
|
||||||
|
.position(|message| message.id == id)
|
||||||
|
})
|
||||||
|
.unwrap_or(marker_index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool {
|
||||||
|
let start = messages
|
||||||
|
.iter()
|
||||||
|
.rposition(|message| message.compaction)
|
||||||
|
.map_or(0, |index| index + 1);
|
||||||
|
messages[start..].iter().any(|message| {
|
||||||
|
!message.system
|
||||||
|
&& !message.compaction
|
||||||
|
&& !message
|
||||||
|
.content
|
||||||
|
.starts_with(crate::agent::COMPACTION_OBSERVATION_PREFIX)
|
||||||
|
&& (!message.content.trim().is_empty()
|
||||||
|
|| message
|
||||||
|
.reasoning
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|reasoning| !reasoning.trim().is_empty()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
pub(super) fn can_compact_session(&self, session_id: i32) -> bool {
|
||||||
|
!self.generating
|
||||||
|
&& self.selected_session == Some(session_id)
|
||||||
|
&& has_chat_after_last_compaction(&self.conversation)
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn start_generation(&mut self) {
|
pub(super) fn start_generation(&mut self) {
|
||||||
if self.selected_project.is_none() {
|
if self.selected_project.is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -94,6 +182,19 @@ impl App {
|
|||||||
if prompt.is_empty() {
|
if prompt.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
if prompt == "/compact" {
|
||||||
|
self.composer.clear();
|
||||||
|
if self.generating {
|
||||||
|
self.manual_compaction_queued = true;
|
||||||
|
self.activity = Some("Compaction queued for the next safe point…".into());
|
||||||
|
} else if let Err(error) =
|
||||||
|
self.start_compaction(PendingContinuation::None, "manual /compact request")
|
||||||
|
{
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.generating {
|
if self.generating {
|
||||||
self.queued_inputs.push_back(prompt);
|
self.queued_inputs.push_back(prompt);
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
@@ -109,16 +210,6 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if prompt == "/compact" {
|
|
||||||
self.composer.clear();
|
|
||||||
if let Err(error) =
|
|
||||||
self.start_compaction(PendingContinuation::None, "manual /compact request")
|
|
||||||
{
|
|
||||||
self.error = Some(error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
if !std::mem::take(&mut self.skip_compaction_once)
|
if !std::mem::take(&mut self.skip_compaction_once)
|
||||||
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
||||||
{
|
{
|
||||||
@@ -163,24 +254,13 @@ impl App {
|
|||||||
let reminder_injected = self.system_prompt_reminder_due();
|
let reminder_injected = self.system_prompt_reminder_due();
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if reminder_injected {
|
if reminder_injected {
|
||||||
injected_system.push(crate::agent::system_prompt_reminder(
|
injected_system.extend(self.system_prompt_reminders(model));
|
||||||
model,
|
|
||||||
&self.config.generation.system_prompt,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let mut messages = self
|
let mut messages = self
|
||||||
.conversation
|
.model_chat_messages()
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|message| ChatTurn {
|
.map(chat_turn)
|
||||||
user: message.user,
|
|
||||||
tool: message.tool,
|
|
||||||
system: message.system,
|
|
||||||
skip_previous_eos: false,
|
|
||||||
reasoning: message.reasoning.clone(),
|
|
||||||
reasoning_complete: message.reasoning_complete,
|
|
||||||
content: message.content.clone(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
messages.extend(injected_system.iter().map(|content| ChatTurn {
|
messages.extend(injected_system.iter().map(|content| ChatTurn {
|
||||||
@@ -290,12 +370,24 @@ impl App {
|
|||||||
crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
|
crate::agent::prompt_reminder_due(self.context_used, self.system_prompt_seen_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn system_prompt_reminders(&self, model: ModelChoice) -> Vec<String> {
|
||||||
|
let mut reminders = vec![crate::agent::system_prompt_reminder(model)];
|
||||||
|
if !self.config.generation.system_prompt.trim().is_empty() {
|
||||||
|
reminders.push(self.config.generation.system_prompt.clone());
|
||||||
|
}
|
||||||
|
reminders
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn poll_generation(&mut self) -> bool {
|
pub(super) fn poll_generation(&mut self) -> bool {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if self.active_compaction.is_some() {
|
if self.active_compaction.is_some() {
|
||||||
return self.poll_compaction();
|
return self.poll_compaction();
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
if self.active_tool_check.is_some() {
|
||||||
|
return self.poll_tool_result_check();
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
if let Some(active) = &self.active_tools {
|
if let Some(active) = &self.active_tools {
|
||||||
match crate::agent::try_tool_result(active) {
|
match crate::agent::try_tool_result(active) {
|
||||||
Ok(Some(result)) => {
|
Ok(Some(result)) => {
|
||||||
@@ -304,10 +396,21 @@ impl App {
|
|||||||
if cancelled {
|
if cancelled {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.activity = Some("Stopped".into());
|
self.activity = Some("Stopped".into());
|
||||||
|
self.start_next_queued();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if let Err(error) = self.continue_after_tool_result(&result) {
|
let continuation = if self.manual_compaction_queued {
|
||||||
|
self.manual_compaction_queued = false;
|
||||||
|
self.start_compaction(
|
||||||
|
PendingContinuation::Tool(result),
|
||||||
|
"queued manual compaction",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
self.start_tool_result_check(result, ToolCheckStage::Initial)
|
||||||
|
};
|
||||||
|
if let Err(error) = continuation {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -337,6 +440,9 @@ impl App {
|
|||||||
loop {
|
loop {
|
||||||
match active.events.try_recv() {
|
match active.events.try_recv() {
|
||||||
Ok(GenerationEvent::Loading) => {}
|
Ok(GenerationEvent::Loading) => {}
|
||||||
|
Ok(GenerationEvent::Activity(activity)) => {
|
||||||
|
self.activity = Some(activity.into());
|
||||||
|
}
|
||||||
Ok(GenerationEvent::Compacted(_)) => {
|
Ok(GenerationEvent::Compacted(_)) => {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.error =
|
self.error =
|
||||||
@@ -358,6 +464,16 @@ impl App {
|
|||||||
self.context_used = used;
|
self.context_used = used;
|
||||||
self.context_limit = limit;
|
self.context_limit = limit;
|
||||||
self.tokens_per_second = tokens_per_second;
|
self.tokens_per_second = tokens_per_second;
|
||||||
|
if !self.stop_requested {
|
||||||
|
self.activity = Some(
|
||||||
|
if tokens_per_second.is_some() {
|
||||||
|
"Generating…"
|
||||||
|
} else {
|
||||||
|
"Reading conversation…"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
context_changed = true;
|
context_changed = true;
|
||||||
}
|
}
|
||||||
Ok(GenerationEvent::Finished(result)) => {
|
Ok(GenerationEvent::Finished(result)) => {
|
||||||
@@ -365,6 +481,8 @@ impl App {
|
|||||||
Ok(_) if self.stop_requested => {
|
Ok(_) if self.stop_requested => {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.activity = Some("Stopped".into());
|
self.activity = Some("Stopped".into());
|
||||||
|
start_queued =
|
||||||
|
!self.queued_inputs.is_empty() || self.manual_compaction_queued;
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let model = self.config.model;
|
let model = self.config.model;
|
||||||
@@ -383,7 +501,8 @@ impl App {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.activity = None;
|
self.activity = None;
|
||||||
start_queued = !self.queued_inputs.is_empty();
|
start_queued = !self.queued_inputs.is_empty()
|
||||||
|
|| self.manual_compaction_queued;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.active_tools = Some(crate::agent::error_async(error));
|
self.active_tools = Some(crate::agent::error_async(error));
|
||||||
@@ -403,6 +522,13 @@ impl App {
|
|||||||
self.active_generation = None;
|
self.active_generation = None;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
Ok(GenerationEvent::Measured(_)) => {
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(
|
||||||
|
"The model runtime returned an unexpected context measurement.".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Err(TryRecvError::Empty) => break,
|
Err(TryRecvError::Empty) => break,
|
||||||
Err(TryRecvError::Disconnected) => {
|
Err(TryRecvError::Disconnected) => {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
@@ -467,7 +593,17 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start_next_queued(&mut self) {
|
fn start_next_queued(&mut self) {
|
||||||
if let Some(prompt) = self.queued_inputs.pop_front() {
|
#[cfg(target_os = "macos")]
|
||||||
|
if std::mem::take(&mut self.manual_compaction_queued) {
|
||||||
|
if let Err(error) =
|
||||||
|
self.start_compaction(PendingContinuation::None, "queued manual compaction")
|
||||||
|
{
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(prompt) = queued_prompt(self.queued_inputs.drain(..)) {
|
||||||
self.composer = prompt;
|
self.composer = prompt;
|
||||||
self.start_generation();
|
self.start_generation();
|
||||||
}
|
}
|
||||||
@@ -493,6 +629,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
|
let tools = Arc::clone(&self.agent_tools.as_ref().unwrap().1);
|
||||||
self.active_tools = Some(crate::agent::execute_async(tools, calls));
|
self.active_tools = Some(crate::agent::execute_async(tools, calls));
|
||||||
|
self.activity = Some("Running tools…".into());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,18 +638,7 @@ impl App {
|
|||||||
let session_id = self
|
let session_id = self
|
||||||
.selected_session
|
.selected_session
|
||||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
||||||
if !std::mem::take(&mut self.skip_compaction_once)
|
self.skip_compaction_once = false;
|
||||||
&& crate::compaction::tool_result_needs_compaction(
|
|
||||||
self.context_used,
|
|
||||||
self.context_limit,
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
return self.start_compaction(
|
|
||||||
PendingContinuation::Tool(result.to_owned()),
|
|
||||||
"context pressure before tool continuation",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let model = self.config.model;
|
let model = self.config.model;
|
||||||
let mut effective = crate::settings::effective_settings(
|
let mut effective = crate::settings::effective_settings(
|
||||||
model,
|
model,
|
||||||
@@ -527,10 +653,12 @@ impl App {
|
|||||||
self.compaction_summary(),
|
self.compaction_summary(),
|
||||||
);
|
);
|
||||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||||
let queued = self.queued_inputs.iter().cloned().collect::<Vec<_>>();
|
let queued = queued_prompt(self.queued_inputs.drain(..));
|
||||||
let reminder = self.system_prompt_reminder_due().then(|| {
|
let reminders = if self.system_prompt_reminder_due() {
|
||||||
crate::agent::system_prompt_reminder(model, &self.config.generation.system_prompt)
|
self.system_prompt_reminders(model)
|
||||||
});
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
let mut saved = self
|
let mut saved = self
|
||||||
.database
|
.database
|
||||||
.as_mut()
|
.as_mut()
|
||||||
@@ -538,13 +666,12 @@ impl App {
|
|||||||
.continue_tool_turn(
|
.continue_tool_turn(
|
||||||
session_id,
|
session_id,
|
||||||
result,
|
result,
|
||||||
&queued,
|
queued.as_deref(),
|
||||||
reminder.as_deref(),
|
&reminders,
|
||||||
assistant_reasoning,
|
assistant_reasoning,
|
||||||
)
|
)
|
||||||
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
|
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
|
||||||
self.queued_inputs.clear();
|
if !reminders.is_empty() {
|
||||||
if reminder.is_some() {
|
|
||||||
self.system_prompt_seen_at = self.context_used;
|
self.system_prompt_seen_at = self.context_used;
|
||||||
}
|
}
|
||||||
let mut assistant = ChatMessage::from(saved.pop().unwrap());
|
let mut assistant = ChatMessage::from(saved.pop().unwrap());
|
||||||
@@ -552,17 +679,9 @@ impl App {
|
|||||||
self.conversation.push(ChatMessage::from(message));
|
self.conversation.push(ChatMessage::from(message));
|
||||||
}
|
}
|
||||||
let messages = self
|
let messages = self
|
||||||
.conversation
|
.model_chat_messages()
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|message| ChatTurn {
|
.map(chat_turn)
|
||||||
user: message.user,
|
|
||||||
tool: message.tool,
|
|
||||||
system: message.system,
|
|
||||||
skip_previous_eos: false,
|
|
||||||
reasoning: message.reasoning.clone(),
|
|
||||||
reasoning_complete: message.reasoning_complete,
|
|
||||||
content: message.content.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
assistant.reasoning_open = assistant_reasoning;
|
assistant.reasoning_open = assistant_reasoning;
|
||||||
self.conversation.push(assistant);
|
self.conversation.push(assistant);
|
||||||
@@ -585,6 +704,163 @@ impl App {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn start_tool_result_check(
|
||||||
|
&mut self,
|
||||||
|
result: String,
|
||||||
|
stage: ToolCheckStage,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if matches!(stage, ToolCheckStage::Initial)
|
||||||
|
&& crate::compaction::should_compact(self.context_used, self.context_limit)
|
||||||
|
{
|
||||||
|
return self.start_compaction(
|
||||||
|
PendingContinuation::Tool(result),
|
||||||
|
"soft limit before tool continuation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let model = self.config.model;
|
||||||
|
let mut effective = crate::settings::effective_settings(
|
||||||
|
model,
|
||||||
|
&self.config.generation,
|
||||||
|
&self.config.runtime,
|
||||||
|
&models_path(),
|
||||||
|
)?;
|
||||||
|
effective.turn.system_prompt =
|
||||||
|
crate::agent::system_prompt(model, &effective.turn.system_prompt);
|
||||||
|
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
|
||||||
|
&effective.turn.system_prompt,
|
||||||
|
self.compaction_summary(),
|
||||||
|
);
|
||||||
|
let mut messages = self
|
||||||
|
.model_chat_messages()
|
||||||
|
.into_iter()
|
||||||
|
.map(chat_turn)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
messages.push(ChatTurn {
|
||||||
|
user: false,
|
||||||
|
tool: true,
|
||||||
|
system: false,
|
||||||
|
skip_previous_eos: false,
|
||||||
|
reasoning: None,
|
||||||
|
reasoning_complete: true,
|
||||||
|
content: result.clone(),
|
||||||
|
});
|
||||||
|
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||||
|
let active = self
|
||||||
|
.generation_service
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "The model runtime is unavailable.".to_owned())?
|
||||||
|
.measure_context(effective.engine, effective.turn, messages, idle_timeout)?;
|
||||||
|
self.active_tool_check = Some(ToolResultCheck {
|
||||||
|
active,
|
||||||
|
result,
|
||||||
|
stage,
|
||||||
|
});
|
||||||
|
self.activity = Some("Checking tool result context…".into());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn poll_tool_result_check(&mut self) -> bool {
|
||||||
|
let event = self
|
||||||
|
.active_tool_check
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.active
|
||||||
|
.events
|
||||||
|
.try_recv();
|
||||||
|
match event {
|
||||||
|
Ok(GenerationEvent::Loading) => {
|
||||||
|
self.activity = Some("Loading model…".into());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(GenerationEvent::Measured(Ok(projected))) => {
|
||||||
|
let check = self.active_tool_check.take().unwrap();
|
||||||
|
if self.manual_compaction_queued && matches!(check.stage, ToolCheckStage::Initial) {
|
||||||
|
self.manual_compaction_queued = false;
|
||||||
|
if let Err(error) = self.start_compaction(
|
||||||
|
PendingContinuation::Tool(check.result),
|
||||||
|
"queued manual compaction",
|
||||||
|
) {
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let reserve = if matches!(check.stage, ToolCheckStage::BoundedError) {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
crate::compaction::tool_result_reserve(self.context_limit)
|
||||||
|
};
|
||||||
|
if crate::compaction::tool_result_fits(projected, self.context_limit, reserve) {
|
||||||
|
if let Err(error) = self.continue_after_tool_result(&check.result) {
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match check.stage {
|
||||||
|
ToolCheckStage::Initial => {
|
||||||
|
if let Err(error) = self.start_compaction(
|
||||||
|
PendingContinuation::Tool(check.result),
|
||||||
|
"tool result would exceed context",
|
||||||
|
) {
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolCheckStage::AfterCompaction => {
|
||||||
|
let error = crate::compaction::bounded_tool_error(
|
||||||
|
projected,
|
||||||
|
self.context_limit,
|
||||||
|
reserve,
|
||||||
|
);
|
||||||
|
if let Err(error) =
|
||||||
|
self.start_tool_result_check(error, ToolCheckStage::BoundedError)
|
||||||
|
{
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolCheckStage::BoundedError => {
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some("context full after compaction".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(GenerationEvent::Measured(Err(error))) => {
|
||||||
|
self.active_tool_check = None;
|
||||||
|
self.generating = false;
|
||||||
|
if self.stop_requested {
|
||||||
|
self.activity = Some("Stopped".into());
|
||||||
|
} else {
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some(error);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(GenerationEvent::Activity(activity)) => {
|
||||||
|
self.activity = Some(activity.into());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(_) => false,
|
||||||
|
Err(TryRecvError::Empty) => false,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
self.active_tool_check = None;
|
||||||
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
|
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(super) fn start_compaction(
|
pub(super) fn start_compaction(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -600,23 +876,14 @@ impl App {
|
|||||||
)?;
|
)?;
|
||||||
effective.turn.system_prompt =
|
effective.turn.system_prompt =
|
||||||
crate::agent::system_prompt(model, &effective.turn.system_prompt);
|
crate::agent::system_prompt(model, &effective.turn.system_prompt);
|
||||||
|
let rebuild_system_prompt = effective.turn.system_prompt.clone();
|
||||||
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
|
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
|
||||||
&effective.turn.system_prompt,
|
&effective.turn.system_prompt,
|
||||||
self.compaction_summary(),
|
self.compaction_summary(),
|
||||||
);
|
);
|
||||||
let messages = self
|
let model_messages = self.model_chat_messages();
|
||||||
.conversation
|
let message_ids = model_messages.iter().map(|message| message.id).collect();
|
||||||
.iter()
|
let messages = model_messages.into_iter().map(chat_turn).collect();
|
||||||
.map(|message| ChatTurn {
|
|
||||||
user: message.user,
|
|
||||||
tool: message.tool,
|
|
||||||
system: message.system,
|
|
||||||
skip_previous_eos: false,
|
|
||||||
reasoning: message.reasoning.clone(),
|
|
||||||
reasoning_complete: message.reasoning_complete,
|
|
||||||
content: message.content.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
|
||||||
let active = self
|
let active = self
|
||||||
.generation_service
|
.generation_service
|
||||||
@@ -627,13 +894,19 @@ impl App {
|
|||||||
effective.turn,
|
effective.turn,
|
||||||
messages,
|
messages,
|
||||||
reason,
|
reason,
|
||||||
|
rebuild_system_prompt,
|
||||||
session_compaction_checkpoint_path(
|
session_compaction_checkpoint_path(
|
||||||
self.selected_session
|
self.selected_session
|
||||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?,
|
.ok_or_else(|| "The active session is unavailable.".to_owned())?,
|
||||||
),
|
),
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
)?;
|
)?;
|
||||||
self.active_compaction = Some(CompactionRequest { active, pending });
|
self.active_compaction = Some(CompactionRequest {
|
||||||
|
active,
|
||||||
|
pending,
|
||||||
|
message_ids,
|
||||||
|
});
|
||||||
|
self.manual_compaction_queued = false;
|
||||||
self.generating = true;
|
self.generating = true;
|
||||||
self.activity = Some("Compacting durable task state…".into());
|
self.activity = Some("Compacting durable task state…".into());
|
||||||
self.tokens_per_second = None;
|
self.tokens_per_second = None;
|
||||||
@@ -648,6 +921,7 @@ impl App {
|
|||||||
loop {
|
loop {
|
||||||
match request.active.events.try_recv() {
|
match request.active.events.try_recv() {
|
||||||
Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()),
|
Ok(GenerationEvent::Loading) => self.activity = Some("Loading model…".into()),
|
||||||
|
Ok(GenerationEvent::Activity(activity)) => self.activity = Some(activity.into()),
|
||||||
Ok(GenerationEvent::Context {
|
Ok(GenerationEvent::Context {
|
||||||
used,
|
used,
|
||||||
limit,
|
limit,
|
||||||
@@ -661,10 +935,12 @@ impl App {
|
|||||||
let request = self.active_compaction.take().unwrap();
|
let request = self.active_compaction.take().unwrap();
|
||||||
match result {
|
match result {
|
||||||
Ok(compacted) => {
|
Ok(compacted) => {
|
||||||
if let Err(error) = self.apply_compaction(&compacted) {
|
if let Err(error) =
|
||||||
|
self.apply_compaction(&compacted, &request.message_ids)
|
||||||
|
{
|
||||||
let _ = fs::remove_file(&compacted.checkpoint);
|
let _ = fs::remove_file(&compacted.checkpoint);
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.activity = None;
|
self.activity = Some("Failed".into());
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -680,14 +956,12 @@ impl App {
|
|||||||
self.start_generation();
|
self.start_generation();
|
||||||
}
|
}
|
||||||
PendingContinuation::Tool(result) => {
|
PendingContinuation::Tool(result) => {
|
||||||
let result = crate::compaction::bounded_tool_result(
|
if let Err(error) = self.start_tool_result_check(
|
||||||
self.context_used,
|
|
||||||
self.context_limit,
|
|
||||||
result,
|
result,
|
||||||
);
|
ToolCheckStage::AfterCompaction,
|
||||||
self.skip_compaction_once = true;
|
) {
|
||||||
if let Err(error) = self.continue_after_tool_result(&result) {
|
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
|
self.activity = Some("Failed".into());
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,6 +970,13 @@ impl App {
|
|||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
|
if let PendingContinuation::User(prompt) = request.pending {
|
||||||
|
if self.composer.trim().is_empty() {
|
||||||
|
self.composer = prompt;
|
||||||
|
} else {
|
||||||
|
self.queued_inputs.push_front(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
if self.stop_requested {
|
if self.stop_requested {
|
||||||
self.activity = Some("Stopped".into());
|
self.activity = Some("Stopped".into());
|
||||||
} else {
|
} else {
|
||||||
@@ -706,12 +987,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(GenerationEvent::Finished(_)) | Ok(GenerationEvent::Chunk { .. }) => {}
|
Ok(GenerationEvent::Finished(_))
|
||||||
|
| Ok(GenerationEvent::Chunk { .. })
|
||||||
|
| Ok(GenerationEvent::Measured(_)) => {}
|
||||||
Err(TryRecvError::Empty) => return false,
|
Err(TryRecvError::Empty) => return false,
|
||||||
Err(TryRecvError::Disconnected) => {
|
Err(TryRecvError::Disconnected) => {
|
||||||
self.active_compaction = None;
|
self.active_compaction = None;
|
||||||
self.generating = false;
|
self.generating = false;
|
||||||
self.activity = None;
|
self.activity = Some("Failed".into());
|
||||||
self.error = Some("The model runtime stopped unexpectedly.".into());
|
self.error = Some("The model runtime stopped unexpectedly.".into());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -723,51 +1006,37 @@ impl App {
|
|||||||
fn apply_compaction(
|
fn apply_compaction(
|
||||||
&mut self,
|
&mut self,
|
||||||
compacted: &crate::engine::CompactionOutput,
|
compacted: &crate::engine::CompactionOutput,
|
||||||
|
message_ids: &[i32],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let session_id = self
|
let session_id = self
|
||||||
.selected_session
|
.selected_session
|
||||||
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
|
||||||
let mut tail = compacted
|
let running_jobs = if let Some((tools_session, tools)) = &self.agent_tools
|
||||||
.tail
|
|
||||||
.iter()
|
|
||||||
.map(|message| crate::database::MessageDraft {
|
|
||||||
user: message.user,
|
|
||||||
tool: message.tool,
|
|
||||||
system: message.system,
|
|
||||||
reasoning: message.reasoning.clone(),
|
|
||||||
reasoning_complete: message.reasoning_complete,
|
|
||||||
content: message.content.clone(),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if let Some((tools_session, tools)) = &self.agent_tools
|
|
||||||
&& *tools_session == session_id
|
&& *tools_session == session_id
|
||||||
&& let Some(observation) = tools
|
{
|
||||||
|
tools
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
.compaction_observation()
|
.compaction_observation()
|
||||||
{
|
} else {
|
||||||
tail.push(crate::database::MessageDraft {
|
None
|
||||||
user: false,
|
};
|
||||||
tool: true,
|
let tail_start = message_ids.get(compacted.tail_start).copied();
|
||||||
system: false,
|
|
||||||
reasoning: None,
|
|
||||||
reasoning_complete: true,
|
|
||||||
content: observation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let messages = self
|
let messages = self
|
||||||
.database
|
.database
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.ok_or_else(|| "The project database is unavailable.".to_owned())?
|
.ok_or_else(|| "The project database is unavailable.".to_owned())?
|
||||||
.replace_with_compacted_transcript(
|
.record_compaction(
|
||||||
session_id,
|
session_id,
|
||||||
&compacted.summary,
|
&compacted.summary,
|
||||||
&tail,
|
tail_start,
|
||||||
|
running_jobs.as_deref(),
|
||||||
compacted.context_tokens,
|
compacted.context_tokens,
|
||||||
self.context_limit,
|
self.context_limit,
|
||||||
)
|
)
|
||||||
.map_err(|error| format!("Could not save compacted conversation: {error}"))?;
|
.map_err(|error| format!("Could not save compacted conversation: {error}"))?;
|
||||||
self.conversation = messages.into_iter().map(ChatMessage::from).collect();
|
self.conversation
|
||||||
|
.extend(messages.into_iter().map(ChatMessage::from));
|
||||||
if let Some(session) = self
|
if let Some(session) = self
|
||||||
.projects
|
.projects
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -780,15 +1049,24 @@ impl App {
|
|||||||
session.last_tokens_per_second = None;
|
session.last_tokens_per_second = None;
|
||||||
}
|
}
|
||||||
let final_checkpoint = session_checkpoint_path(session_id);
|
let final_checkpoint = session_checkpoint_path(session_id);
|
||||||
fs::rename(&compacted.checkpoint, &final_checkpoint).map_err(|error| {
|
if let Err(error) = fs::rename(&compacted.checkpoint, &final_checkpoint) {
|
||||||
format!(
|
let _ = fs::remove_file(&compacted.checkpoint);
|
||||||
|
self.error = Some(format!(
|
||||||
"The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use."
|
"The compacted conversation was saved, but its checkpoint could not be promoted: {error}. It will rebuild on next use."
|
||||||
)
|
));
|
||||||
})?;
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn compaction_summary(&self) -> Option<&str> {
|
pub(super) fn compaction_summary(&self) -> Option<&str> {
|
||||||
|
if let Some(summary) = self
|
||||||
|
.conversation
|
||||||
|
.iter()
|
||||||
|
.rfind(|message| message.compaction)
|
||||||
|
.map(|message| message.content.as_str())
|
||||||
|
{
|
||||||
|
return Some(summary);
|
||||||
|
}
|
||||||
let session_id = self.selected_session?;
|
let session_id = self.selected_session?;
|
||||||
self.projects
|
self.projects
|
||||||
.iter()
|
.iter()
|
||||||
@@ -797,6 +1075,14 @@ impl App {
|
|||||||
.and_then(|session| session.compacted_summary.as_deref())
|
.and_then(|session| session.compacted_summary.as_deref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn model_chat_messages(&self) -> Vec<&ChatMessage> {
|
||||||
|
let start = compacted_context_start(&self.conversation);
|
||||||
|
self.conversation[start..]
|
||||||
|
.iter()
|
||||||
|
.filter(|message| !message.compaction)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Asks the model for a session title without opening a session for it: the
|
/// Asks the model for a session title without opening a session for it: the
|
||||||
/// turn runs against the shared transient KV cache and only its text is kept.
|
/// turn runs against the shared transient KV cache and only its text is kept.
|
||||||
///
|
///
|
||||||
@@ -826,7 +1112,7 @@ impl App {
|
|||||||
// pass summarize the user's opening message on its own.
|
// pass summarize the user's opening message on its own.
|
||||||
let mut messages = stored
|
let mut messages = stored
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|message| !message.content.trim().is_empty())
|
.filter(|message| !message.compaction && !message.content.trim().is_empty())
|
||||||
.map(|message| ChatTurn {
|
.map(|message| ChatTurn {
|
||||||
user: message.user,
|
user: message.user,
|
||||||
tool: message.tool,
|
tool: message.tool,
|
||||||
@@ -980,3 +1266,89 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
|
|||||||
None => title.to_owned(),
|
None => title.to_owned(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
ChatMessage, compacted_context_start, has_chat_after_last_compaction, queued_prompt,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queued_guidance_is_one_reference_style_user_turn() {
|
||||||
|
assert_eq!(queued_prompt(Vec::new()), None);
|
||||||
|
assert_eq!(queued_prompt(["one".to_owned()]), Some("one".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
queued_prompt(["one".to_owned(), "two".to_owned()]),
|
||||||
|
Some("Queued user message 1:\none\n\nQueued user message 2:\ntwo".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_compaction_selects_its_tail_without_hiding_history() {
|
||||||
|
let message = |id: i32, compaction: bool, tail: Option<i32>| ChatMessage {
|
||||||
|
id,
|
||||||
|
user: !compaction,
|
||||||
|
tool: false,
|
||||||
|
system: compaction,
|
||||||
|
compaction,
|
||||||
|
compaction_tail_start: tail,
|
||||||
|
reasoning: None,
|
||||||
|
reasoning_complete: true,
|
||||||
|
reasoning_open: false,
|
||||||
|
content: format!("message {id}"),
|
||||||
|
markdown: Vec::new(),
|
||||||
|
};
|
||||||
|
let history = vec![
|
||||||
|
message(1, false, None),
|
||||||
|
message(2, false, None),
|
||||||
|
message(3, true, Some(2)),
|
||||||
|
message(4, false, None),
|
||||||
|
message(5, true, Some(4)),
|
||||||
|
message(6, false, None),
|
||||||
|
message(7, true, Some(6)),
|
||||||
|
message(8, false, None),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert_eq!(history.len(), 8);
|
||||||
|
assert_eq!(compacted_context_start(&history), 5);
|
||||||
|
assert_eq!(history[compacted_context_start(&history)].id, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forced_compaction_requires_new_visible_chat() {
|
||||||
|
let message =
|
||||||
|
|id: i32, user: bool, tool: bool, system: bool, compaction: bool| ChatMessage {
|
||||||
|
id,
|
||||||
|
user,
|
||||||
|
tool,
|
||||||
|
system,
|
||||||
|
compaction,
|
||||||
|
compaction_tail_start: None,
|
||||||
|
reasoning: None,
|
||||||
|
reasoning_complete: true,
|
||||||
|
reasoning_open: false,
|
||||||
|
content: format!("message {id}"),
|
||||||
|
markdown: Vec::new(),
|
||||||
|
};
|
||||||
|
let mut history = vec![
|
||||||
|
message(1, true, false, false, false),
|
||||||
|
message(2, false, false, true, true),
|
||||||
|
ChatMessage {
|
||||||
|
content: format!(
|
||||||
|
"{} running job",
|
||||||
|
crate::agent::COMPACTION_OBSERVATION_PREFIX
|
||||||
|
),
|
||||||
|
..message(3, false, true, false, false)
|
||||||
|
},
|
||||||
|
message(4, false, false, true, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(!has_chat_after_last_compaction(&history));
|
||||||
|
history.push(message(5, false, true, false, false));
|
||||||
|
assert!(has_chat_after_last_compaction(&history));
|
||||||
|
history.push(message(6, false, false, true, true));
|
||||||
|
assert!(!has_chat_after_last_compaction(&history));
|
||||||
|
history.push(message(7, true, false, false, false));
|
||||||
|
assert!(has_chat_after_last_compaction(&history));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -441,13 +441,18 @@ impl App {
|
|||||||
/// on the state the session is in.
|
/// on the state the session is in.
|
||||||
fn session_menu_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> {
|
fn session_menu_panel<'a>(&self, session: &'a Session) -> Element<'a, Message> {
|
||||||
let state = session.state();
|
let state = session.state();
|
||||||
|
let compact = menu_action(ICON_SPARK, "Compact context");
|
||||||
|
let compact = if self.can_compact_session(session.id) {
|
||||||
|
compact.on_press(Message::CompactSession(session.id))
|
||||||
|
} else {
|
||||||
|
compact
|
||||||
|
};
|
||||||
let mut actions = column![
|
let mut actions = column![
|
||||||
menu_action(ICON_NEW_SESSION, "Rename session")
|
menu_action(ICON_NEW_SESSION, "Rename session")
|
||||||
.on_press(Message::StartRenameSession(session.id)),
|
.on_press(Message::StartRenameSession(session.id)),
|
||||||
menu_action(ICON_SPARK, "Retitle with AI")
|
menu_action(ICON_SPARK, "Retitle with AI")
|
||||||
.on_press(Message::RetitleSession(session.id)),
|
.on_press(Message::RetitleSession(session.id)),
|
||||||
menu_action(ICON_SPARK, "Compact context")
|
compact,
|
||||||
.on_press(Message::CompactSession(session.id)),
|
|
||||||
menu_action(ICON_ARCHIVE, "Rebuild context on next use")
|
menu_action(ICON_ARCHIVE, "Rebuild context on next use")
|
||||||
.on_press(Message::RebuildSessionContext(session.id)),
|
.on_press(Message::RebuildSessionContext(session.id)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -56,12 +56,13 @@ impl App {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let markdown_style = markdown::Style::from_palette(app_theme().palette());
|
let markdown_style = markdown::Style::from_palette(app_theme().palette());
|
||||||
if let Some(summary) = self.compaction_summary() {
|
for (index, message) in self.conversation.iter().enumerate() {
|
||||||
|
if message.compaction {
|
||||||
messages = messages.push(
|
messages = messages.push(
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
text("Compacted task state").size(11),
|
text("Context compacted").size(11),
|
||||||
text(summary).size(13).color(muted_text()),
|
text(&message.content).size(13).color(muted_text()),
|
||||||
]
|
]
|
||||||
.spacing(5),
|
.spacing(5),
|
||||||
)
|
)
|
||||||
@@ -69,8 +70,8 @@ impl App {
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(preference_group_style),
|
.style(preference_group_style),
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
for (index, message) in self.conversation.iter().enumerate() {
|
|
||||||
if message.system {
|
if message.system {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -149,6 +150,14 @@ impl App {
|
|||||||
.style(move |theme| chat_message_style(theme, user)),
|
.style(move |theme| chat_message_style(theme, user)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if let Some(activity) = &self.activity {
|
||||||
|
messages = messages.push(
|
||||||
|
container(text(activity).size(13).color(muted_text()))
|
||||||
|
.padding(12)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(preference_group_style),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Tab walks every focusable widget of every window, so a composer
|
// Tab walks every focusable widget of every window, so a composer
|
||||||
// left behind an open dialog would take a turn in that dialog's
|
// left behind an open dialog would take a turn in that dialog's
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::engine::ChatTurn;
|
|||||||
|
|
||||||
pub(crate) const SUMMARY_MAX_TOKENS: i32 = 4096;
|
pub(crate) const SUMMARY_MAX_TOKENS: i32 = 4096;
|
||||||
pub(crate) const TOOL_RESULT_RESERVE_TOKENS: u32 = 1024;
|
pub(crate) const TOOL_RESULT_RESERVE_TOKENS: u32 = 1024;
|
||||||
|
const MIN_SUMMARY_TOKENS: u32 = 256;
|
||||||
|
|
||||||
const SOFT_PERCENT: u32 = 85;
|
const SOFT_PERCENT: u32 = 85;
|
||||||
const MIN_FREE_TOKENS: u32 = 8192;
|
const MIN_FREE_TOKENS: u32 = 8192;
|
||||||
@@ -21,24 +22,23 @@ pub(crate) fn should_compact(used: u32, limit: u32) -> bool {
|
|||||||
|| limit.saturating_sub(used) <= MIN_FREE_TOKENS.min(limit / 8)
|
|| limit.saturating_sub(used) <= MIN_FREE_TOKENS.min(limit / 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tool_result_needs_compaction(used: u32, limit: u32, result: &str) -> bool {
|
pub(crate) fn tool_result_reserve(limit: u32) -> u32 {
|
||||||
should_compact(used, limit)
|
TOOL_RESULT_RESERVE_TOKENS.min((limit / 8).max(16))
|
||||||
|| used
|
|
||||||
.saturating_add(result.len().min(u32::MAX as usize) as u32)
|
|
||||||
.saturating_add(TOOL_RESULT_RESERVE_TOKENS)
|
|
||||||
>= limit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn bounded_tool_result(used: u32, limit: u32, result: String) -> String {
|
pub(crate) fn tool_result_fits(projected: u32, limit: u32, reserve: u32) -> bool {
|
||||||
if used
|
limit > 0 && projected.saturating_add(reserve) < limit
|
||||||
.saturating_add(result.len().min(u32::MAX as usize) as u32)
|
|
||||||
.saturating_add(TOOL_RESULT_RESERVE_TOKENS)
|
|
||||||
< limit
|
|
||||||
{
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
"Tool error: the result is too large for the remaining context after compaction. Retry with a smaller read/search/bash output.\n".into()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bounded_tool_error(projected: u32, limit: u32, reserve: u32) -> String {
|
||||||
|
format!(
|
||||||
|
"Tool error: tool result still does not fit after context compaction (projected_prompt={projected} tokens, ctx={limit}, reserve={reserve}). Retry with a smaller read/search/bash output.\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn summary_budget(prompt: u32, limit: u32) -> Option<i32> {
|
||||||
|
let room = limit.saturating_sub(prompt).saturating_sub(1);
|
||||||
|
(room >= MIN_SUMMARY_TOKENS).then_some(room.min(SUMMARY_MAX_TOKENS as u32) as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tail_budget(context: u32) -> u32 {
|
pub(crate) fn tail_budget(context: u32) -> u32 {
|
||||||
@@ -132,11 +132,30 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn oversized_tool_result_becomes_a_bounded_retry_error() {
|
fn a_new_summary_replaces_the_previous_rebuild_summary() {
|
||||||
let result = "x".repeat(4_000);
|
let current = summary_system_prompt("tools", Some("old state"));
|
||||||
assert!(tool_result_needs_compaction(6_000, 10_000, &result));
|
assert!(current.contains("old state"));
|
||||||
let error = bounded_tool_result(4_000, 5_000, result);
|
let rebuilt = summary_system_prompt("tools", Some("new state"));
|
||||||
|
assert!(rebuilt.contains("new state"));
|
||||||
|
assert!(!rebuilt.contains("old state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hard_trigger_and_retry_reserve_match_the_reference() {
|
||||||
|
assert_eq!(tool_result_reserve(4096), 512);
|
||||||
|
assert_eq!(tool_result_reserve(65_536), 1024);
|
||||||
|
assert!(tool_result_fits(3000, 4096, 512));
|
||||||
|
assert!(!tool_result_fits(3584, 4096, 512));
|
||||||
|
let error = bounded_tool_error(5000, 4096, 512);
|
||||||
assert!(error.starts_with("Tool error:"));
|
assert!(error.starts_with("Tool error:"));
|
||||||
assert!(error.len() < 160);
|
assert!(error.contains("projected_prompt=5000 tokens"));
|
||||||
|
assert!(error.len() < 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_budget_keeps_answer_room_and_rejects_exhausted_contexts() {
|
||||||
|
assert_eq!(summary_budget(1000, 8192), Some(SUMMARY_MAX_TOKENS));
|
||||||
|
assert_eq!(summary_budget(7900, 8192), Some(291));
|
||||||
|
assert_eq!(summary_budget(7936, 8192), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/database.rs
162
src/database.rs
@@ -119,6 +119,8 @@ pub struct StoredMessage {
|
|||||||
pub reasoning_complete: bool,
|
pub reasoning_complete: bool,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub system: bool,
|
pub system: bool,
|
||||||
|
pub compaction: bool,
|
||||||
|
pub compaction_tail_start: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
@@ -131,15 +133,8 @@ struct NewMessage<'a> {
|
|||||||
reasoning_complete: bool,
|
reasoning_complete: bool,
|
||||||
content: &'a str,
|
content: &'a str,
|
||||||
system: bool,
|
system: bool,
|
||||||
}
|
compaction: bool,
|
||||||
|
compaction_tail_start: Option<i32>,
|
||||||
pub struct MessageDraft {
|
|
||||||
pub user: bool,
|
|
||||||
pub tool: bool,
|
|
||||||
pub reasoning: Option<String>,
|
|
||||||
pub reasoning_complete: bool,
|
|
||||||
pub content: String,
|
|
||||||
pub system: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -338,6 +333,8 @@ impl Database {
|
|||||||
reasoning_complete: true,
|
reasoning_complete: true,
|
||||||
content,
|
content,
|
||||||
system: true,
|
system: true,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?,
|
.get_result(connection)?,
|
||||||
@@ -352,6 +349,8 @@ impl Database {
|
|||||||
reasoning_complete: true,
|
reasoning_complete: true,
|
||||||
content: prompt,
|
content: prompt,
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?;
|
.get_result(connection)?;
|
||||||
@@ -364,6 +363,8 @@ impl Database {
|
|||||||
reasoning_complete: !reasoning,
|
reasoning_complete: !reasoning,
|
||||||
content: "",
|
content: "",
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?;
|
.get_result(connection)?;
|
||||||
@@ -377,13 +378,13 @@ impl Database {
|
|||||||
&mut self,
|
&mut self,
|
||||||
session_id: i32,
|
session_id: i32,
|
||||||
result: &str,
|
result: &str,
|
||||||
queued_users: &[String],
|
queued_user: Option<&str>,
|
||||||
reminder: Option<&str>,
|
system_messages: &[String],
|
||||||
reasoning: bool,
|
reasoning: bool,
|
||||||
) -> Result<Vec<StoredMessage>, String> {
|
) -> Result<Vec<StoredMessage>, String> {
|
||||||
self.connection
|
self.connection
|
||||||
.transaction(|connection| {
|
.transaction(|connection| {
|
||||||
let mut stored = Vec::with_capacity(queued_users.len() + 3);
|
let mut stored = Vec::with_capacity(system_messages.len() + 3);
|
||||||
stored.push(
|
stored.push(
|
||||||
diesel::insert_into(messages::table)
|
diesel::insert_into(messages::table)
|
||||||
.values(NewMessage {
|
.values(NewMessage {
|
||||||
@@ -394,11 +395,13 @@ impl Database {
|
|||||||
reasoning_complete: true,
|
reasoning_complete: true,
|
||||||
content: result,
|
content: result,
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?,
|
.get_result(connection)?,
|
||||||
);
|
);
|
||||||
for content in queued_users {
|
if let Some(content) = queued_user {
|
||||||
stored.push(
|
stored.push(
|
||||||
diesel::insert_into(messages::table)
|
diesel::insert_into(messages::table)
|
||||||
.values(NewMessage {
|
.values(NewMessage {
|
||||||
@@ -409,12 +412,14 @@ impl Database {
|
|||||||
reasoning_complete: true,
|
reasoning_complete: true,
|
||||||
content,
|
content,
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?,
|
.get_result(connection)?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(content) = reminder {
|
for content in system_messages {
|
||||||
stored.push(
|
stored.push(
|
||||||
diesel::insert_into(messages::table)
|
diesel::insert_into(messages::table)
|
||||||
.values(NewMessage {
|
.values(NewMessage {
|
||||||
@@ -425,6 +430,8 @@ impl Database {
|
|||||||
reasoning_complete: true,
|
reasoning_complete: true,
|
||||||
content,
|
content,
|
||||||
system: true,
|
system: true,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?,
|
.get_result(connection)?,
|
||||||
@@ -439,6 +446,8 @@ impl Database {
|
|||||||
reasoning_complete: !reasoning,
|
reasoning_complete: !reasoning,
|
||||||
content: "",
|
content: "",
|
||||||
system: false,
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?;
|
.get_result(connection)?;
|
||||||
@@ -466,11 +475,12 @@ impl Database {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_with_compacted_transcript(
|
pub fn record_compaction(
|
||||||
&mut self,
|
&mut self,
|
||||||
session_id: i32,
|
session_id: i32,
|
||||||
summary: &str,
|
summary: &str,
|
||||||
tail: &[MessageDraft],
|
tail_start: Option<i32>,
|
||||||
|
running_jobs: Option<&str>,
|
||||||
context_used: u32,
|
context_used: u32,
|
||||||
context_limit: u32,
|
context_limit: u32,
|
||||||
) -> Result<Vec<StoredMessage>, String> {
|
) -> Result<Vec<StoredMessage>, String> {
|
||||||
@@ -480,8 +490,6 @@ impl Database {
|
|||||||
.map_err(|_| "Context limit is too large to save".to_owned())?;
|
.map_err(|_| "Context limit is too large to save".to_owned())?;
|
||||||
self.connection
|
self.connection
|
||||||
.transaction(|connection| {
|
.transaction(|connection| {
|
||||||
diesel::delete(messages::table.filter(messages::session_id.eq(session_id)))
|
|
||||||
.execute(connection)?;
|
|
||||||
diesel::update(sessions::table.find(session_id))
|
diesel::update(sessions::table.find(session_id))
|
||||||
.set((
|
.set((
|
||||||
sessions::compacted_summary.eq(Some(summary)),
|
sessions::compacted_summary.eq(Some(summary)),
|
||||||
@@ -490,18 +498,36 @@ impl Database {
|
|||||||
sessions::last_tokens_per_second.eq(None::<f32>),
|
sessions::last_tokens_per_second.eq(None::<f32>),
|
||||||
))
|
))
|
||||||
.execute(connection)?;
|
.execute(connection)?;
|
||||||
let mut stored = Vec::with_capacity(tail.len());
|
let mut stored = Vec::with_capacity(2);
|
||||||
for message in tail {
|
|
||||||
stored.push(
|
stored.push(
|
||||||
diesel::insert_into(messages::table)
|
diesel::insert_into(messages::table)
|
||||||
.values(NewMessage {
|
.values(NewMessage {
|
||||||
session_id,
|
session_id,
|
||||||
user: message.user,
|
user: false,
|
||||||
tool: message.tool,
|
tool: false,
|
||||||
reasoning: message.reasoning.as_deref(),
|
reasoning: None,
|
||||||
reasoning_complete: message.reasoning_complete,
|
reasoning_complete: true,
|
||||||
content: &message.content,
|
content: summary,
|
||||||
system: message.system,
|
system: true,
|
||||||
|
compaction: true,
|
||||||
|
compaction_tail_start: tail_start,
|
||||||
|
})
|
||||||
|
.returning(StoredMessage::as_returning())
|
||||||
|
.get_result(connection)?,
|
||||||
|
);
|
||||||
|
if let Some(content) = running_jobs {
|
||||||
|
stored.push(
|
||||||
|
diesel::insert_into(messages::table)
|
||||||
|
.values(NewMessage {
|
||||||
|
session_id,
|
||||||
|
user: false,
|
||||||
|
tool: true,
|
||||||
|
reasoning: None,
|
||||||
|
reasoning_complete: true,
|
||||||
|
content,
|
||||||
|
system: false,
|
||||||
|
compaction: false,
|
||||||
|
compaction_tail_start: None,
|
||||||
})
|
})
|
||||||
.returning(StoredMessage::as_returning())
|
.returning(StoredMessage::as_returning())
|
||||||
.get_result(connection)?,
|
.get_result(connection)?,
|
||||||
@@ -586,7 +612,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn chat_messages_survive_reopen_and_follow_session_deletion() {
|
fn chat_and_compaction_history_survive_reopen_and_session_deletion() {
|
||||||
let id = SystemTime::now()
|
let id = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -606,8 +632,8 @@ mod tests {
|
|||||||
.continue_tool_turn(
|
.continue_tool_turn(
|
||||||
session.id,
|
session.id,
|
||||||
"Tool result",
|
"Tool result",
|
||||||
&["Queued correction".into()],
|
Some("Queued correction"),
|
||||||
Some("Tool reminder"),
|
&["Tool reminder".into()],
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -636,36 +662,86 @@ mod tests {
|
|||||||
assert_eq!(messages[5].content, "Tool reminder");
|
assert_eq!(messages[5].content, "Tool reminder");
|
||||||
assert!(!messages[6].user);
|
assert!(!messages[6].user);
|
||||||
assert!(!messages[6].tool);
|
assert!(!messages[6].tool);
|
||||||
let compacted = reopened
|
let first = reopened
|
||||||
.replace_with_compacted_transcript(
|
.record_compaction(
|
||||||
session.id,
|
session.id,
|
||||||
"Keep the active task.",
|
"First durable state.",
|
||||||
&[MessageDraft {
|
Some(messages[4].id),
|
||||||
user: true,
|
Some("bash job=1 status=running"),
|
||||||
tool: false,
|
|
||||||
reasoning: None,
|
|
||||||
reasoning_complete: true,
|
|
||||||
content: "Recent question".into(),
|
|
||||||
system: false,
|
|
||||||
}],
|
|
||||||
321,
|
321,
|
||||||
32_768,
|
32_768,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(compacted.len(), 1);
|
assert_eq!(first.len(), 2);
|
||||||
|
assert!(first[0].compaction);
|
||||||
|
assert_eq!(first[0].compaction_tail_start, Some(messages[4].id));
|
||||||
drop(reopened);
|
drop(reopened);
|
||||||
let mut reopened = Database::open(&path).unwrap();
|
let mut reopened = Database::open(&path).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.load_projects().unwrap()[0].sessions[0]
|
reopened.load_projects().unwrap()[0].sessions[0]
|
||||||
.compacted_summary
|
.compacted_summary
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("Keep the active task.")
|
Some("First durable state.")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
reopened.load_projects().unwrap()[0].sessions[0].context_used,
|
reopened.load_projects().unwrap()[0].sessions[0].context_used,
|
||||||
321
|
321
|
||||||
);
|
);
|
||||||
assert_eq!(reopened.load_messages(session.id).unwrap().len(), 1);
|
let messages = reopened.load_messages(session.id).unwrap();
|
||||||
|
assert_eq!(messages.len(), 9);
|
||||||
|
assert_eq!(messages[1].content, "Question");
|
||||||
|
assert!(messages[7].compaction);
|
||||||
|
assert_eq!(messages[7].content, "First durable state.");
|
||||||
|
assert_eq!(messages[8].content, "bash job=1 status=running");
|
||||||
|
reopened
|
||||||
|
.continue_tool_turn(session.id, "Reloaded tool result", None, &[], false)
|
||||||
|
.unwrap();
|
||||||
|
let continued = reopened.load_messages(session.id).unwrap();
|
||||||
|
let second_tail = continued[9].id;
|
||||||
|
reopened
|
||||||
|
.record_compaction(
|
||||||
|
session.id,
|
||||||
|
"Second durable state.",
|
||||||
|
Some(second_tail),
|
||||||
|
None,
|
||||||
|
222,
|
||||||
|
32_768,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
reopened
|
||||||
|
.continue_tool_turn(session.id, "Final tool result", None, &[], false)
|
||||||
|
.unwrap();
|
||||||
|
let continued = reopened.load_messages(session.id).unwrap();
|
||||||
|
let third_tail = continued[12].id;
|
||||||
|
reopened
|
||||||
|
.record_compaction(
|
||||||
|
session.id,
|
||||||
|
"Third durable state.",
|
||||||
|
Some(third_tail),
|
||||||
|
None,
|
||||||
|
111,
|
||||||
|
32_768,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
reopened
|
||||||
|
.start_chat_turn(session.id, "After third compaction", &[], false)
|
||||||
|
.unwrap();
|
||||||
|
drop(reopened);
|
||||||
|
|
||||||
|
let mut reopened = Database::open(&path).unwrap();
|
||||||
|
let history = reopened.load_messages(session.id).unwrap();
|
||||||
|
assert_eq!(history.len(), 17);
|
||||||
|
assert_eq!(history[1].content, "Question");
|
||||||
|
let markers = history
|
||||||
|
.iter()
|
||||||
|
.filter(|message| message.compaction)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(markers.len(), 3);
|
||||||
|
assert_eq!(markers[0].content, "First durable state.");
|
||||||
|
assert_eq!(markers[1].content, "Second durable state.");
|
||||||
|
assert_eq!(markers[2].content, "Third durable state.");
|
||||||
|
assert_eq!(markers[2].compaction_tail_start, Some(third_tail));
|
||||||
|
assert_eq!(history[15].content, "After third compaction");
|
||||||
reopened.delete_session(session.id).unwrap();
|
reopened.delete_session(session.id).unwrap();
|
||||||
assert!(reopened.load_messages(session.id).unwrap().is_empty());
|
assert!(reopened.load_messages(session.id).unwrap().is_empty());
|
||||||
drop(reopened);
|
drop(reopened);
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ pub(crate) struct GenerationOutput {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub(crate) struct CompactionOutput {
|
pub(crate) struct CompactionOutput {
|
||||||
pub(crate) summary: String,
|
pub(crate) summary: String,
|
||||||
pub(crate) tail: Vec<ChatTurn>,
|
pub(crate) tail_start: usize,
|
||||||
pub(crate) context_tokens: u32,
|
pub(crate) context_tokens: u32,
|
||||||
pub(crate) checkpoint: PathBuf,
|
pub(crate) checkpoint: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -530,19 +530,23 @@ impl Generator {
|
|||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn compact(
|
pub(crate) fn compact(
|
||||||
&mut self,
|
&mut self,
|
||||||
messages: &[ChatTurn],
|
messages: &[ChatTurn],
|
||||||
settings: &TurnSettings,
|
settings: &TurnSettings,
|
||||||
|
rebuild_system_prompt: &str,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
checkpoint: &Path,
|
checkpoint: &Path,
|
||||||
cancelled: &AtomicBool,
|
cancelled: &AtomicBool,
|
||||||
mut progress: impl FnMut(u32, u32, Option<f32>),
|
mut progress: impl FnMut(u32, u32, Option<f32>),
|
||||||
|
mut phase: impl FnMut(&'static str),
|
||||||
) -> Result<CompactionOutput, String> {
|
) -> Result<CompactionOutput, String> {
|
||||||
let _ = std::fs::remove_file(checkpoint);
|
let _ = std::fs::remove_file(checkpoint);
|
||||||
self.executor.reset()?;
|
self.executor.reset()?;
|
||||||
self.checkpoint = None;
|
self.checkpoint = None;
|
||||||
let result = (|| {
|
let result = (|| {
|
||||||
|
phase("Compacting durable task state…");
|
||||||
let mut private_messages = messages.to_vec();
|
let mut private_messages = messages.to_vec();
|
||||||
private_messages.push(ChatTurn {
|
private_messages.push(ChatTurn {
|
||||||
user: true,
|
user: true,
|
||||||
@@ -555,15 +559,24 @@ impl Generator {
|
|||||||
});
|
});
|
||||||
let mut private_settings = settings.clone();
|
let mut private_settings = settings.clone();
|
||||||
private_settings.reasoning_mode = ReasoningMode::Direct;
|
private_settings.reasoning_mode = ReasoningMode::Direct;
|
||||||
private_settings.max_generated_tokens = crate::compaction::SUMMARY_MAX_TOKENS;
|
let private_prompt = self.executor.model().render_conversation(
|
||||||
|
&private_settings.system_prompt,
|
||||||
|
&private_messages,
|
||||||
|
private_settings.reasoning_mode,
|
||||||
|
);
|
||||||
|
private_settings.max_generated_tokens = crate::compaction::summary_budget(
|
||||||
|
private_prompt.len().min(u32::MAX as usize) as u32,
|
||||||
|
self.executor.context(),
|
||||||
|
)
|
||||||
|
.ok_or_else(|| "not enough context left to request compaction summary".to_owned())?;
|
||||||
private_settings.temperature = 0.0;
|
private_settings.temperature = 0.0;
|
||||||
private_settings.stops.extend([
|
private_settings.stops = vec![
|
||||||
"<|DSML|".into(),
|
"<|DSML|".into(),
|
||||||
"<DSML|".into(),
|
"<DSML|".into(),
|
||||||
"<tool_call>".into(),
|
"<tool_call>".into(),
|
||||||
"<think>".into(),
|
"<think>".into(),
|
||||||
"</think>".into(),
|
"</think>".into(),
|
||||||
]);
|
];
|
||||||
let (output, prompt_complete) = self.generate_inner(
|
let (output, prompt_complete) = self.generate_inner(
|
||||||
&private_messages,
|
&private_messages,
|
||||||
&private_settings,
|
&private_settings,
|
||||||
@@ -580,6 +593,7 @@ impl Generator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The private request must never become the rebuilt session prefix.
|
// The private request must never become the rebuilt session prefix.
|
||||||
|
phase("Rebuilding compacted context…");
|
||||||
self.executor.reset()?;
|
self.executor.reset()?;
|
||||||
self.checkpoint = None;
|
self.checkpoint = None;
|
||||||
|
|
||||||
@@ -609,7 +623,7 @@ impl Generator {
|
|||||||
);
|
);
|
||||||
let tail = messages[start..].to_vec();
|
let tail = messages[start..].to_vec();
|
||||||
let rebuilt_system =
|
let rebuilt_system =
|
||||||
crate::compaction::summary_system_prompt(&settings.system_prompt, Some(&summary));
|
crate::compaction::summary_system_prompt(rebuild_system_prompt, Some(&summary));
|
||||||
let history_tokens = self.executor.model().render_history(
|
let history_tokens = self.executor.model().render_history(
|
||||||
&rebuilt_system,
|
&rebuilt_system,
|
||||||
&tail,
|
&tail,
|
||||||
@@ -627,10 +641,11 @@ impl Generator {
|
|||||||
return Err("context compaction interrupted during rebuild".into());
|
return Err("context compaction interrupted during rebuild".into());
|
||||||
}
|
}
|
||||||
let tag = conversation_tag(&rebuilt_system, settings.reasoning_mode, &tail);
|
let tag = conversation_tag(&rebuilt_system, settings.reasoning_mode, &tail);
|
||||||
|
phase("Saving compacted context…");
|
||||||
self.save_checkpoint(checkpoint, tag)?;
|
self.save_checkpoint(checkpoint, tag)?;
|
||||||
Ok(CompactionOutput {
|
Ok(CompactionOutput {
|
||||||
summary,
|
summary,
|
||||||
tail,
|
tail_start: start,
|
||||||
context_tokens: history_tokens.len() as u32,
|
context_tokens: history_tokens.len() as u32,
|
||||||
checkpoint: checkpoint.to_owned(),
|
checkpoint: checkpoint.to_owned(),
|
||||||
})
|
})
|
||||||
@@ -645,6 +660,20 @@ impl Generator {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rendered_history_tokens(
|
||||||
|
&self,
|
||||||
|
messages: &[ChatTurn],
|
||||||
|
settings: &TurnSettings,
|
||||||
|
) -> Result<u32, String> {
|
||||||
|
u32::try_from(
|
||||||
|
self.executor
|
||||||
|
.model()
|
||||||
|
.render_history(&settings.system_prompt, messages, settings.reasoning_mode)
|
||||||
|
.len(),
|
||||||
|
)
|
||||||
|
.map_err(|_| "rendered conversation is too large".to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
fn select_checkpoint(
|
fn select_checkpoint(
|
||||||
&mut self,
|
&mut self,
|
||||||
checkpoint: &Path,
|
checkpoint: &Path,
|
||||||
|
|||||||
124
src/runtime.rs
124
src/runtime.rs
@@ -46,6 +46,7 @@ impl CheckpointTarget {
|
|||||||
|
|
||||||
pub(crate) enum GenerationEvent {
|
pub(crate) enum GenerationEvent {
|
||||||
Loading,
|
Loading,
|
||||||
|
Activity(&'static str),
|
||||||
Chunk {
|
Chunk {
|
||||||
reasoning: bool,
|
reasoning: bool,
|
||||||
content: String,
|
content: String,
|
||||||
@@ -57,6 +58,23 @@ pub(crate) enum GenerationEvent {
|
|||||||
},
|
},
|
||||||
Finished(Result<GenerationOutput, String>),
|
Finished(Result<GenerationOutput, String>),
|
||||||
Compacted(Result<CompactionOutput, String>),
|
Compacted(Result<CompactionOutput, String>),
|
||||||
|
Measured(Result<u32, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Operation {
|
||||||
|
Generate,
|
||||||
|
Compact {
|
||||||
|
reason: String,
|
||||||
|
rebuild_system_prompt: String,
|
||||||
|
},
|
||||||
|
Measure,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ResponseKind {
|
||||||
|
Generation,
|
||||||
|
Compaction,
|
||||||
|
Measurement,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Command {
|
struct Command {
|
||||||
@@ -64,7 +82,7 @@ struct Command {
|
|||||||
turn: TurnSettings,
|
turn: TurnSettings,
|
||||||
messages: Vec<ChatTurn>,
|
messages: Vec<ChatTurn>,
|
||||||
checkpoint: CheckpointTarget,
|
checkpoint: CheckpointTarget,
|
||||||
compact_reason: Option<String>,
|
operation: Operation,
|
||||||
idle_timeout: Duration,
|
idle_timeout: Duration,
|
||||||
cancel: Arc<AtomicBool>,
|
cancel: Arc<AtomicBool>,
|
||||||
events: Sender<GenerationEvent>,
|
events: Sender<GenerationEvent>,
|
||||||
@@ -100,7 +118,7 @@ impl GenerationService {
|
|||||||
turn,
|
turn,
|
||||||
messages,
|
messages,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
compact_reason: None,
|
operation: Operation::Generate,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
cancel: Arc::clone(&cancel),
|
cancel: Arc::clone(&cancel),
|
||||||
events,
|
events,
|
||||||
@@ -116,14 +134,46 @@ impl GenerationService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn compact(
|
pub(crate) fn compact(
|
||||||
&self,
|
&self,
|
||||||
engine: EngineSettings,
|
engine: EngineSettings,
|
||||||
turn: TurnSettings,
|
turn: TurnSettings,
|
||||||
messages: Vec<ChatTurn>,
|
messages: Vec<ChatTurn>,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
|
rebuild_system_prompt: String,
|
||||||
checkpoint: PathBuf,
|
checkpoint: PathBuf,
|
||||||
idle_timeout: Duration,
|
idle_timeout: Duration,
|
||||||
|
) -> Result<ActiveGeneration, String> {
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
let (events, receiver) = mpsc::channel();
|
||||||
|
self.commands
|
||||||
|
.send(Command {
|
||||||
|
engine,
|
||||||
|
turn,
|
||||||
|
messages,
|
||||||
|
checkpoint: CheckpointTarget::Local(checkpoint),
|
||||||
|
operation: Operation::Compact {
|
||||||
|
reason: reason.to_owned(),
|
||||||
|
rebuild_system_prompt,
|
||||||
|
},
|
||||||
|
idle_timeout,
|
||||||
|
cancel: Arc::clone(&cancel),
|
||||||
|
events,
|
||||||
|
})
|
||||||
|
.map_err(|_| "The model runtime stopped unexpectedly.".to_owned())?;
|
||||||
|
Ok(ActiveGeneration {
|
||||||
|
events: receiver,
|
||||||
|
cancel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn measure_context(
|
||||||
|
&self,
|
||||||
|
engine: EngineSettings,
|
||||||
|
turn: TurnSettings,
|
||||||
|
messages: Vec<ChatTurn>,
|
||||||
|
idle_timeout: Duration,
|
||||||
) -> Result<ActiveGeneration, String> {
|
) -> Result<ActiveGeneration, String> {
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
let (events, receiver) = mpsc::channel();
|
let (events, receiver) = mpsc::channel();
|
||||||
@@ -133,8 +183,8 @@ impl GenerationService {
|
|||||||
engine,
|
engine,
|
||||||
turn,
|
turn,
|
||||||
messages,
|
messages,
|
||||||
checkpoint: CheckpointTarget::Local(checkpoint),
|
checkpoint: CheckpointTarget::OneShot(PathBuf::new()),
|
||||||
compact_reason: Some(reason.to_owned()),
|
operation: Operation::Measure,
|
||||||
idle_timeout,
|
idle_timeout,
|
||||||
cancel: Arc::clone(&cancel),
|
cancel: Arc::clone(&cancel),
|
||||||
events,
|
events,
|
||||||
@@ -157,7 +207,11 @@ fn run(commands: Receiver<Command>, metrics: Arc<Metrics>) {
|
|||||||
let request_started = Instant::now();
|
let request_started = Instant::now();
|
||||||
let source = command.checkpoint.source();
|
let source = command.checkpoint.source();
|
||||||
let events = command.events.clone();
|
let events = command.events.clone();
|
||||||
|
let response = response_kind(&command.operation);
|
||||||
|
let tracked = !matches!(command.operation, Operation::Measure);
|
||||||
|
if tracked {
|
||||||
metrics.request_started(source);
|
metrics.request_started(source);
|
||||||
|
}
|
||||||
if let Err(error) = catch_runtime_panic(|| {
|
if let Err(error) = catch_runtime_panic(|| {
|
||||||
run_command(
|
run_command(
|
||||||
command,
|
command,
|
||||||
@@ -169,11 +223,13 @@ fn run(commands: Receiver<Command>, metrics: Arc<Metrics>) {
|
|||||||
&mut idle_timeout,
|
&mut idle_timeout,
|
||||||
);
|
);
|
||||||
}) {
|
}) {
|
||||||
|
if tracked {
|
||||||
metrics.request_failed(request_started.elapsed());
|
metrics.request_failed(request_started.elapsed());
|
||||||
|
}
|
||||||
if loaded.take().is_some() {
|
if loaded.take().is_some() {
|
||||||
metrics.unloaded();
|
metrics.unloaded();
|
||||||
}
|
}
|
||||||
let _ = events.send(GenerationEvent::Finished(Err(error)));
|
let _ = events.send(error_event(response, error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
@@ -197,12 +253,16 @@ fn run_command(
|
|||||||
last_used: &mut Instant,
|
last_used: &mut Instant,
|
||||||
idle_timeout: &mut Duration,
|
idle_timeout: &mut Duration,
|
||||||
) {
|
) {
|
||||||
|
let response = response_kind(&command.operation);
|
||||||
|
let tracked = !matches!(command.operation, Operation::Measure);
|
||||||
*idle_timeout = command.idle_timeout;
|
*idle_timeout = command.idle_timeout;
|
||||||
if command.cancel.load(Ordering::Relaxed) {
|
if command.cancel.load(Ordering::Relaxed) {
|
||||||
|
if tracked {
|
||||||
metrics.request_failed(request_started.elapsed());
|
metrics.request_failed(request_started.elapsed());
|
||||||
let _ = command.events.send(GenerationEvent::Finished(
|
}
|
||||||
Err("generation cancelled".into()),
|
let _ = command
|
||||||
));
|
.events
|
||||||
|
.send(error_event(response, "generation cancelled".into()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if loaded
|
if loaded
|
||||||
@@ -228,8 +288,10 @@ fn run_command(
|
|||||||
Some((command.engine.clone(), generator))
|
Some((command.engine.clone(), generator))
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
if tracked {
|
||||||
metrics.request_failed(request_started.elapsed());
|
metrics.request_failed(request_started.elapsed());
|
||||||
let _ = command.events.send(GenerationEvent::Finished(Err(error)));
|
}
|
||||||
|
let _ = command.events.send(error_event(response, error));
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -261,17 +323,25 @@ fn run_command(
|
|||||||
tokens_per_second,
|
tokens_per_second,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if let Some(reason) = &command.compact_reason {
|
if let Operation::Compact {
|
||||||
|
reason,
|
||||||
|
rebuild_system_prompt,
|
||||||
|
} = &command.operation
|
||||||
|
{
|
||||||
let CheckpointTarget::Local(checkpoint) = &command.checkpoint else {
|
let CheckpointTarget::Local(checkpoint) = &command.checkpoint else {
|
||||||
unreachable!("compaction checkpoints are local")
|
unreachable!("compaction checkpoints are local")
|
||||||
};
|
};
|
||||||
let result = generator.compact(
|
let result = generator.compact(
|
||||||
&command.messages,
|
&command.messages,
|
||||||
&command.turn,
|
&command.turn,
|
||||||
|
rebuild_system_prompt,
|
||||||
reason,
|
reason,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
&command.cancel,
|
&command.cancel,
|
||||||
&mut progress,
|
&mut progress,
|
||||||
|
|activity| {
|
||||||
|
let _ = command.events.send(GenerationEvent::Activity(activity));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
match &result {
|
match &result {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -283,6 +353,12 @@ fn run_command(
|
|||||||
*last_used = Instant::now();
|
*last_used = Instant::now();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if matches!(command.operation, Operation::Measure) {
|
||||||
|
let result = generator.rendered_history_tokens(&command.messages, &command.turn);
|
||||||
|
let _ = command.events.send(GenerationEvent::Measured(result));
|
||||||
|
*last_used = Instant::now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
let result = match command.checkpoint {
|
let result = match command.checkpoint {
|
||||||
CheckpointTarget::Local(path) => generator.generate(
|
CheckpointTarget::Local(path) => generator.generate(
|
||||||
&path,
|
&path,
|
||||||
@@ -320,6 +396,22 @@ fn run_command(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_kind(operation: &Operation) -> ResponseKind {
|
||||||
|
match operation {
|
||||||
|
Operation::Generate => ResponseKind::Generation,
|
||||||
|
Operation::Compact { .. } => ResponseKind::Compaction,
|
||||||
|
Operation::Measure => ResponseKind::Measurement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_event(kind: ResponseKind, error: String) -> GenerationEvent {
|
||||||
|
match kind {
|
||||||
|
ResponseKind::Generation => GenerationEvent::Finished(Err(error)),
|
||||||
|
ResponseKind::Compaction => GenerationEvent::Compacted(Err(error)),
|
||||||
|
ResponseKind::Measurement => GenerationEvent::Measured(Err(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn catch_runtime_panic<T>(operation: impl FnOnce() -> T) -> Result<T, String> {
|
fn catch_runtime_panic<T>(operation: impl FnOnce() -> T) -> Result<T, String> {
|
||||||
// Generator owns GPU handles and is not unwind-safe; callers discard it on error.
|
// Generator owns GPU handles and is not unwind-safe; callers discard it on error.
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation))
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(operation))
|
||||||
@@ -351,4 +443,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(catch_runtime_panic(|| 42), Ok(42));
|
assert_eq!(catch_runtime_panic(|| 42), Ok(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_failures_use_the_matching_event() {
|
||||||
|
assert!(matches!(
|
||||||
|
error_event(ResponseKind::Compaction, "stopped".into()),
|
||||||
|
GenerationEvent::Compacted(Err(error)) if error == "stopped"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
error_event(ResponseKind::Measurement, "stopped".into()),
|
||||||
|
GenerationEvent::Measured(Err(error)) if error == "stopped"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ diesel::table! {
|
|||||||
reasoning_complete -> Bool,
|
reasoning_complete -> Bool,
|
||||||
content -> Text,
|
content -> Text,
|
||||||
system -> Bool,
|
system -> Bool,
|
||||||
|
compaction -> Bool,
|
||||||
|
compaction_tail_start -> Nullable<Integer>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -749,6 +749,7 @@ fn stream_response_with_keepalive(
|
|||||||
"The model runtime returned an unexpected compaction event.".into(),
|
"The model runtime returned an unexpected compaction event.".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
GenerationEvent::Activity(_) | GenerationEvent::Measured(_) => {}
|
||||||
GenerationEvent::Loading => {}
|
GenerationEvent::Loading => {}
|
||||||
GenerationEvent::Context {
|
GenerationEvent::Context {
|
||||||
tokens_per_second, ..
|
tokens_per_second, ..
|
||||||
|
|||||||
Reference in New Issue
Block a user