echotools 2.3.39__tar.gz → 2.3.42__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. {echotools-2.3.39 → echotools-2.3.42}/PKG-INFO +1 -1
  2. {echotools-2.3.39 → echotools-2.3.42}/pyproject.toml +1 -1
  3. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/stream.py +50 -4
  4. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/history.py +39 -0
  5. echotools-2.3.42/src/echotools/exec/fncall/prompt/history_format.py +116 -0
  6. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/inject.py +6 -5
  7. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/prompt_helpers.py +1 -1
  8. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml.py +29 -6
  9. echotools-2.3.42/src/echotools/exec/fncall/protocols/entml_think/__init__.py +31 -0
  10. echotools-2.3.39/src/echotools/exec/fncall/protocols/entml_thinking_parse.py → echotools-2.3.42/src/echotools/exec/fncall/protocols/entml_think/parse.py +86 -38
  11. {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/PKG-INFO +1 -1
  12. {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/SOURCES.txt +5 -4
  13. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_protocol.py +30 -3
  14. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_thinking_history.py +1 -1
  15. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_thinking_parse.py +7 -1
  16. echotools-2.3.42/src/tests/test_fncall_stream_thinking.py +110 -0
  17. echotools-2.3.39/src/echotools/exec/fncall/prompt/history_format.py +0 -89
  18. echotools-2.3.39/src/echotools/exec/fncall/protocols/entml_sanitize.py +0 -24
  19. {echotools-2.3.39 → echotools-2.3.42}/README.md +0 -0
  20. {echotools-2.3.39 → echotools-2.3.42}/docs/README.md +0 -0
  21. {echotools-2.3.39 → echotools-2.3.42}/setup.cfg +0 -0
  22. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/__init__.py +0 -0
  23. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/__init__.py +0 -0
  24. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/list_cache.py +0 -0
  25. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/memory_cache.py +0 -0
  26. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/__init__.py +0 -0
  27. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/base.py +0 -0
  28. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center.py +0 -0
  29. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center_notify.py +0 -0
  30. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center_template.py +0 -0
  31. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/loader.py +0 -0
  32. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/merge.py +0 -0
  33. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/__init__.py +0 -0
  34. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/base.py +0 -0
  35. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/classify.py +0 -0
  36. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/common.py +0 -0
  37. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/http.py +0 -0
  38. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/ids/__init__.py +0 -0
  39. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/ids/generator.py +0 -0
  40. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/__init__.py +0 -0
  41. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/io_utils.py +0 -0
  42. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/printstream.py +0 -0
  43. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/logger/__init__.py +0 -0
  44. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/logger/manager.py +0 -0
  45. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/retry/__init__.py +0 -0
  46. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/retry/retry.py +0 -0
  47. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/compat.py +0 -0
  48. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/__init__.py +0 -0
  49. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/candidate.py +0 -0
  50. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/dispatcher.py +0 -0
  51. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/proxy_selector.py +0 -0
  52. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/race.py +0 -0
  53. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/selector.py +0 -0
  54. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/usage.py +0 -0
  55. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/__init__.py +0 -0
  56. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/__init__.py +0 -0
  57. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/xml_params.py +0 -0
  58. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/xml_parser.py +0 -0
  59. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/__init__.py +0 -0
  60. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/templates.py +0 -0
  61. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/__init__.py +0 -0
  62. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_invoke.py +0 -0
  63. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_patterns.py +0 -0
  64. /echotools-2.3.39/src/echotools/exec/fncall/protocols/entml_thinking.py → /echotools-2.3.42/src/echotools/exec/fncall/protocols/entml_think/core.py +0 -0
  65. /echotools-2.3.39/src/echotools/exec/fncall/protocols/entml_thinking_history.py → /echotools-2.3.42/src/echotools/exec/fncall/protocols/entml_think/hist.py +0 -0
  66. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_tools.py +0 -0
  67. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_values.py +0 -0
  68. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/registry.py +0 -0
  69. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/__init__.py +0 -0
  70. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/coercion.py +0 -0
  71. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/loop_detect.py +0 -0
  72. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/normalization.py +0 -0
  73. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/schema_render.py +0 -0
  74. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/xml_helpers.py +0 -0
  75. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/keys.py +0 -0
  76. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/__init__.py +0 -0
  77. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/manager.py +0 -0
  78. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/updater.py +0 -0
  79. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/updater_pull.py +0 -0
  80. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/process/__init__.py +0 -0
  81. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/process/port.py +0 -0
  82. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/protocol/__init__.py +0 -0
  83. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/protocol/base.py +0 -0
  84. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/__init__.py +0 -0
  85. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/conpty.py +0 -0
  86. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/__init__.py +0 -0
  87. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/core.py +0 -0
  88. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/proc.py +0 -0
  89. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/unix.py +0 -0
  90. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/win.py +0 -0
  91. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/sanitize.py +0 -0
  92. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/__init__.py +0 -0
  93. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/base.py +0 -0
  94. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/client.py +0 -0
  95. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/ssh.py +0 -0
  96. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/tmux.py +0 -0
  97. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/__init__.py +0 -0
  98. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/charmap.py +0 -0
  99. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/spinner.py +0 -0
  100. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/ui.py +0 -0
  101. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/__init__.py +0 -0
  102. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/bus.py +0 -0
  103. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/event.py +0 -0
  104. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/__init__.py +0 -0
  105. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/spinner.py +0 -0
  106. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/spinner_color.py +0 -0
  107. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/__init__.py +0 -0
  108. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/context.py +0 -0
  109. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/span.py +0 -0
  110. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/tracer.py +0 -0
  111. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/translate.py +0 -0
  112. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/watcher/__init__.py +0 -0
  113. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/watcher/file_watcher.py +0 -0
  114. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/__init__.py +0 -0
  115. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/application.py +0 -0
  116. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/broker.py +0 -0
  117. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/app.js +0 -0
  118. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/filezone.js +0 -0
  119. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/motionkit.js +0 -0
  120. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sendbutton.js +0 -0
  121. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sortablelist.css +0 -0
  122. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sortablelist.js +0 -0
  123. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/textinput.js +0 -0
  124. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/voiceinput.js +0 -0
  125. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter-500.woff2 +0 -0
  126. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter-600.woff2 +0 -0
  127. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter400.woff2 +0 -0
  128. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/tailwind.css +0 -0
  129. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/inputbox.css +0 -0
  130. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/inputbox.js +0 -0
  131. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/style.css +0 -0
  132. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/voicegif.js +0 -0
  133. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/middleware.py +0 -0
  134. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/stats.py +0 -0
  135. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/utils.py +0 -0
  136. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/files/__init__.py +0 -0
  137. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/files/file_util.py +0 -0
  138. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/network/__init__.py +0 -0
  139. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/network/http_utils.py +0 -0
  140. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/__init__.py +0 -0
  141. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/base.py +0 -0
  142. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/discovery.py +0 -0
  143. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/registry.py +0 -0
  144. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/registry_ops.py +0 -0
  145. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/proxy/__init__.py +0 -0
  146. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/proxy/manager.py +0 -0
  147. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/runtime/__init__.py +0 -0
  148. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/runtime/collector.py +0 -0
  149. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/scheduler/__init__.py +0 -0
  150. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/scheduler/scheduler.py +0 -0
  151. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/sdk/__init__.py +0 -0
  152. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/sdk/facade.py +0 -0
  153. {echotools-2.3.39 → echotools-2.3.42}/src/echotools/version.py +0 -0
  154. {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/dependency_links.txt +0 -0
  155. {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/requires.txt +0 -0
  156. {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/top_level.txt +0 -0
  157. {echotools-2.3.39 → echotools-2.3.42}/src/tests/bench_import.py +0 -0
  158. {echotools-2.3.39 → echotools-2.3.42}/src/tests/conftest.py +0 -0
  159. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_cache_retry.py +0 -0
  160. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_completion.py +0 -0
  161. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_config.py +0 -0
  162. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_config_extended.py +0 -0
  163. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_console.py +0 -0
  164. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_coverage_core.py +0 -0
  165. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch.py +0 -0
  166. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch_extended.py +0 -0
  167. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch_race.py +0 -0
  168. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_tools.py +0 -0
  169. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_errors.py +0 -0
  170. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_errors_common.py +0 -0
  171. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_events.py +0 -0
  172. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_events_event.py +0 -0
  173. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_facade.py +0 -0
  174. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_fncall.py +0 -0
  175. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_ids.py +0 -0
  176. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_imports.py +0 -0
  177. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_io.py +0 -0
  178. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_keys.py +0 -0
  179. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_lifecycle.py +0 -0
  180. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_logger.py +0 -0
  181. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_merge.py +0 -0
  182. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_plugin.py +0 -0
  183. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_printstream.py +0 -0
  184. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_protocols.py +0 -0
  185. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_proxy.py +0 -0
  186. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_proxy_selector.py +0 -0
  187. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_runtime.py +0 -0
  188. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_scheduler.py +0 -0
  189. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_tracing.py +0 -0
  190. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_usage.py +0 -0
  191. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_web.py +0 -0
  192. {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_web_broker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echotools
3
- Version: 2.3.39
3
+ Version: 2.3.42
4
4
  Summary: 通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链
5
5
  Author: nichengfuben
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "echotools"
7
- version = "2.3.39"
7
+ version = "2.3.42"
8
8
  description = "通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链"
9
9
  readme = "docs/README.md"
10
10
  requires-python = ">=3.8"
@@ -13,7 +13,7 @@ from echotools.exec.protocol.base import ToolProtocol
13
13
  # 懒导入:仅 entml 协议需要
14
14
  def _make_thinking_filter(protocol: ToolProtocol):
15
15
  if getattr(protocol, "id", None) == "entml":
16
- from echotools.exec.fncall.protocols.entml_thinking_parse import (
16
+ from echotools.exec.fncall.protocols.entml_think.parse import (
17
17
  EntmlThinkingStreamFilter,
18
18
  )
19
19
  return EntmlThinkingStreamFilter()
@@ -136,12 +136,59 @@ class FncallStreamParser:
136
136
 
137
137
  return buffer, ""
138
138
 
139
+ def _append_content_text(self, text: str) -> None:
140
+ if text:
141
+ self._text_parts.append(text)
142
+
143
+ def _feed_content_waiting(self, text: str) -> None:
144
+ """thinking 已闭合后,对可见正文做 invoke 检测(不再经过 thinking 过滤器)。"""
145
+ if not text:
146
+ return
147
+ trigger_tags = self._protocol.get_trigger_tags()
148
+ found, pos = self._protocol.detect_start(text)
149
+ if not found:
150
+ safe, remain = self._split_safe_text(text, trigger_tags)
151
+ if safe:
152
+ self._append_content_text(safe)
153
+ self._waiting_tail = remain
154
+ return
155
+
156
+ if pos > 0:
157
+ self._append_content_text(text[:pos])
158
+
159
+ self._fncall_buf = text[pos:]
160
+ self._detected = True
161
+ self._state = self.IN_FUNCTION_CALLS
162
+ if self._is_call_closed():
163
+ self._state = self.DONE
164
+
165
+ def _feed_waiting_thinking_plain(self, combined: str) -> None:
166
+ """未闭合 thinking 阶段:块内一律按纯文本进 thinking,不检测 invoke。"""
167
+ assert self._thinking_filter is not None
168
+ for kind, part in self._thinking_filter.feed(combined):
169
+ if kind == "thinking":
170
+ self._thinking_parts.append(part)
171
+ elif part:
172
+ self._feed_content_waiting(part)
173
+
139
174
  def _feed_waiting(self, chunk: str) -> None:
140
175
  """在 WAITING_FOR_TAG 状态下处理新块。"""
141
176
  combined = self._waiting_tail + chunk
142
- found, pos = self._protocol.detect_start(combined)
177
+ self._waiting_tail = ""
178
+
179
+ if self._thinking_filter is not None:
180
+ from echotools.exec.fncall.protocols.entml_think.parse import (
181
+ has_unclosed_entml_thinking,
182
+ )
183
+ if (
184
+ self._thinking_filter.in_open_thinking()
185
+ or has_unclosed_entml_thinking(combined)
186
+ ):
187
+ self._feed_waiting_thinking_plain(combined)
188
+ return
143
189
 
144
190
  trigger_tags = self._protocol.get_trigger_tags()
191
+ found, pos = self._protocol.detect_start(combined)
145
192
 
146
193
  if not found:
147
194
  safe, remain = self._split_safe_text(combined, trigger_tags)
@@ -154,7 +201,6 @@ class FncallStreamParser:
154
201
  self._emit_text(combined[:pos])
155
202
 
156
203
  self._fncall_buf = combined[pos:]
157
- self._waiting_tail = ""
158
204
  self._detected = True
159
205
  self._state = self.IN_FUNCTION_CALLS
160
206
 
@@ -216,7 +262,7 @@ class FncallStreamParser:
216
262
 
217
263
  # 兜底:剥离残留 <entml:thinking>(holdback 边界/分片异常时)
218
264
  if self._thinking_filter is not None and clean_text:
219
- from echotools.exec.fncall.protocols.entml_thinking_parse import (
265
+ from echotools.exec.fncall.protocols.entml_think.parse import (
220
266
  split_entml_thinking,
221
267
  )
222
268
  clean_text, more_thinking = split_entml_thinking(clean_text)
@@ -39,6 +39,45 @@ _TOOL_CALL_MARKER_RE = re.compile(
39
39
  re.MULTILINE,
40
40
  )
41
41
 
42
+ # entml 标签清理正则
43
+ _ENTML_FNCALL_BLOCK_RE = re.compile(
44
+ r"<entml:function_calls\b[^>]*>.*?</entml:function_calls>",
45
+ re.DOTALL,
46
+ )
47
+ _ENTML_INVOKE_RE = re.compile(
48
+ r'<entml:invoke\s+name="[^"]+"\s*>.*?</entml:invoke>',
49
+ re.DOTALL,
50
+ )
51
+ _ENTML_PARAMETERS_RE = re.compile(
52
+ r'<entml:parameters>.*?</entml:parameters>',
53
+ re.DOTALL,
54
+ )
55
+ _ENTML_PARAM_RE = re.compile(
56
+ r'<entml:parameter\s+name="[^"]+"[^>]*>.*?</entml:parameter>',
57
+ re.DOTALL,
58
+ )
59
+ # 清理任意 entml:* 标签
60
+ _ENTML_ANY_TAG_RE = re.compile(
61
+ r'<entml:[a-zA-Z_][\w]*\b[^>]*>.*?</entml:[a-zA-Z_][\w]*>',
62
+ re.DOTALL,
63
+ )
64
+
65
+
66
+ def _clean_entml_tags(content: str) -> str:
67
+ """移除内容中的所有 entml 标签及其内部内容。"""
68
+ if not content:
69
+ return content
70
+ cleaned = content
71
+ cleaned = _ENTML_FNCALL_BLOCK_RE.sub("", cleaned)
72
+ cleaned = _ENTML_INVOKE_RE.sub("", cleaned)
73
+ cleaned = _ENTML_PARAMETERS_RE.sub("", cleaned)
74
+ cleaned = _ENTML_PARAM_RE.sub("", cleaned)
75
+ cleaned = _ENTML_ANY_TAG_RE.sub("", cleaned)
76
+ # 清理多余空行
77
+ lines = [line for line in cleaned.splitlines() if line.strip()]
78
+ return "\n".join(lines)
79
+
80
+
42
81
  # ---------------------------------------------------------------------------
43
82
  # 渲染工具调用 / 工具结果
44
83
  # ---------------------------------------------------------------------------
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ """Conversation history block formatting."""
4
+
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ from echotools.exec.fncall.shared.normalization import normalize_content
8
+
9
+ from .prompt_helpers import (
10
+ format_assistant_block,
11
+ format_assistant_block_with_results,
12
+ format_tool_block,
13
+ format_user_block,
14
+ join_history_parts,
15
+ )
16
+
17
+
18
+ def history_contains_tool_calls(messages: List[Dict[str, Any]]) -> bool:
19
+ """历史消息中是否包含 assistant tool_calls 或 tool 结果。"""
20
+ for m in messages:
21
+ role = m.get("role") or "user"
22
+ if role == "assistant" and m.get("tool_calls"):
23
+ return True
24
+ if role == "tool":
25
+ return True
26
+ return False
27
+
28
+
29
+ def _append_user_part(
30
+ parts: List[Tuple[str, bool]],
31
+ m: Dict[str, Any],
32
+ clean_fn: Any,
33
+ ) -> None:
34
+ content_str = normalize_content(m.get("content", ""))
35
+ parts.append((format_user_block(content_str, clean_fn), False))
36
+
37
+
38
+ def _append_assistant_part(
39
+ parts: List[Tuple[str, bool]],
40
+ messages: List[Dict[str, Any]],
41
+ index: int,
42
+ protocol: Optional[Any],
43
+ call_id_to_name: Dict[str, str],
44
+ seen_assistant_keys: set,
45
+ *,
46
+ include_thinking_in_history: bool,
47
+ ) -> int:
48
+ m = messages[index]
49
+ content_str = normalize_content(m.get("content", ""))
50
+ tcs = m.get("tool_calls") or []
51
+ if tcs:
52
+ tool_msgs = []
53
+ j = index + 1
54
+ while j < len(messages) and (messages[j].get("role") or "user") == "tool":
55
+ tool_msgs.append(messages[j])
56
+ j += 1
57
+ blocks = format_assistant_block_with_results(
58
+ m, tool_msgs, protocol, call_id_to_name, seen_assistant_keys,
59
+ include_thinking_in_history=include_thinking_in_history,
60
+ )
61
+ for block in blocks:
62
+ parts.append((block, False))
63
+ return j
64
+
65
+ blocks = format_assistant_block(
66
+ m, content_str, protocol, call_id_to_name, seen_assistant_keys,
67
+ include_thinking_in_history=include_thinking_in_history,
68
+ )
69
+ for block in blocks:
70
+ parts.append((block, False))
71
+ return index + 1
72
+
73
+
74
+ def _format_conversation_history(
75
+ messages: List[Dict[str, Any]],
76
+ protocol: Optional[Any] = None,
77
+ *,
78
+ include_thinking_in_history: bool = False,
79
+ ) -> str:
80
+ if not messages:
81
+ return ""
82
+
83
+ call_id_to_name: Dict[str, str] = {}
84
+ seen_assistant_keys = set()
85
+ parts: List[Tuple[str, bool]] = []
86
+ clean_fn = protocol.clean_tags if protocol and hasattr(protocol, "clean_tags") else None
87
+
88
+ i = 0
89
+ while i < len(messages):
90
+ m = messages[i]
91
+ role: str = m.get("role") or "user"
92
+
93
+ if role == "user":
94
+ _append_user_part(parts, m, clean_fn)
95
+ i += 1
96
+ continue
97
+
98
+ if role == "assistant":
99
+ i = _append_assistant_part(
100
+ parts, messages, i, protocol, call_id_to_name,
101
+ seen_assistant_keys,
102
+ include_thinking_in_history=include_thinking_in_history,
103
+ )
104
+ continue
105
+
106
+ if role == "tool":
107
+ content_str = normalize_content(m.get("content", ""))
108
+ parts.append((format_tool_block(m, content_str, call_id_to_name), True))
109
+ i += 1
110
+ continue
111
+
112
+ content_str = normalize_content(m.get("content", ""))
113
+ parts.append((f"<{role}>\n{content_str}\n</{role}>", False))
114
+ i += 1
115
+
116
+ return join_history_parts(parts)
@@ -22,8 +22,8 @@ from echotools.exec.fncall.protocols.entml import (
22
22
  format_entml_conversation_history,
23
23
  format_entml_current_user_message,
24
24
  )
25
- from echotools.exec.fncall.protocols.entml_thinking import build_entml_thinking_section
26
- from echotools.exec.fncall.protocols.entml_thinking_history import (
25
+ from echotools.exec.fncall.protocols.entml_think.core import build_entml_thinking_section
26
+ from echotools.exec.fncall.protocols.entml_think.hist import (
27
27
  apply_thinking_history_policy,
28
28
  parse_include_thinking_in_history,
29
29
  )
@@ -145,12 +145,13 @@ def inject_fncall(
145
145
  if not tools:
146
146
  if protocol.id == "entml":
147
147
  sections: List[str] = []
148
- thinking_section = build_entml_thinking_section(protocol_options)
149
- if thinking_section:
150
- sections.append(thinking_section)
151
148
  if history_text.strip():
152
149
  sections.append(format_entml_conversation_history(history_text))
153
150
  sections.append(format_entml_current_user_message(current_user_message))
151
+ # thinking 放在最后,超限截断时优先保留在 send_text 尾部
152
+ thinking_section = build_entml_thinking_section(protocol_options)
153
+ if thinking_section:
154
+ sections.append(thinking_section)
154
155
  prompt = "\n\n".join(sections)
155
156
  else:
156
157
  prompt = build_no_tools_prompt(history_text, current_user_message)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from typing import Any, Dict, List, Optional, Set, Tuple
4
4
 
5
5
  from echotools.base.logger.manager import get_logger
6
- from echotools.exec.fncall.protocols.entml_thinking_history import (
6
+ from echotools.exec.fncall.protocols.entml_think.hist import (
7
7
  extract_reasoning_text,
8
8
  format_entml_thinking_history_block,
9
9
  )
@@ -8,6 +8,7 @@
8
8
  from __future__ import annotations
9
9
 
10
10
  import json as _json
11
+ import re
11
12
  from typing import Any, Dict, List, Optional, Tuple
12
13
 
13
14
  from echotools.exec.fncall.prompt.templates import (
@@ -18,8 +19,7 @@ from echotools.exec.fncall.protocols.entml_invoke import (
18
19
  parse_entml_tool_calls,
19
20
  )
20
21
  from echotools.exec.fncall.protocols.entml_patterns import BLOCK_RE
21
- from echotools.exec.fncall.protocols.entml_sanitize import strip_entml_from_content
22
- from echotools.exec.fncall.protocols.entml_thinking import build_entml_thinking_section
22
+ from echotools.exec.fncall.protocols.entml_think.core import build_entml_thinking_section
23
23
  from echotools.exec.fncall.protocols.entml_tools import format_entml_tool_descs
24
24
  from echotools.exec.fncall.shared.coercion import _build_param_schema_index
25
25
  from echotools.exec.fncall.shared.normalization import (
@@ -28,6 +28,27 @@ from echotools.exec.fncall.shared.normalization import (
28
28
  )
29
29
  from echotools.exec.protocol.base import ToolProtocol
30
30
 
31
+ # 仅按 entml: 前缀剥离标签,不区分具体标签名。
32
+ _ENTML_PAIR_RE = re.compile(
33
+ r"<entml:[a-zA-Z_][\w]*\b[^>]*>.*?</entml:[a-zA-Z_][\w]*>",
34
+ re.DOTALL,
35
+ )
36
+ _ENTML_SELF_CLOSING_RE = re.compile(r"<entml:[a-zA-Z_][\w]*\b[^>]*/>", re.DOTALL)
37
+ _ENTML_ORPHAN_CLOSE_RE = re.compile(r"</entml:[a-zA-Z_][\w]*>", re.DOTALL)
38
+ _ENTML_ORPHAN_OPEN_RE = re.compile(r"<entml:[a-zA-Z_][\w]*\b[^>]*>", re.DOTALL)
39
+
40
+
41
+ def strip_entml_from_content(content: str) -> str:
42
+ """从 user 消息正文剥离所有 entml:* 标签及残留开闭标签。"""
43
+ if not content:
44
+ return content
45
+ cleaned = content
46
+ cleaned = _ENTML_PAIR_RE.sub("", cleaned)
47
+ cleaned = _ENTML_SELF_CLOSING_RE.sub("", cleaned)
48
+ cleaned = _ENTML_ORPHAN_CLOSE_RE.sub("", cleaned)
49
+ cleaned = _ENTML_ORPHAN_OPEN_RE.sub("", cleaned)
50
+ return cleaned.strip()
51
+
31
52
 
32
53
  def _parse_tool_call_args(tc: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
33
54
  fn = tc.get("function") or {}
@@ -141,10 +162,6 @@ class EntmlProtocol(ToolProtocol):
141
162
  else:
142
163
  sections = [_ENTML_INSTRUCTION.rstrip()]
143
164
 
144
- thinking_section = build_entml_thinking_section(protocol_options)
145
- if thinking_section:
146
- sections.append(thinking_section)
147
-
148
165
  if user_system_prompt and user_system_prompt.strip():
149
166
  sections.append(
150
167
  f"<user_system_prompt>\n{user_system_prompt.strip()}\n</user_system_prompt>"
@@ -161,6 +178,12 @@ class EntmlProtocol(ToolProtocol):
161
178
  sections.append(f"<loop_warning>\n{loop_warning}\n</loop_warning>")
162
179
 
163
180
  sections.append(format_entml_current_user_message(current_user_message))
181
+
182
+ # thinking 放在最后,超限截断时优先保留在 send_text 尾部
183
+ thinking_section = build_entml_thinking_section(protocol_options)
184
+ if thinking_section:
185
+ sections.append(thinking_section)
186
+
164
187
  return "\n\n".join(sections)
165
188
 
166
189
  def detect_start(self, buffer: str) -> Tuple[bool, int]:
@@ -0,0 +1,31 @@
1
+ """Entml thinking: prompt section, history blocks, stream parsing."""
2
+
3
+ from echotools.exec.fncall.protocols.entml_think.core import (
4
+ build_entml_thinking_section,
5
+ parse_max_thinking_length,
6
+ )
7
+ from echotools.exec.fncall.protocols.entml_think.hist import (
8
+ apply_thinking_history_policy,
9
+ extract_reasoning_text,
10
+ format_entml_thinking_history_block,
11
+ parse_include_thinking_in_history,
12
+ parse_interleaved_history,
13
+ )
14
+ from echotools.exec.fncall.protocols.entml_think.parse import (
15
+ EntmlThinkingStreamFilter,
16
+ has_unclosed_entml_thinking,
17
+ split_entml_thinking,
18
+ )
19
+
20
+ __all__ = [
21
+ "EntmlThinkingStreamFilter",
22
+ "apply_thinking_history_policy",
23
+ "build_entml_thinking_section",
24
+ "extract_reasoning_text",
25
+ "format_entml_thinking_history_block",
26
+ "has_unclosed_entml_thinking",
27
+ "parse_include_thinking_in_history",
28
+ "parse_interleaved_history",
29
+ "parse_max_thinking_length",
30
+ "split_entml_thinking",
31
+ ]
@@ -12,6 +12,31 @@ _THINKING_OPEN_PREFIX = "<entml:thinking"
12
12
  _THINKING_CLOSE = "</entml:thinking>"
13
13
 
14
14
 
15
+ def has_unclosed_entml_thinking(text: str) -> bool:
16
+ """buffer 中是否存在尚未闭合的 <entml:thinking> 块(含开标签未收齐)。"""
17
+ if not text:
18
+ return False
19
+ i = 0
20
+ while i < len(text):
21
+ open_at = text.find(_THINKING_OPEN_PREFIX, i)
22
+ if open_at < 0:
23
+ break
24
+ gt = text.find(">", open_at)
25
+ if gt < 0:
26
+ return True
27
+ close_at = text.find(_THINKING_CLOSE, gt + 1)
28
+ if close_at < 0:
29
+ return True
30
+ i = close_at + len(_THINKING_CLOSE)
31
+ max_keep = len(_THINKING_OPEN_PREFIX) - 1
32
+ check_len = min(len(text), max_keep)
33
+ for length in range(check_len, 0, -1):
34
+ suffix = text[-length:]
35
+ if _THINKING_OPEN_PREFIX.startswith(suffix) and suffix != _THINKING_OPEN_PREFIX:
36
+ return True
37
+ return False
38
+
39
+
15
40
  def split_entml_thinking(text: str) -> Tuple[str, str]:
16
41
  """从文本中剥离 <entml:thinking> 块,返回 (正文, 思考链拼接)。"""
17
42
  if not text:
@@ -53,6 +78,63 @@ class EntmlThinkingStreamFilter:
53
78
  # 当前 thinking 块是否已输出过任何内容(用于首片去前导空白)
54
79
  self._thinking_started = False
55
80
 
81
+ def in_open_thinking(self) -> bool:
82
+ """思考块已开始且尚未收到 </entml:thinking>(含开/闭标签分片 hold)。"""
83
+ if self._in_block:
84
+ return True
85
+ open_at = self._pending.find(_THINKING_OPEN_PREFIX)
86
+ if open_at >= 0:
87
+ gt = self._pending.find(">", open_at)
88
+ if gt < 0:
89
+ return True
90
+ _, hold = _hold_prefix(self._pending, _THINKING_OPEN_PREFIX)
91
+ if hold:
92
+ return True
93
+ return False
94
+
95
+ def _feed_in_block(self, out: List[Tuple[str, str]]) -> bool:
96
+ """处理块内 pending;返回 False 表示应退出 feed 主循环。"""
97
+ close_at = self._pending.find(_THINKING_CLOSE)
98
+ if close_at >= 0:
99
+ piece = self._pending[:close_at]
100
+ emitted = self._emit_thinking_piece(piece)
101
+ if emitted:
102
+ out.append(("thinking", emitted))
103
+ self._in_block = False
104
+ self._thinking_started = False
105
+ self._pending = self._pending[close_at + len(_THINKING_CLOSE) :]
106
+ return True
107
+
108
+ safe, hold = _hold_prefix(self._pending, _THINKING_CLOSE)
109
+ if safe:
110
+ emitted = self._emit_thinking_piece(safe)
111
+ if emitted:
112
+ out.append(("thinking", emitted))
113
+ self._pending = hold
114
+ return False
115
+
116
+ def _feed_before_block(self, out: List[Tuple[str, str]]) -> bool:
117
+ """开标签之前或尚未进入块;返回 False 表示应退出 feed 主循环。"""
118
+ open_at = self._pending.find(_THINKING_OPEN_PREFIX)
119
+ if open_at < 0:
120
+ safe, self._pending = _hold_prefix(self._pending, _THINKING_OPEN_PREFIX)
121
+ if safe:
122
+ out.append(("content", safe))
123
+ return False
124
+
125
+ if open_at > 0:
126
+ out.append(("content", self._pending[:open_at]))
127
+ self._pending = self._pending[open_at:]
128
+
129
+ gt = self._pending.find(">")
130
+ if gt < 0:
131
+ return False
132
+
133
+ self._in_block = True
134
+ self._thinking_started = False
135
+ self._pending = self._pending[gt + 1 :]
136
+ return True
137
+
56
138
  def feed(self, chunk: str) -> List[Tuple[str, str]]:
57
139
  """返回 [(kind, text), ...],kind 为 content 或 thinking。"""
58
140
  if not chunk:
@@ -63,46 +145,12 @@ class EntmlThinkingStreamFilter:
63
145
 
64
146
  while self._pending:
65
147
  if self._in_block:
66
- close_at = self._pending.find(_THINKING_CLOSE)
67
- if close_at >= 0:
68
- piece = self._pending[:close_at]
69
- emitted = self._emit_thinking_piece(piece)
70
- if emitted:
71
- out.append(("thinking", emitted))
72
- self._in_block = False
73
- self._thinking_started = False
74
- self._pending = self._pending[close_at + len(_THINKING_CLOSE) :]
75
- continue
76
-
77
- # 关闭标签可能被分片:hold 真前缀,其余立即作为 thinking 输出
78
- safe, hold = _hold_prefix(self._pending, _THINKING_CLOSE)
79
- if safe:
80
- emitted = self._emit_thinking_piece(safe)
81
- if emitted:
82
- out.append(("thinking", emitted))
83
- self._pending = hold
148
+ if not self._feed_in_block(out):
149
+ break
150
+ continue
151
+ if not self._feed_before_block(out):
84
152
  break
85
153
 
86
- open_at = self._pending.find(_THINKING_OPEN_PREFIX)
87
- if open_at < 0:
88
- safe, self._pending = _hold_prefix(self._pending, _THINKING_OPEN_PREFIX)
89
- if safe:
90
- out.append(("content", safe))
91
- break
92
-
93
- if open_at > 0:
94
- out.append(("content", self._pending[:open_at]))
95
- self._pending = self._pending[open_at:]
96
-
97
- gt = self._pending.find(">")
98
- if gt < 0:
99
- # 开标签属性未收齐
100
- break
101
-
102
- self._in_block = True
103
- self._thinking_started = False
104
- self._pending = self._pending[gt + 1 :]
105
-
106
154
  return out
107
155
 
108
156
  def _emit_thinking_piece(self, piece: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echotools
3
- Version: 2.3.39
3
+ Version: 2.3.42
4
4
  Summary: 通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链
5
5
  Author: nichengfuben
6
6
  License: MIT
@@ -57,12 +57,12 @@ src/echotools/exec/fncall/protocols/__init__.py
57
57
  src/echotools/exec/fncall/protocols/entml.py
58
58
  src/echotools/exec/fncall/protocols/entml_invoke.py
59
59
  src/echotools/exec/fncall/protocols/entml_patterns.py
60
- src/echotools/exec/fncall/protocols/entml_sanitize.py
61
- src/echotools/exec/fncall/protocols/entml_thinking.py
62
- src/echotools/exec/fncall/protocols/entml_thinking_history.py
63
- src/echotools/exec/fncall/protocols/entml_thinking_parse.py
64
60
  src/echotools/exec/fncall/protocols/entml_tools.py
65
61
  src/echotools/exec/fncall/protocols/entml_values.py
62
+ src/echotools/exec/fncall/protocols/entml_think/__init__.py
63
+ src/echotools/exec/fncall/protocols/entml_think/core.py
64
+ src/echotools/exec/fncall/protocols/entml_think/hist.py
65
+ src/echotools/exec/fncall/protocols/entml_think/parse.py
66
66
  src/echotools/exec/fncall/shared/__init__.py
67
67
  src/echotools/exec/fncall/shared/coercion.py
68
68
  src/echotools/exec/fncall/shared/loop_detect.py
@@ -167,6 +167,7 @@ src/tests/test_events.py
167
167
  src/tests/test_events_event.py
168
168
  src/tests/test_facade.py
169
169
  src/tests/test_fncall.py
170
+ src/tests/test_fncall_stream_thinking.py
170
171
  src/tests/test_ids.py
171
172
  src/tests/test_imports.py
172
173
  src/tests/test_io.py
@@ -6,7 +6,7 @@ import pytest
6
6
 
7
7
  from echotools.exec.fncall import get_protocol, inject_fncall
8
8
  from echotools.exec.fncall.protocols.entml_invoke import parse_entml_tool_calls
9
- from echotools.exec.fncall.protocols.entml_thinking import (
9
+ from echotools.exec.fncall.protocols.entml_think.core import (
10
10
  build_entml_thinking_section,
11
11
  default_max_thinking_length_for_level,
12
12
  normalize_thinking_level,
@@ -77,7 +77,7 @@ def test_build_entml_thinking_section_empty_without_options() -> None:
77
77
 
78
78
 
79
79
  def test_parse_max_thinking_length() -> None:
80
- from echotools.exec.fncall.protocols.entml_thinking import parse_max_thinking_length
80
+ from echotools.exec.fncall.protocols.entml_think.core import parse_max_thinking_length
81
81
 
82
82
  assert parse_max_thinking_length(None) is None
83
83
  assert parse_max_thinking_length("") is None
@@ -223,6 +223,33 @@ def test_inject_no_tools_with_thinking_on() -> None:
223
223
  assert "<entml:thinking_mode>on</entml:thinking_mode>" in result
224
224
  assert "<thinking_behavior>" in result
225
225
  assert "Never skip the thinking block" in result
226
+ # thinking 块必须在 current_user_message 之后
227
+ assert result.index("</current_user_message>") < result.index("<entml:thinking_mode>")
228
+
229
+
230
+ def test_render_prompt_thinking_after_current_user() -> None:
231
+ proto = get_protocol("entml")
232
+ prompt = proto.render_prompt(
233
+ tool_descs=proto.format_tool_descs([
234
+ {
235
+ "type": "function",
236
+ "function": {
237
+ "name": "search",
238
+ "description": "Search",
239
+ "parameters": {"type": "object", "properties": {}},
240
+ },
241
+ }
242
+ ]),
243
+ lang="en",
244
+ history_text="<user>\nold\n</user>",
245
+ current_user_message="new",
246
+ protocol_options={"thinking_mode": "on", "max_thinking_length": 1000},
247
+ )
248
+ assert prompt.index("In this environment") < prompt.index("<entml:conversation_history>")
249
+ assert prompt.index("<entml:conversation_history>") < prompt.index("<current_user_message>")
250
+ assert prompt.index("</current_user_message>") < prompt.index("<entml:thinking_mode>")
251
+ assert prompt.index("<entml:thinking_mode>") < prompt.index("<thinking_behavior>")
252
+ assert prompt.rstrip().endswith("</thinking_behavior>")
226
253
 
227
254
 
228
255
  def test_inject_with_history_entml_tags() -> None:
@@ -316,7 +343,7 @@ def test_entml_history_tool_invoke_reminder_when_tools_in_history() -> None:
316
343
 
317
344
 
318
345
  def test_strip_entml_from_user_content() -> None:
319
- from echotools.exec.fncall.protocols.entml_sanitize import strip_entml_from_content
346
+ from echotools.exec.fncall.protocols.entml import strip_entml_from_content
320
347
 
321
348
  raw = (
322
349
  "请查天气\n"
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from echotools.exec.fncall.protocols.entml_thinking_history import (
3
+ from echotools.exec.fncall.protocols.entml_think.hist import (
4
4
  apply_thinking_history_policy,
5
5
  extract_reasoning_text,
6
6
  parse_interleaved_history,
@@ -1,5 +1,6 @@
1
- from echotools.exec.fncall.protocols.entml_thinking_parse import (
1
+ from echotools.exec.fncall.protocols.entml_think.parse import (
2
2
  EntmlThinkingStreamFilter,
3
+ has_unclosed_entml_thinking,
3
4
  split_entml_thinking,
4
5
  )
5
6
 
@@ -16,6 +17,11 @@ def test_split_entml_thinking() -> None:
16
17
  assert "<entml:thinking>" not in content
17
18
 
18
19
 
20
+ def test_has_unclosed_entml_thinking_streaming_prefix() -> None:
21
+ assert has_unclosed_entml_thinking("<entml:think")
22
+ assert not has_unclosed_entml_thinking("<entml:thinking>done</entml:thinking>")
23
+
24
+
19
25
  def test_entml_thinking_stream_filter() -> None:
20
26
  filt = EntmlThinkingStreamFilter()
21
27
  parts = []