| @@ -4044,9 +4044,9 @@ | ||
| 4044 | 4044 | private function mxchat_openai_token_param_for($model) { |
| 4045 | 4045 | if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'openai_token_param')) { |
| 4046 | 4046 | return MxChat_Model_Catalog::openai_token_param($model); |
| 4047 | 4047 | } |
| 4048 | - return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens'; | |
| 4048 | + return (strpos((string) $model, 'gpt-5') === 0 || strpos((string) $model, 'gpt-6') === 0) ? 'max_completion_tokens' : 'max_tokens'; // gpt-6 too (plan 45c22d) | |
| 4049 | 4049 | } |
| 4050 | 4050 | |
| 4051 | 4051 | /** |
| 4052 | 4052 | * plan-mxchat-20260715-7124f4: whether a NON-default temperature may be sent to |
| @@ -4060,9 +4060,9 @@ | ||
| 4060 | 4060 | private function mxchat_openai_supports_temperature_for($model) { |
| 4061 | 4061 | if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) { |
| 4062 | 4062 | return MxChat_Model_Catalog::supports_temperature($model); |
| 4063 | 4063 | } |
| 4064 | - return strpos((string) $model, 'gpt-5') !== 0; | |
| 4064 | + return strpos((string) $model, 'gpt-5') !== 0 && strpos((string) $model, 'gpt-6') !== 0; // gpt-6 too (plan 45c22d) | |
| 4065 | 4065 | } |
| 4066 | 4066 | |
| 4067 | 4067 | /** |
| 4068 | 4068 | * plan-mxchat-20260714-dcb71c: per-surface reasoning_effort, sourced from the |
| @@ -4082,9 +4082,9 @@ | ||
| 4082 | 4082 | return $this->mxchat_reasoning_effort_fallback($model, $context); |
| 4083 | 4083 | } |
| 4084 | 4084 | |
| 4085 | 4085 | private function mxchat_reasoning_effort_fallback($model, $context) { |
| 4086 | - if (strpos($model, 'gpt-5') !== 0) { | |
| 4086 | + if (strpos($model, 'gpt-5') !== 0 && strpos($model, 'gpt-6') !== 0) { // gpt-6 too (plan 45c22d) | |
| 4087 | 4087 | return null; |
| 4088 | 4088 | } |
| 4089 | 4089 | if ($context === 'websearch') { |
| 4090 | 4090 | $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano'); |
| @@ -4091,8 +4091,9 @@ | ||
| 4091 | 4091 | if (in_array($model, $no_reasoning_web, true)) return null; |
| 4092 | 4092 | if ($model === 'gpt-5.1-2025-11-13') return 'low'; |
| 4093 | 4093 | if ($model === 'gpt-5.5') return 'low'; |
| 4094 | 4094 | if ($model === 'gpt-5.4') return 'low'; |
| 4095 | + if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d | |
| 4095 | 4096 | if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low'; |
| 4096 | 4097 | return null; |
| 4097 | 4098 | } |
| 4098 | 4099 | // 'chat' |
| @@ -4103,8 +4104,9 @@ | ||
| 4103 | 4104 | if (in_array($model, $no_reasoning_models, true)) return null; |
| 4104 | 4105 | if ($model === 'gpt-5.1-2025-11-13') return 'low'; |
| 4105 | 4106 | if ($model === 'gpt-5.5') return 'none'; |
| 4106 | 4107 | if ($model === 'gpt-5.4') return 'none'; |
| 4108 | + if (in_array($model, array('gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d | |
| 4107 | 4109 | if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low'; |
| 4108 | 4110 | return 'minimal'; |
| 4109 | 4111 | } |
| 4110 | 4112 | |
| @@ -8534,17 +8536,24 @@ | ||
| 8534 | 8536 | // ----- (b) keyword leg ----- |
| 8535 | 8537 | $keyword_hits = array(); |
| 8536 | 8538 | $keyword_tokens = array(); |
| 8537 | 8539 | $keyword_leg = 'none'; |
| 8540 | + $keyword_code_match = array('exact' => 0, 'prefix' => 0); | |
| 8538 | 8541 | if ($hybrid_enabled) { |
| 8539 | 8542 | $keyword_tokens = MxChat_Pinecone_Documents::extract_code_tokens($user_query); |
| 8540 | 8543 | if (!empty($keyword_tokens)) { |
| 8541 | 8544 | $leg_k = min(20, $top_k); |
| 8545 | + // 3e83e4: the filter also matches the normalised code set stored on | |
| 8546 | + // each document, so "R165321320" reaches the "R1653" records; the | |
| 8547 | + // codes field comes back with the hit so exact matches rank first. | |
| 8542 | 8548 | $kw_filter = MxChat_Pinecone_Documents::merge_filters($user_filter, MxChat_Pinecone_Documents::keyword_filter($keyword_tokens)); |
| 8543 | - $kw_hits = MxChat_Pinecone_Documents::search_dense($user_embedding, $leg_k, $kw_filter, $docs_cfg); | |
| 8549 | + $kw_fields = array_merge(MxChat_Pinecone_Documents::RECORD_FIELDS, array(MxChat_Pinecone_Documents::CODES_FIELD)); | |
| 8550 | + $kw_hits = MxChat_Pinecone_Documents::search_dense($user_embedding, $leg_k, $kw_filter, $docs_cfg, $kw_fields); | |
| 8544 | 8551 | if (!is_wp_error($kw_hits) && !empty($kw_hits)) { |
| 8545 | - $keyword_hits = $kw_hits; | |
| 8552 | + $ranked = MxChat_Pinecone_Documents::rank_keyword_hits($kw_hits, $keyword_tokens); | |
| 8553 | + $keyword_hits = $ranked['hits']; | |
| 8546 | 8554 | $keyword_leg = 'filter'; |
| 8555 | + $keyword_code_match = array('exact' => $ranked['exact'], 'prefix' => $ranked['prefix']); | |
| 8547 | 8556 | } else { |
| 8548 | 8557 | $bm25 = MxChat_Pinecone_Documents::search_text($user_query, $leg_k, $user_filter, $docs_cfg); |
| 8549 | 8558 | if (!is_wp_error($bm25) && !empty($bm25)) { |
| 8550 | 8559 | // BM25 scores are not cosines: anchor on the dense score when |
| @@ -8563,8 +8572,9 @@ | ||
| 8563 | 8572 | } |
| 8564 | 8573 | } |
| 8565 | 8574 | $this->last_similarity_analysis['keyword_tokens'] = $keyword_tokens; |
| 8566 | 8575 | $this->last_similarity_analysis['keyword_leg'] = $keyword_leg; |
| 8576 | + $this->last_similarity_analysis['keyword_codes'] = $keyword_code_match; | |
| 8567 | 8577 | |
| 8568 | 8578 | // ----- access + threshold ----- |
| 8569 | 8579 | $matches_by_id = array(); |
| 8570 | 8580 | foreach ($dense as $hit) { |
| @@ -8621,8 +8631,44 @@ | ||
| 8621 | 8631 | $ordered[] = $cand; |
| 8622 | 8632 | } |
| 8623 | 8633 | } else { |
| 8624 | 8634 | $fused = $this->mxchat_rrf_fuse(array_slice($vector_candidates, 0, 20), $keyword_rows, 60); |
| 8635 | + // 3e83e4: a prefix-only code hit (the visitor typed "R165321320", the | |
| 8636 | + // record only says "R1653") ranks BELOW every exact code hit. Fusion | |
| 8637 | + // alone can tie them when the dense leg disagrees by one place, so | |
| 8638 | + // prefix rows are capped just under the lowest exact row; exact rows | |
| 8639 | + // and vector-only rows keep the scores fusion gave them. | |
| 8640 | + $exact_ids = array(); | |
| 8641 | + $prefix_ids = array(); | |
| 8642 | + foreach ($keyword_hits as $kh) { | |
| 8643 | + if (($kh['code_match'] ?? 'exact') === 'prefix') { | |
| 8644 | + $prefix_ids[$kh['id']] = true; | |
| 8645 | + } else { | |
| 8646 | + $exact_ids[$kh['id']] = true; | |
| 8647 | + } | |
| 8648 | + } | |
| 8649 | + if (!empty($exact_ids) && !empty($prefix_ids)) { | |
| 8650 | + $floor = null; | |
| 8651 | + foreach ($fused as $fid => $f) { | |
| 8652 | + if (isset($exact_ids[$fid])) { | |
| 8653 | + $floor = ($floor === null) ? $f['rrf'] : min($floor, $f['rrf']); | |
| 8654 | + } | |
| 8655 | + } | |
| 8656 | + if ($floor !== null) { | |
| 8657 | + $capped = 0; | |
| 8658 | + foreach ($fused as $fid => $f) { | |
| 8659 | + if (isset($prefix_ids[$fid]) && $f['rrf'] >= $floor) { | |
| 8660 | + $capped++; | |
| 8661 | + $fused[$fid]['rrf'] = $floor - $capped * 1e-7; | |
| 8662 | + } | |
| 8663 | + } | |
| 8664 | + if ($capped > 0) { | |
| 8665 | + uasort($fused, function ($a, $b) { | |
| 8666 | + return $b['rrf'] <=> $a['rrf']; | |
| 8667 | + }); | |
| 8668 | + } | |
| 8669 | + } | |
| 8670 | + } | |
| 8625 | 8671 | $rank = 0; |
| 8626 | 8672 | foreach ($fused as $f) { |
| 8627 | 8673 | $rank++; |
| 8628 | 8674 | $fused_rank_map[$f['id']] = $rank; |
| @@ -10395,17 +10441,20 @@ | ||
| 10395 | 10441 | } |
| 10396 | 10442 | |
| 10397 | 10443 | /* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */ |
| 10398 | 10444 | private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) { |
| 10445 | + $depth = MxChat_Tool_Registry::max_depth(); | |
| 10446 | + $budget = MxChat_Tool_Registry::max_tool_calls_per_turn(); | |
| 10447 | + $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools); | |
| 10399 | 10448 | $messages = array(); |
| 10400 | - $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content); | |
| 10449 | + // plan 150b72: the model is told its per-turn tool budget up front (only | |
| 10450 | + // when tools are actually offered). A local string — the owner's | |
| 10451 | + // instructions from get_system_instructions() are never mutated. | |
| 10452 | + $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content . $this->mxchat_fc_budget_notice($budget, $tool_schema)); | |
| 10401 | 10453 | foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) { |
| 10402 | 10454 | $messages[] = $m; |
| 10403 | 10455 | } |
| 10404 | 10456 | |
| 10405 | - $depth = MxChat_Tool_Registry::max_depth(); | |
| 10406 | - $budget = MxChat_Tool_Registry::max_tool_calls_per_turn(); | |
| 10407 | - $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools); | |
| 10408 | 10457 | $used_tool = false; |
| 10409 | 10458 | $calls_made = 0; |
| 10410 | 10459 | $tool_texts = array(); |
| 10411 | 10460 | $out_tokens = 0; |
| @@ -10432,8 +10481,18 @@ | ||
| 10432 | 10481 | } |
| 10433 | 10482 | if ($offer_tools) { |
| 10434 | 10483 | $body['tools'] = $tool_schema; |
| 10435 | 10484 | $body['tool_choice'] = 'auto'; |
| 10485 | + if (($budget - $calls_made) === 1) { | |
| 10486 | + // plan 150b72: one call left for this turn — ask for it as ONE | |
| 10487 | + // call. Without this the model fans out, one call runs, the | |
| 10488 | + // rest get the budget-exhausted result and it answers from a | |
| 10489 | + // fraction of the data. Only on the last-call hop: a wide, | |
| 10490 | + // legitimate fan-out earlier in the turn stays parallel. An | |
| 10491 | + // owner's http_request_args filter runs after this body is | |
| 10492 | + // built, so a value set there still wins. | |
| 10493 | + $body['parallel_tool_calls'] = false; | |
| 10494 | + } | |
| 10436 | 10495 | } |
| 10437 | 10496 | $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']); |
| 10438 | 10497 | if ($r['code'] !== 200 || !is_array($r['data'])) { |
| 10439 | 10498 | return $this->mxchat_fc_degrade('openai', $this->mxchat_fc_failure_detail($r), $used_tool, $calls_made, $tool_texts); |
| @@ -10485,8 +10544,15 @@ | ||
| 10485 | 10544 | 'tool_call_id' => $tool_call_id, |
| 10486 | 10545 | 'content' => $exec['content'], |
| 10487 | 10546 | ); |
| 10488 | 10547 | } |
| 10548 | + if ($calls_made < $budget) { | |
| 10549 | + // plan 150b72: say what is left after this hop's calls — as its own | |
| 10550 | + // system line, never inside a tool's result (a result the model | |
| 10551 | + // parses as data stays pure). A spent budget is already covered by | |
| 10552 | + // mxchat_fc_budget_exhausted_text() on the refused calls. | |
| 10553 | + $messages[] = array('role' => 'system', 'content' => $this->mxchat_fc_remaining_text($budget - $calls_made)); | |
| 10554 | + } | |
| 10489 | 10555 | } |
| 10490 | 10556 | return $this->mxchat_fc_degrade('openai', 'hop depth exhausted', $used_tool, $calls_made, $tool_texts); |
| 10491 | 10557 | } |
| 10492 | 10558 | |
| @@ -10497,8 +10563,12 @@ | ||
| 10497 | 10563 | |
| 10498 | 10564 | $depth = MxChat_Tool_Registry::max_depth(); |
| 10499 | 10565 | $budget = MxChat_Tool_Registry::max_tool_calls_per_turn(); |
| 10500 | 10566 | $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools); |
| 10567 | + // plan 150b72: budget sentence INSIDE the single cached system block, so the | |
| 10568 | + // cache_control breakpoint mxchat_anthropic_system_blocks() sets still | |
| 10569 | + // covers tools + system as one prefix (the sentence is constant per turn). | |
| 10570 | + $system .= $this->mxchat_fc_budget_notice($budget, $tool_schema); | |
| 10501 | 10571 | $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']); |
| 10502 | 10572 | $used_tool = false; |
| 10503 | 10573 | $calls_made = 0; |
| 10504 | 10574 | $tool_texts = array(); |
| @@ -10515,8 +10585,12 @@ | ||
| 10515 | 10585 | if ($omit_temp) unset($body['temperature']); |
| 10516 | 10586 | if ($offer_tools) { |
| 10517 | 10587 | $body['tools'] = $tool_schema; |
| 10518 | 10588 | $body['tool_choice'] = array('type' => 'auto'); |
| 10589 | + if (($budget - $calls_made) === 1) { | |
| 10590 | + // plan 150b72: see the OpenAI loop — one call left, ask for one call. | |
| 10591 | + $body['tool_choice']['disable_parallel_tool_use'] = true; | |
| 10592 | + } | |
| 10519 | 10593 | } |
| 10520 | 10594 | $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']); |
| 10521 | 10595 | if ($r['code'] !== 200 || !is_array($r['data'])) { |
| 10522 | 10596 | return $this->mxchat_fc_degrade('anthropic', $this->mxchat_fc_failure_detail($r), $used_tool, $calls_made, $tool_texts); |
| @@ -10569,8 +10643,13 @@ | ||
| 10569 | 10643 | 'tool_use_id' => $tool_use_id, |
| 10570 | 10644 | 'content' => $exec['content'], |
| 10571 | 10645 | ); |
| 10572 | 10646 | } |
| 10647 | + if ($calls_made < $budget) { | |
| 10648 | + // plan 150b72: remaining-count line as a text block AFTER the | |
| 10649 | + // tool_result blocks (Anthropic requires the results first). | |
| 10650 | + $results[] = array('type' => 'text', 'text' => $this->mxchat_fc_remaining_text($budget - $calls_made)); | |
| 10651 | + } | |
| 10573 | 10652 | $messages[] = array('role' => 'user', 'content' => $results); |
| 10574 | 10653 | } |
| 10575 | 10654 | return $this->mxchat_fc_degrade('anthropic', 'hop depth exhausted', $used_tool, $calls_made, $tool_texts); |
| 10576 | 10655 | } |
| @@ -10576,10 +10655,18 @@ | ||
| 10576 | 10655 | } |
| 10577 | 10656 | |
| 10578 | 10657 | /* ---------------- Google Gemini loop ---------------- */ |
| 10579 | 10658 | private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) { |
| 10659 | + $depth = MxChat_Tool_Registry::max_depth(); | |
| 10660 | + $budget = MxChat_Tool_Registry::max_tool_calls_per_turn(); | |
| 10661 | + $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools); | |
| 10580 | 10662 | $contents = array(); |
| 10581 | - $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content))); | |
| 10663 | + // plan 150b72: the same budget sentence as the other two loops, in the | |
| 10664 | + // same place Gemini gets its system text. Gemini has no documented | |
| 10665 | + // per-request parallel-disable flag (functionCallingConfig carries mode | |
| 10666 | + // and allowedFunctionNames only — checked 2026-09-27), so the prompt | |
| 10667 | + // side is all it gets; nothing undocumented is sent. | |
| 10668 | + $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content . $this->mxchat_fc_budget_notice($budget, $tool_schema)))); | |
| 10582 | 10669 | $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.'))); |
| 10583 | 10670 | foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) { |
| 10584 | 10671 | $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'), |
| 10585 | 10672 | 'parts' => array(array('text' => $m['content']))); |
| @@ -10584,11 +10671,8 @@ | ||
| 10584 | 10671 | $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'), |
| 10585 | 10672 | 'parts' => array(array('text' => $m['content']))); |
| 10586 | 10673 | } |
| 10587 | 10674 | |
| 10588 | - $depth = MxChat_Tool_Registry::max_depth(); | |
| 10589 | - $budget = MxChat_Tool_Registry::max_tool_calls_per_turn(); | |
| 10590 | - $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools); | |
| 10591 | 10675 | // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the |
| 10592 | 10676 | // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a |
| 10593 | 10677 | // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would |
| 10594 | 10678 | // just answer in text and never emit a tool call. Always use v1beta for the FC loop — |
| @@ -10679,8 +10763,13 @@ | ||
| 10679 | 10763 | // guidance). Older models omit the id — then we send none, exactly as before. |
| 10680 | 10764 | if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; } |
| 10681 | 10765 | $resp_parts[] = array('functionResponse' => $fr); |
| 10682 | 10766 | } |
| 10767 | + if ($calls_made < $budget) { | |
| 10768 | + // plan 150b72: remaining-count line as a text part after the | |
| 10769 | + // functionResponse parts of the same turn. | |
| 10770 | + $resp_parts[] = array('text' => $this->mxchat_fc_remaining_text($budget - $calls_made)); | |
| 10771 | + } | |
| 10683 | 10772 | $contents[] = array('role' => 'user', 'parts' => $resp_parts); |
| 10684 | 10773 | } |
| 10685 | 10774 | return $this->mxchat_fc_degrade('gemini', 'hop depth exhausted', $used_tool, $calls_made, $tool_texts); |
| 10686 | 10775 | } |
| @@ -10697,8 +10786,55 @@ | ||
| 10697 | 10786 | * tool_call_id, Gemini a functionResponse per functionCall. |
| 10698 | 10787 | */ |
| 10699 | 10788 | private function mxchat_fc_budget_exhausted_text() { |
| 10700 | 10789 | return 'Tool-call budget for this turn is exhausted; answer with what you already have.'; |
| 10790 | +} | |
| 10791 | + | |
| 10792 | +/** | |
| 10793 | + * plan 150b72 — the budget sentence the model reads BEFORE it plans its | |
| 10794 | + * calls. Before this the model learned the cap only from the refused calls' | |
| 10795 | + * exhausted text, i.e. after it had already overrun it (3c23ab made | |
| 10796 | + * exhaustion safe, not visible). Returned WITH a leading space so callers | |
| 10797 | + * concatenate it onto their system text; '' when no tools are offered or the | |
| 10798 | + * owner suppresses it. Two sentences at most: it precedes every owner's own | |
| 10799 | + * instructions and must not compete with them. Model-facing, never shown. | |
| 10800 | + * | |
| 10801 | + * @param int $budget Resolved per-turn cap (MxChat_Tool_Registry::max_tool_calls_per_turn()). | |
| 10802 | + * @param array $tool_schema Provider-shaped tool list; empty = no tools offered = no sentence. | |
| 10803 | + * @return string | |
| 10804 | + */ | |
| 10805 | +private function mxchat_fc_budget_notice($budget, $tool_schema) { | |
| 10806 | + $budget = (int) $budget; | |
| 10807 | + if ($budget < 1 || empty($tool_schema)) { | |
| 10808 | + return ''; | |
| 10809 | + } | |
| 10810 | + $notice = sprintf( | |
| 10811 | + 'You may make at most %d tool call%s while answering this message. Prefer one call with complete arguments over several narrow ones, and answer from what you have once the calls are used up.', | |
| 10812 | + $budget, | |
| 10813 | + $budget === 1 ? '' : 's' | |
| 10814 | + ); | |
| 10815 | + /** | |
| 10816 | + * Filter the tool-budget sentence appended to the system content when | |
| 10817 | + * tools are offered. Return '' to send nothing (an owner who writes their | |
| 10818 | + * own tool policy). | |
| 10819 | + * | |
| 10820 | + * @param string $notice The sentence(s). | |
| 10821 | + * @param int $budget The resolved per-turn tool-call budget. | |
| 10822 | + */ | |
| 10823 | + $notice = apply_filters('mxchat_function_calling_budget_notice', $notice, $budget); | |
| 10824 | + $notice = is_string($notice) ? trim($notice) : ''; | |
| 10825 | + return $notice === '' ? '' : ' ' . $notice; | |
| 10826 | +} | |
| 10827 | + | |
| 10828 | +/** | |
| 10829 | + * plan 150b72 — one line appended to a hop's tool-result batch (its own | |
| 10830 | + * message / block / part, never inside a tool's result) saying how many | |
| 10831 | + * calls remain for the turn. Only while the budget is not yet spent — a | |
| 10832 | + * spent budget is covered by mxchat_fc_budget_exhausted_text(). | |
| 10833 | + */ | |
| 10834 | +private function mxchat_fc_remaining_text($remaining) { | |
| 10835 | + $remaining = max(0, (int) $remaining); | |
| 10836 | + return sprintf('%d tool call%s for this message.', $remaining, $remaining === 1 ? ' remains' : 's remain'); | |
| 10701 | 10837 | } |
| 10702 | 10838 | |
| 10703 | 10839 | /** |
| 10704 | 10840 | * plan 3c23ab — the loop cannot finish normally (provider rejected a hop, or |