fix: capture mlx-omni reasoning tokens; strip empty turns for expert; add /save
ober
b806178ebf4e07c3ae21b8682fd39fa0bf7e001c
--- a/src/jcode/core/expert.ss +++ b/src/jcode/core/expert.ss @@ -93,6 +93,33 @@ (config-ref "expert" "model")) "")) +;; An assistant turn that has neither visible content nor tool calls is +;; what tripped deepseek's `content or tool_calls must be set` validator. +;; Such turns happen when the primary model reasons-only (mlx-omni puts +;; the tokens in the `reasoning` field) and we have nothing to forward. +;; Drop them before serializing — and drop any orphaned tool-result that +;; would otherwise reference a tool_call we just removed. +(def (assistant-empty? msg) + (and (equal? (message-role msg) "assistant") + (let ((c (message-content msg)) + (tcs (message-tool-calls msg))) + (and (or (not c) (and (string? c) (= (string-length c) 0))) + (or (not tcs) (and (list? tcs) (null? tcs))))))) + +(def (strip-empty-assistant-turns messages) + (let loop ((in messages) (out '())) + (cond + ((null? in) (reverse out)) + ((assistant-empty? (car in)) + ;; Also drop the immediately-following tool result(s) — they would + ;; reference the dropped assistant's tool_call_ids. + (let skip ((rest (cdr in))) + (cond + ((and (pair? rest) (equal? (message-role (car rest)) "tool")) + (skip (cdr rest))) + (else (loop rest out))))) + (else (loop (cdr in) (cons (car in) out)))))) + ;; When escalating, rebuild the leading system message to include a brief ;; note that the expert is being consulted and why. Anthropic collapses ;; system messages into a single body field; OpenAI/others take one at the @@ -101,7 +128,8 @@ (def (with-expert-handoff messages reason-text) (let ((handoff-note (format "\n\n[EXPERT HANDOFF] You are being consulted as the expert model because the primary model appeared stuck or low-confidence (~a). The conversation above is the full context. Provide the best answer you can for the user's most recent request — be concrete and direct." - reason-text))) + reason-text)) + (messages (strip-empty-assistant-turns messages))) (cond ((null? messages) messages) ((equal? (message-role (car messages)) "system") --- a/src/jcode/core/models.ss +++ b/src/jcode/core/models.ss @@ -427,6 +427,11 @@ ((string-contains mid "o3") 200000) ((string-contains mid "gemini-1.5") 1000000) ((string-contains mid "gemini-2") 1000000) + ;; Local mlx_lm LoRAs: KV cache stays fp16 even when weights are + ;; quantized, so the Metal wired-memory cap (not the architectural + ;; ctx limit) dominates. Keep this conservative so compaction kicks + ;; in before prefill blows the 32 GB wired budget. + ((string-contains mid "jerboa-mlx") 16384) ((string-contains mid "qwen3") 32768) ((string-contains mid "llama-3") 131072) ((string-contains mid "deepseek") 131072) --- a/src/jcode/provider/provider.ss +++ b/src/jcode/provider/provider.ss @@ -998,6 +998,10 @@ (headers (openai-headers provider)) (body (openai-stream-body provider messages tools)) (text-acc (open-output-string)) + ;; Reasoning tokens (mlx-omni `reasoning`, DeepSeek-R1 + ;; `reasoning_content`). Captured separately so an otherwise + ;; content-less turn still has something to record. + (reasoning-acc (open-output-string)) ;; tool-call accumulators: index -> alist with id/name/args-so-far (tc-table (make-hash-table)) (usage-acc (make-hash-table)) @@ -1052,6 +1056,15 @@ (when (and content (string? content) (> (string-length content) 0)) (put-string text-acc content) (token-cb content))) + ;; Reasoning token (mlx-omni `reasoning`, R1 + ;; `reasoning_content`). Stream to UI so the user + ;; sees thinking, and accumulate so an otherwise + ;; empty turn still records something downstream. + (let ((r (or (hash-get delta "reasoning") + (hash-get delta "reasoning_content")))) + (when (and r (string? r) (> (string-length r) 0)) + (put-string reasoning-acc r) + (token-cb r))) ;; Tool call fragments (let ((tcs (hash-get delta "tool_calls"))) (when (and tcs (list? tcs)) @@ -1079,7 +1092,17 @@ (log-error logger "stream-http-error" `((status . ,http-status) (url . ,url))))) ;; Build result - (let* ((content (get-output-string text-acc)) + (let* ((raw-content (get-output-string text-acc)) + (reasoning (get-output-string reasoning-acc)) + ;; If the model emitted only reasoning, fold it into content as + ;; <think>...</think> so the turn isn't empty (which would + ;; otherwise trip the auto-escalator and produce an invalid + ;; assistant message for downstream providers). + (content (cond + ((> (string-length raw-content) 0) raw-content) + ((> (string-length reasoning) 0) + (string-append "<think>" reasoning "</think>")) + (else ""))) (indices (sort < (hash-keys tc-table))) (tool-calls (map (lambda (idx) --- a/src/jcode/ui/tui.ss +++ b/src/jcode/ui/tui.ss @@ -526,6 +526,7 @@ " /clear Start new session" " /compact Summarize older turns to free up context" " /sessions List sessions" + " /save [path] Export current session to markdown (default ~/jcode-session-<id>.md)" " /search <term> Search session history" " /mcp Toggle MCP tools on/off (for local models)" " /ask-claude Second opinion from claude CLI (sandboxed)" @@ -624,6 +625,11 @@ (map (lambda (s) (format " ~a ~a" (session-id s) (session-title s))) sessions) "\n")))))) + ((equal? cmd "save") + (handle-save! state "")) + ((string-prefix? "save " cmd) + (handle-save! state + (substring cmd 5 (string-length cmd)))) ((string-prefix? "search " cmd) (let* ((term (string-trim (substring cmd 7 (string-length cmd)))) (results (session-search term))) @@ -1121,6 +1127,64 @@ (send-worker-event! gen (list 'ask-result provider result)))))))) +;; ---- /save [path] ---- +;; Dump the visible message thread to a markdown file. Sessions persist in +;; ~/.jcode/sessions.db but a plain-text artifact is easier to share/diff. +(def (msg-block->markdown blk port) + (case (msg-block-role blk) + ((user) + (display "## user\n\n" port) + (display (or (msg-block-content blk) "") port) + (display "\n\n" port)) + ((assistant) + (display "## assistant\n\n" port) + (let ((th (msg-block-thinking blk))) + (when (and th (string? th) (> (string-length th) 0)) + (display "<details><summary>reasoning</summary>\n\n" port) + (display th port) + (display "\n\n</details>\n\n" port))) + (display (or (msg-block-content blk) "") port) + (display "\n\n" port)) + ((tool) + (display "### tool: " port) + (display (or (msg-block-tool-name blk) "?") port) + (display "\n\n```\n" port) + (display (or (msg-block-content blk) "") port) + (display "\n```\n\n" port)) + ((error) + (display "### error\n\n```\n" port) + (display (or (msg-block-content blk) "") port) + (display "\n```\n\n" port)) + ((system) + (display "### system\n\n" port) + (display (or (msg-block-content blk) "") port) + (display "\n\n" port)))) + +(def (handle-save! state arg) + (let* ((raw (string-trim arg)) + (home (or (getenv "HOME") ".")) + (sid (or (app-state-session-id state) "untitled")) + (default-path + (string-append home "/jcode-session-" sid ".md")) + (p1 (if (string=? raw "") default-path raw)) + (path (if (and (>= (string-length p1) 2) + (string=? (substring p1 0 2) "~/")) + (string-append home (substring p1 1 (string-length p1))) + p1))) + (guard (e [#t (add-message! state + (msg-block-error + (format "Save failed: ~a" (err->string e))))]) + (when (file-exists? path) (delete-file path)) + (call-with-output-file path + (lambda (p) + (display "# jcode session " p) (display sid p) + (newline p) (newline p) + (for-each + (lambda (m) (msg-block->markdown m p)) + (app-state-messages state)))) + (add-message! state + (msg-block-system (format "Saved session to ~a" path)))))) + ;; ---- /expert <prompt> ---- ;; Force the next turn to the configured expert by parameterizing the ;; provider/model overrides for the duration of run-agent!. The worker