expert: route stuck/uncertain prompts to a stronger model

ober

00cd34ebdcb1c558c4495d71ac77d9f49786faf1

diff --git a/src/jcode/core/agent.ss b/src/jcode/core/agent.ss
index 670de90..7ad4860 100644
--- a/src/jcode/core/agent.ss
+++ b/src/jcode/core/agent.ss
@@ -18,6 +18,7 @@
         ./log
         ./message
         ./session
+        ./expert
         :jcode/provider/provider
         :jcode/tool/registry
         :jerboa/core
@@ -38,6 +39,8 @@ You have access to these tools:
 
 ~a
 
+~a
+
 IMPORTANT RULES:
 - Source code lives in src/ with extension .ss. NEVER edit files in lib/ — those are generated build artifacts (.sls/.so).
 - ALWAYS read a file (with the read tool) before editing it. Do not invent file contents.
@@ -55,7 +58,8 @@ Be concise. Prefer edit over write for modifying existing files."
     (current-directory)
     (mode-label (current-mode))
     (format-tool-list)
-    (mode-instructions (current-mode))))
+    (mode-instructions (current-mode))
+    (expert-prompt-instructions)))
 
 (def (format-tool-list)
   "Build a bullet list of all registered tools for the system prompt."
@@ -215,7 +219,7 @@ Be concise. Prefer edit over write for modifying existing files."
   (let* ((provider (get-current-provider))
          (tools (get-tool-schemas))
          (msgs (refresh-system-prompt messages))
-         (response (provider-chat provider msgs tools)))
+         (response (chat-with-expert provider msgs tools)))
     (log-debug logger "got-response" `((role . ,(message-role response))))
     ;; Detect text-format tool calls (some models output tool calls as text)
     (let* ((content (or (message-content response) ""))
@@ -235,7 +239,7 @@ Be concise. Prefer edit over write for modifying existing files."
          (let ((results (execute-tool-calls tcs)))
            (for-each (lambda (r) (session-add-message session-id r)) results)
            (let* ((final-msgs (refresh-system-prompt (session-get-messages session-id)))
-                  (final (provider-chat provider final-msgs '())))
+                  (final (chat-with-expert provider final-msgs '())))
              (session-add-message session-id final)
              final)))
         (else
@@ -251,7 +255,7 @@ Be concise. Prefer edit over write for modifying existing files."
          (tools    (get-tool-schemas))
          (msgs     (refresh-system-prompt messages)))
     (let-values (((content tool-calls usage)
-                  (provider-stream-chat provider msgs tools (current-stream-cb))))
+                  (stream-chat-with-expert provider msgs tools (current-stream-cb))))
       (when (and usage (current-usage-cb))
         ((current-usage-cb) usage))
       ;; Detect text-format tool calls (some models output tool calls as text)
@@ -273,7 +277,7 @@ Be concise. Prefer edit over write for modifying existing files."
            (let ((results (execute-tool-calls effective-tcs)))
              (for-each (lambda (r) (session-add-message session-id r)) results)
              (let-values (((fc _tc _u)
-                           (provider-stream-chat provider
+                           (stream-chat-with-expert provider
                              (refresh-system-prompt (session-get-messages session-id))
                              '() (current-stream-cb))))
                (let ((final (make-assistant-message
@@ -364,13 +368,13 @@ Be concise. Prefer edit over write for modifying existing files."
 
 (def (agent-chat-loop provider messages tools round)
   (let* ((msgs messages)
-         (response (provider-chat provider msgs tools)))
+         (response (chat-with-expert provider msgs tools)))
     (cond
       ((not (message-tool-calls response)) (message-content response))
       ((>= round *max-tool-rounds*)
        (let* ((results (execute-tool-calls (message-tool-calls response)))
               (new-messages (append msgs (list response) results))
-              (final (provider-chat provider new-messages '())))
+              (final (chat-with-expert provider new-messages '())))
          (or (message-content final) "")))
       (else
        (let* ((results (execute-tool-calls (message-tool-calls response)))
@@ -380,7 +384,7 @@ Be concise. Prefer edit over write for modifying existing files."
 (def (agent-chat-loop-stream provider messages tools round)
   (let* ((msgs messages))
     (let-values (((content tool-calls usage)
-                  (provider-stream-chat provider msgs tools (current-stream-cb))))
+                  (stream-chat-with-expert provider msgs tools (current-stream-cb))))
       (cond
         ((null? tool-calls) content)
         ((>= round *max-tool-rounds*)
@@ -390,7 +394,7 @@ Be concise. Prefer edit over write for modifying existing files."
                 (results   (execute-tool-calls tool-calls))
                 (new-msgs  (append msgs (list response) results)))
            (let-values (((fc _tc _u)
-                         (provider-stream-chat provider new-msgs '() (current-stream-cb))))
+                         (stream-chat-with-expert provider new-msgs '() (current-stream-cb))))
              fc)))
         (else
          (let* ((response  (make-assistant-message
@@ -403,4 +407,4 @@ Be concise. Prefer edit over write for modifying existing files."
 (def (agent-step messages)
   (let* ((provider (get-current-provider))
          (tools (get-tool-schemas)))
-    (provider-chat provider messages tools)))
+    (chat-with-expert provider messages tools)))
diff --git a/src/jcode/core/expert.ss b/src/jcode/core/expert.ss
new file mode 100644
index 0000000..d6fd0d1
--- /dev/null
+++ b/src/jcode/core/expert.ss
@@ -0,0 +1,140 @@
+;;; jcode expert escalation
+;;;
+;;; When the primary model is uncertain or unable to solve a problem, it can
+;;; emit the literal token <expert/> in its response to route the query to a
+;;; stronger "expert" model configured separately. Opt-in via the "expert"
+;;; block in config.
+;;;
+;;; Configuration (~/.jcode/config.json or ./jcode.json):
+;;;
+;;;   {
+;;;     "provider": "mlx",
+;;;     "model":    "qwen3-coder-30b",
+;;;     "expert": {
+;;;       "provider": "openrouter",
+;;;       "model":    "deepseek/deepseek-chat"
+;;;     }
+;;;   }
+;;;
+;;; When the primary response contains the sentinel and an expert is
+;;; configured, the same messages are re-sent to the expert provider and
+;;; that response replaces the primary one. If no expert is configured the
+;;; sentinel is silently stripped so accidental emission is harmless.
+
+(export *expert-sentinel*
+        wants-expert?
+        strip-expert-sentinel
+        config-expert-enabled?
+        get-expert-provider
+        expert-prompt-instructions
+        chat-with-expert
+        stream-chat-with-expert)
+
+(import :std/misc/string
+        :jcode/core/config
+        :jcode/core/log
+        :jcode/core/message
+        :jcode/provider/provider)
+
+(def logger (make-logger "expert"))
+
+;; XML-style self-closing tag — distinctive enough not to collide with
+;; normal prose or code, but harmless if accidentally emitted (stripped
+;; before the user sees the response).
+(def *expert-sentinel* "<expert/>")
+
+(def (wants-expert? content)
+  (and content
+       (string? content)
+       (string-contains content *expert-sentinel*)
+       #t))
+
+;; Multi-character string replace (string-replace in std/misc operates on
+;; chars). Builds output incrementally so it tolerates large content.
+(def (string-replace-all str old new)
+  (let ((old-len (string-length old))
+        (out (open-output-string)))
+    (let loop ((s str))
+      (let ((idx (string-contains s old)))
+        (cond
+          (idx
+           (put-string out (substring s 0 idx))
+           (put-string out new)
+           (loop (substring s (+ idx old-len) (string-length s))))
+          (else
+           (put-string out s)
+           (get-output-string out)))))))
+
+(def (strip-expert-sentinel content)
+  (if (and content (string? content))
+    (string-replace-all content *expert-sentinel* "")
+    content))
+
+(def (config-expert-enabled?)
+  (and (config-ref "expert" "provider")
+       (config-ref "expert" "model")
+       #t))
+
+(def (get-expert-provider)
+  (let ((name (config-ref "expert" "provider"))
+        (model (config-ref "expert" "model")))
+    (and name model
+         (make-provider name (config-get-provider-key name) model))))
+
+(def (expert-prompt-instructions)
+  (if (config-expert-enabled?)
+    (format "ESCALATION TO EXPERT MODEL:
+- If you cannot solve the problem, lack the knowledge to answer correctly, or have low confidence in your answer, emit the literal token ~a as the first thing in your response, then briefly state what you need help with and STOP.
+- This routes the query to a stronger expert model (~a/~a) which will replace your response.
+- Use this sparingly — only when you genuinely don't know. The expert is slower and more costly than you."
+      *expert-sentinel*
+      (config-ref "expert" "provider")
+      (config-ref "expert" "model"))
+    ""))
+
+;; Non-streaming wrapper. Calls the primary provider; if its response
+;; contains the sentinel and an expert is configured, re-sends the same
+;; messages to the expert and returns that response instead. The primary's
+;; attempt is discarded (not added to session).
+(def (chat-with-expert provider messages tools)
+  (let* ((response (provider-chat provider messages tools))
+         (content  (message-content response)))
+    (cond
+      ((and (wants-expert? content) (config-expert-enabled?))
+       (let ((expert (get-expert-provider)))
+         (log-info logger "escalating-to-expert"
+           `((from . ,(provider-name provider))
+             (to . ,(provider-name expert))
+             (model . ,(provider-model expert))))
+         (provider-chat expert messages tools)))
+      ((wants-expert? content)
+       (log-warn logger "expert-requested-but-not-configured" '())
+       (make-assistant-message
+         (strip-expert-sentinel content)
+         (message-tool-calls response)))
+      (else response))))
+
+;; Streaming wrapper. The primary stream is shown to the user as it arrives
+;; (so they see the partial attempt and the sentinel itself if emitted).
+;; Once the primary stream completes we check for the sentinel and, if
+;; present, stream the expert response. The expert's (content tool-calls
+;; usage) is what gets returned and recorded.
+(def (stream-chat-with-expert provider messages tools token-cb)
+  (let-values (((content tcs usage)
+                (provider-stream-chat provider messages tools token-cb)))
+    (cond
+      ((and (wants-expert? content) (config-expert-enabled?))
+       (let ((expert (get-expert-provider)))
+         (log-info logger "escalating-to-expert"
+           `((from . ,(provider-name provider))
+             (to . ,(provider-name expert))
+             (model . ,(provider-model expert))))
+         (when token-cb
+           (token-cb (format "\n\n[escalating to ~a/~a]\n\n"
+                       (provider-name expert)
+                       (provider-model expert))))
+         (provider-stream-chat expert messages tools token-cb)))
+      ((wants-expert? content)
+       (log-warn logger "expert-requested-but-not-configured" '())
+       (values (strip-expert-sentinel content) tcs usage))
+      (else (values content tcs usage)))))