expert: route stuck/uncertain prompts to a stronger model
ober
00cd34ebdcb1c558c4495d71ac77d9f49786faf1
--- 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))) new file mode 100644 --- /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)))))