handleStreamClosed drives what happens after one round's stream finishes: dispatch the next pending tool call, or, if none, finalize the turn and hand control back. A turn ends precisely when the assistant emits no tool calls; there is no loop tool to land on.
()
| 909 | // history (measured ~1.6x on a real run), so the true prompt can reach the |
| 910 | // server's window while Pack still believes it has headroom. Ollama then |
| 911 | // front-truncates silently (200 OK, system prompt lost). A server count at |
| 912 | // or past 95% of the window is that band; log it so a forensic pass sees |
| 913 | // the overflow instead of inferring it. Log-only; the headroom constant |
| 914 | // stays put until a run actually trips this. |
| 915 | if ctxSize := m.activeContextSize(); e.PromptTokens > 0 && e.PromptTokens >= ctxSize-ctxSize/20 { |
| 916 | dbgWritef("ctx_pressure", "prompt_tokens=%d at >=95%% of ctx=%d; real prompt has outgrown the packer's estimate, next request risks silent server-side truncation", e.PromptTokens, ctxSize) |
| 917 | // Say it out loud once. Past this band the server front-truncates |
| 918 | // silently - dropping the system prompt first - and every later round |
| 919 | // re-prefills a window it then discards. That reads to the user as "the |
| 920 | // agent got slow and stupid", with the cause visible only in a debug log |
| 921 | // they had to have enabled in advance. |
| 922 | if !m.ctxPressureWarned { |
| 923 | m.ctxPressureWarned = true |
| 924 | m.appendLine(styleError.Render(fmt.Sprintf( |
| 925 | "⚠ prompt is at %d of %d context tokens. Your server may be silently truncating it (the system prompt goes first). Lower context_size in .codehamr/config.yaml to match what the server really serves, or /clear.", |
| 926 | e.PromptTokens, ctxSize))) |
| 927 | } |
| 928 | } |
| 929 | // The inverse tripwire: the server counted far FEWER prompt tokens than the |
| 930 | // packer sent. char/4 only ever UNDERcounts (measured ~1.6x on code-heavy |
| 931 | // history) and chat-template overhead inflates the server's count further, |
| 932 | // so a report under HALF the estimate can only mean the server dropped |
| 933 | // content before the model saw it - stock Ollama's small default num_ctx |
| 934 | // front-truncating the system prompt away while the configured context_size |
| 935 | // says there is room. The >=95% check above can never fire in that state |
| 936 | // (prompt_tokens plateaus far below the configured window), which is |
| 937 | // exactly why this one exists. The 12k floor keeps small early prompts, |
| 938 | // where fixed overhead dominates, from tripping it. |
| 939 | if e.PromptTokens > 0 && m.lastPromptEstimate > 12000 && e.PromptTokens < m.lastPromptEstimate/2 && !m.ctxTruncationWarned { |
| 940 | m.ctxTruncationWarned = true |
| 941 | dbgWritef("ctx_truncation", "server prompt_tokens=%d vs packed estimate=%d; server-side truncation", e.PromptTokens, m.lastPromptEstimate) |
| 942 | m.appendLine(styleError.Render(fmt.Sprintf( |
| 943 | "⚠ the server processed only %d tokens of a ~%d-token prompt - it is silently truncating context (the system prompt goes first). Raise the server's window (e.g. Ollama num_ctx) or lower context_size in .codehamr/config.yaml to what it really serves.", |
| 944 | e.PromptTokens, m.lastPromptEstimate))) |
| 945 | } |
| 946 | m.flushStreaming() |
| 947 | } |
| 948 | |
| 949 | // maxStreamReplays bounds transparent mid-stream retries per turn. Two is |
| 950 | // enough to ride out a flaky proxy without letting a server that drops every |
| 951 | // round spin forever; past it the error surfaces as it always did. |
| 952 | const maxStreamReplays = 2 |
| 953 | |
| 954 | // applyError unwinds the turn on a stream error: preserve content streamed |
| 955 | // before the error (so the user keeps failure context), emit the one-line hint, |
| 956 | // drop the pending queue, reset turn state. |
| 957 | // |
| 958 | // Unless the drop is replayable. llm sets MidStream for a socket that died |
| 959 | // after delivering at least one frame and that is not the server's own refusal. |
| 960 | // Only applyDone writes an assistant message to history, so a round that never |
| 961 | // reached EventDone left history untouched: re-issuing the identical request |
| 962 | // duplicates nothing. Discard this round's partial buffers first - they are |
| 963 | // display and queue state, not context - and go again. Without this, one |
| 964 | // transient drop three hours into an unattended run ends it and waits for a |
| 965 | // human. |
| 966 | func (m *Model) applyError(e llm.Event) tea.Cmd { |
| 967 | dbgWritef("error", "%v", e.Err) |
| 968 | if e.MidStream && m.phase.active() && m.streamReplays < maxStreamReplays { |