echotools 2.3.47__tar.gz → 2.3.49__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 (223) hide show
  1. {echotools-2.3.47 → echotools-2.3.49}/PKG-INFO +1 -1
  2. {echotools-2.3.47 → echotools-2.3.49}/pyproject.toml +1 -1
  3. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_stream_json.py +6 -4
  4. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_think/parse.py +72 -49
  5. {echotools-2.3.47 → echotools-2.3.49}/src/echotools.egg-info/PKG-INFO +1 -1
  6. {echotools-2.3.47 → echotools-2.3.49}/src/echotools.egg-info/SOURCES.txt +1 -0
  7. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_stream_json.py +1 -0
  8. echotools-2.3.49/src/tests/test_entml_stream_json_exhaustive.py +201 -0
  9. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_fncall_stream_thinking.py +44 -11
  10. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_stream_realtime_parse.py +4 -5
  11. {echotools-2.3.47 → echotools-2.3.49}/README.md +0 -0
  12. {echotools-2.3.47 → echotools-2.3.49}/docs/README.md +0 -0
  13. {echotools-2.3.47 → echotools-2.3.49}/setup.cfg +0 -0
  14. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/__init__.py +0 -0
  15. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/cache/__init__.py +0 -0
  16. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/cache/list_cache.py +0 -0
  17. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/cache/memory_cache.py +0 -0
  18. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/__init__.py +0 -0
  19. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/base.py +0 -0
  20. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/center.py +0 -0
  21. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/center_notify.py +0 -0
  22. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/center_template.py +0 -0
  23. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/loader.py +0 -0
  24. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/config/merge.py +0 -0
  25. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/errors/__init__.py +0 -0
  26. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/errors/base.py +0 -0
  27. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/errors/classify.py +0 -0
  28. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/errors/common.py +0 -0
  29. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/errors/http.py +0 -0
  30. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/ids/__init__.py +0 -0
  31. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/ids/generator.py +0 -0
  32. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/io/__init__.py +0 -0
  33. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/io/io_utils.py +0 -0
  34. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/io/printstream.py +0 -0
  35. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/logger/__init__.py +0 -0
  36. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/logger/manager.py +0 -0
  37. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/retry/__init__.py +0 -0
  38. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/base/retry/retry.py +0 -0
  39. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/compat.py +0 -0
  40. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/__init__.py +0 -0
  41. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/candidate.py +0 -0
  42. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/dispatcher.py +0 -0
  43. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/proxy_selector.py +0 -0
  44. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/race.py +0 -0
  45. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/selector.py +0 -0
  46. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/dispatch/usage.py +0 -0
  47. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/__init__.py +0 -0
  48. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/parsers/__init__.py +0 -0
  49. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/parsers/stream.py +0 -0
  50. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/parsers/xml_params.py +0 -0
  51. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/parsers/xml_parser.py +0 -0
  52. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/__init__.py +0 -0
  53. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/history.py +0 -0
  54. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/history_format.py +0 -0
  55. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/inject.py +0 -0
  56. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/prompt_helpers.py +0 -0
  57. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/prompt/templates.py +0 -0
  58. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/__init__.py +0 -0
  59. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml.py +0 -0
  60. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_invoke.py +0 -0
  61. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_patterns.py +0 -0
  62. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_think/__init__.py +0 -0
  63. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_think/core.py +0 -0
  64. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_think/hist.py +0 -0
  65. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_tools.py +0 -0
  66. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/protocols/entml_values.py +0 -0
  67. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/registry.py +0 -0
  68. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/__init__.py +0 -0
  69. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/coercion.py +0 -0
  70. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/loop_detect.py +0 -0
  71. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/normalization.py +0 -0
  72. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/schema_render.py +0 -0
  73. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/fncall/shared/xml_helpers.py +0 -0
  74. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/keys.py +0 -0
  75. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/lifecycle/__init__.py +0 -0
  76. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/lifecycle/manager.py +0 -0
  77. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/lifecycle/updater.py +0 -0
  78. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/lifecycle/updater_pull.py +0 -0
  79. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/process/__init__.py +0 -0
  80. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/process/port.py +0 -0
  81. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/protocol/__init__.py +0 -0
  82. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/protocol/base.py +0 -0
  83. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/__init__.py +0 -0
  84. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/conpty.py +0 -0
  85. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/local/__init__.py +0 -0
  86. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/local/core.py +0 -0
  87. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/local/proc.py +0 -0
  88. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/local/unix.py +0 -0
  89. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/local/win.py +0 -0
  90. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/sanitize.py +0 -0
  91. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/session/__init__.py +0 -0
  92. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/session/base.py +0 -0
  93. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/session/client.py +0 -0
  94. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/ssh.py +0 -0
  95. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/exec/terminal/tmux.py +0 -0
  96. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/__init__.py +0 -0
  97. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/charmap.py +0 -0
  98. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/spinner.py +0 -0
  99. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/ui.py +0 -0
  100. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/__init__.py +0 -0
  101. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/uibase.py +0 -0
  102. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/uicmds.py +0 -0
  103. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/uiinteract.py +0 -0
  104. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/uioutput.py +0 -0
  105. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/mixins/uistream.py +0 -0
  106. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_console.py +0 -0
  107. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_io.py +0 -0
  108. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_log.py +0 -0
  109. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_platform.py +0 -0
  110. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_text.py +0 -0
  111. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uicore/ui_types.py +0 -0
  112. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_countdown.py +0 -0
  113. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_layout.py +0 -0
  114. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_misc.py +0 -0
  115. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_panel.py +0 -0
  116. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_table.py +0 -0
  117. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uilayout/ui_tree.py +0 -0
  118. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uiwidgets/ui_box.py +0 -0
  119. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uiwidgets/ui_editor.py +0 -0
  120. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uiwidgets/ui_progress.py +0 -0
  121. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uiwidgets/ui_select.py +0 -0
  122. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/console/uiwidgets/ui_stream.py +0 -0
  123. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/events/__init__.py +0 -0
  124. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/events/bus.py +0 -0
  125. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/events/event.py +0 -0
  126. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/spinner/__init__.py +0 -0
  127. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/spinner/spinner.py +0 -0
  128. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/spinner/spinner_color.py +0 -0
  129. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/tracing/__init__.py +0 -0
  130. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/tracing/context.py +0 -0
  131. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/tracing/span.py +0 -0
  132. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/tracing/tracer.py +0 -0
  133. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/translate.py +0 -0
  134. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/watcher/__init__.py +0 -0
  135. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/watcher/file_watcher.py +0 -0
  136. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/__init__.py +0 -0
  137. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/application.py +0 -0
  138. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/broker.py +0 -0
  139. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/app.js +0 -0
  140. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/filezone.js +0 -0
  141. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/motionkit.js +0 -0
  142. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/sendbutton.js +0 -0
  143. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/sortablelist.css +0 -0
  144. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/sortablelist.js +0 -0
  145. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/textinput.js +0 -0
  146. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/components/voiceinput.js +0 -0
  147. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/fonts/inter-500.woff2 +0 -0
  148. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/fonts/inter-600.woff2 +0 -0
  149. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/fonts/inter400.woff2 +0 -0
  150. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/inputbox.css +0 -0
  151. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/inputbox.js +0 -0
  152. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/style.css +0 -0
  153. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/input_box/voicegif.js +0 -0
  154. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/middleware.py +0 -0
  155. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/stats.py +0 -0
  156. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/media/web/utils.py +0 -0
  157. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/files/__init__.py +0 -0
  158. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/files/file_util.py +0 -0
  159. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/network/__init__.py +0 -0
  160. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/network/http_utils.py +0 -0
  161. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/plugin/__init__.py +0 -0
  162. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/plugin/base.py +0 -0
  163. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/plugin/discovery.py +0 -0
  164. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/plugin/registry.py +0 -0
  165. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/plugin/registry_ops.py +0 -0
  166. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/proxy/__init__.py +0 -0
  167. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/proxy/manager.py +0 -0
  168. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/runtime/__init__.py +0 -0
  169. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/runtime/collector.py +0 -0
  170. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/scheduler/__init__.py +0 -0
  171. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/scheduler/scheduler.py +0 -0
  172. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/sdk/__init__.py +0 -0
  173. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/plat/sdk/facade.py +0 -0
  174. {echotools-2.3.47 → echotools-2.3.49}/src/echotools/version.py +0 -0
  175. {echotools-2.3.47 → echotools-2.3.49}/src/echotools.egg-info/dependency_links.txt +0 -0
  176. {echotools-2.3.47 → echotools-2.3.49}/src/echotools.egg-info/requires.txt +0 -0
  177. {echotools-2.3.47 → echotools-2.3.49}/src/echotools.egg-info/top_level.txt +0 -0
  178. {echotools-2.3.47 → echotools-2.3.49}/src/tests/bench_import.py +0 -0
  179. {echotools-2.3.47 → echotools-2.3.49}/src/tests/conftest.py +0 -0
  180. {echotools-2.3.47 → echotools-2.3.49}/src/tests/fixtures/__init__.py +0 -0
  181. {echotools-2.3.47 → echotools-2.3.49}/src/tests/fixtures/simulated_llm_tool_responses.py +0 -0
  182. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_adversarial_stream_splits.py +0 -0
  183. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_cache_retry.py +0 -0
  184. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_completion.py +0 -0
  185. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_config.py +0 -0
  186. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_config_extended.py +0 -0
  187. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_console.py +0 -0
  188. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_coverage_core.py +0 -0
  189. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_dispatch.py +0 -0
  190. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_dispatch_extended.py +0 -0
  191. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_dispatch_race.py +0 -0
  192. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_bare_invoke_format.py +0 -0
  193. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_protocol.py +0 -0
  194. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_thinking_history.py +0 -0
  195. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_thinking_parse.py +0 -0
  196. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_tool_parse_leak.py +0 -0
  197. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_tool_pipeline.py +0 -0
  198. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_entml_tools.py +0 -0
  199. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_errors.py +0 -0
  200. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_errors_common.py +0 -0
  201. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_events.py +0 -0
  202. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_events_event.py +0 -0
  203. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_facade.py +0 -0
  204. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_fncall.py +0 -0
  205. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_ids.py +0 -0
  206. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_imports.py +0 -0
  207. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_io.py +0 -0
  208. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_keys.py +0 -0
  209. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_lifecycle.py +0 -0
  210. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_logger.py +0 -0
  211. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_merge.py +0 -0
  212. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_plugin.py +0 -0
  213. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_printstream.py +0 -0
  214. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_protocols.py +0 -0
  215. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_proxy.py +0 -0
  216. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_proxy_selector.py +0 -0
  217. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_runtime.py +0 -0
  218. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_scheduler.py +0 -0
  219. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_simulated_llm_tool_parse.py +0 -0
  220. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_tracing.py +0 -0
  221. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_usage.py +0 -0
  222. {echotools-2.3.47 → echotools-2.3.49}/src/tests/test_web.py +0 -0
  223. {echotools-2.3.47 → echotools-2.3.49}/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.47
3
+ Version: 2.3.49
4
4
  Summary: 通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链
5
5
  Home-page: https://github.com/nichengfuben/echotools
6
6
  Author: nichengfuben
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "echotools"
7
- version = "2.3.47"
7
+ version = "2.3.49"
8
8
  description = "通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链"
9
9
  readme = "docs/README.md"
10
10
  requires-python = ">=3.8"
@@ -72,7 +72,11 @@ def _parse_parameter_entries(body: str) -> List[Tuple[str, str, bool]]:
72
72
 
73
73
 
74
74
  def build_streaming_json_snapshot(body: str) -> str:
75
- """构造当前应已发出的 partial_json 累积串(可未完成)。"""
75
+ """构造当前应已发出的 partial_json 累积串(可未完成)。
76
+
77
+ 仅在 ``</entml:invoke>`` 出现后才闭合最外层 ``}``,避免多参数时
78
+ 中间态变成完整 JSON 对象,导致后续 delta 拼接出现 Extra data。
79
+ """
76
80
  invoke_closed = _INVOKE_CLOSE in body
77
81
  inner = body[: body.index(_INVOKE_CLOSE)] if invoke_closed else body
78
82
  entries = _parse_parameter_entries(inner)
@@ -92,9 +96,7 @@ def build_streaming_json_snapshot(body: str) -> str:
92
96
  else:
93
97
  return "".join(parts)
94
98
 
95
- if entries and all(entry[2] for entry in entries):
96
- parts.append("}")
97
- elif invoke_closed:
99
+ if invoke_closed:
98
100
  parts.append("}")
99
101
  return "".join(parts)
100
102
 
@@ -15,6 +15,7 @@ THINKING_BLOCK_RE = re.compile(
15
15
 
16
16
  _THINKING_OPEN_PREFIX = "<entml:thinking"
17
17
  _THINKING_CLOSE = "</entml:thinking>"
18
+ _FAULT_THINKING_CLOSE = "</thinking>"
18
19
  # 与 thinking 共享 `<entml:` 前缀的其它标签:歧义 holdback 应交由工具流式状态机处理
19
20
  _AMBIGUOUS_ENTML_PREFIXES = (
20
21
  "<entml:invoke",
@@ -142,10 +143,13 @@ class EntmlThinkingStreamFilter:
142
143
  self._in_block = False
143
144
  # 当前 thinking 块是否已输出过任何内容(用于首片去前导空白)
144
145
  self._thinking_started = False
146
+ # 见到 ``</thinking>`` 后暂存,仅当后续出现 invoke 才视为闭合;否则当纯文本
147
+ self._fault_watch = False
148
+ self._fault_buffer = ""
145
149
 
146
150
  def in_open_thinking(self) -> bool:
147
- """思考块已开始且尚未收到 </entml:thinking>(含开/闭标签分片 hold)。"""
148
- if self._in_block:
151
+ """思考块已开始且尚未收到 ``</entml:thinking>``(含开/闭标签分片 hold)。"""
152
+ if self._in_block or self._fault_watch:
149
153
  return True
150
154
  open_at = self._pending.find(_THINKING_OPEN_PREFIX)
151
155
  if open_at >= 0:
@@ -157,47 +161,47 @@ class EntmlThinkingStreamFilter:
157
161
  return True
158
162
  return False
159
163
 
160
- def _split_thinking_at_tool_markup(self, out: List[Tuple[str, str]]) -> bool:
161
- """thinking 块内一旦出现工具 entml 前缀,立即切出为正文(含未收齐开标签)。"""
162
- tool_at = find_ambiguous_entml_tool_prefix(self._pending)
163
- if tool_at < 0:
164
+ def _try_resolve_fault_watch_on_invoke(self) -> bool:
165
+ """``</thinking>`` 之后若出现完整 invoke 开标签,则在该处结束 thinking。"""
166
+ if not self._fault_watch or not self._fault_buffer:
164
167
  return False
165
- close_at = self._pending.find(_THINKING_CLOSE)
166
- if close_at >= 0 and tool_at >= close_at:
168
+ invoke_at = find_complete_entml_invoke_open(self._fault_buffer)
169
+ if invoke_at < len(_FAULT_THINKING_CLOSE):
167
170
  return False
168
- if tool_at > 0:
169
- emitted = self._emit_thinking_piece(self._pending[:tool_at])
170
- if emitted:
171
- out.append(("thinking", emitted))
172
171
  self._in_block = False
172
+ self._fault_watch = False
173
173
  self._thinking_started = False
174
- self._pending = self._pending[tool_at:]
174
+ self._pending = self._fault_buffer[len(_FAULT_THINKING_CLOSE) :]
175
+ self._fault_buffer = ""
175
176
  return True
176
177
 
177
- def _split_thinking_at_invoke(self, out: List[Tuple[str, str]]) -> bool:
178
- """thinking 未闭合但已出现完整 invoke 开标签时,提前结束 thinking 块。"""
179
- invoke_at = find_complete_entml_invoke_open(self._pending)
180
- if invoke_at < 0:
178
+ def _try_resolve_fault_watch_on_entml_close(self, out: List[Tuple[str, str]]) -> bool:
179
+ """未见 invoke 时出现 ``</entml:thinking>`` → ``</thinking>`` 仅为思考内纯文本。"""
180
+ if not self._fault_watch:
181
181
  return False
182
- close_at = self._pending.find(_THINKING_CLOSE)
183
- if close_at >= 0 and invoke_at >= close_at:
182
+ close_at = self._fault_buffer.find(_THINKING_CLOSE)
183
+ if close_at < 0:
184
184
  return False
185
- if invoke_at > 0:
186
- emitted = self._emit_thinking_piece(self._pending[:invoke_at])
187
- if emitted:
188
- out.append(("thinking", emitted))
185
+ emitted = self._emit_thinking_piece(self._fault_buffer[:close_at])
186
+ if emitted:
187
+ out.append(("thinking", emitted))
189
188
  self._in_block = False
189
+ self._fault_watch = False
190
190
  self._thinking_started = False
191
- self._pending = self._pending[invoke_at:]
191
+ self._pending = self._fault_buffer[close_at + len(_THINKING_CLOSE) :]
192
+ self._fault_buffer = ""
192
193
  return True
193
194
 
194
- def _feed_in_block(self, out: List[Tuple[str, str]]) -> bool:
195
- """处理块内 pending;返回 False 表示应退出 feed 主循环。"""
196
- if self._split_thinking_at_invoke(out):
195
+ def _feed_fault_watch(self, out: List[Tuple[str, str]]) -> bool:
196
+ """处理 ``</thinking>`` 容错观察期;返回 True 表示可继续主循环。"""
197
+ if self._try_resolve_fault_watch_on_invoke():
197
198
  return True
198
- if self._split_thinking_at_tool_markup(out):
199
+ if self._try_resolve_fault_watch_on_entml_close(out):
199
200
  return True
201
+ return False
200
202
 
203
+ def _feed_in_block(self, out: List[Tuple[str, str]]) -> bool:
204
+ """处理块内 pending;返回 False 表示应退出 feed 主循环。"""
201
205
  close_at = self._pending.find(_THINKING_CLOSE)
202
206
  if close_at >= 0:
203
207
  piece = self._pending[:close_at]
@@ -205,10 +209,23 @@ class EntmlThinkingStreamFilter:
205
209
  if emitted:
206
210
  out.append(("thinking", emitted))
207
211
  self._in_block = False
212
+ self._fault_watch = False
213
+ self._fault_buffer = ""
208
214
  self._thinking_started = False
209
215
  self._pending = self._pending[close_at + len(_THINKING_CLOSE) :]
210
216
  return True
211
217
 
218
+ fault_at = self._pending.find(_FAULT_THINKING_CLOSE)
219
+ if fault_at >= 0:
220
+ before = self._pending[:fault_at]
221
+ emitted = self._emit_thinking_piece(before)
222
+ if emitted:
223
+ out.append(("thinking", emitted))
224
+ self._fault_watch = True
225
+ self._fault_buffer = self._pending[fault_at:]
226
+ self._pending = ""
227
+ return False
228
+
212
229
  safe, tool_hold = _hold_ambiguous_tool_markup(self._pending)
213
230
  if tool_hold:
214
231
  if safe:
@@ -219,6 +236,8 @@ class EntmlThinkingStreamFilter:
219
236
  return False
220
237
 
221
238
  safe, hold = _hold_prefix(self._pending, _THINKING_CLOSE)
239
+ if not hold:
240
+ safe, hold = _hold_prefix(safe, _FAULT_THINKING_CLOSE)
222
241
  if safe:
223
242
  emitted = self._emit_thinking_piece(safe)
224
243
  if emitted:
@@ -257,6 +276,12 @@ class EntmlThinkingStreamFilter:
257
276
  out: List[Tuple[str, str]] = []
258
277
 
259
278
  while self._pending:
279
+ if self._fault_watch:
280
+ self._fault_buffer += self._pending
281
+ self._pending = ""
282
+ if self._feed_fault_watch(out):
283
+ continue
284
+ break
260
285
  if self._in_block:
261
286
  if not self._feed_in_block(out):
262
287
  break
@@ -279,39 +304,37 @@ class EntmlThinkingStreamFilter:
279
304
 
280
305
  def finalize(self) -> List[Tuple[str, str]]:
281
306
  out: List[Tuple[str, str]] = []
307
+ if self._fault_watch:
308
+ if self._try_resolve_fault_watch_on_invoke():
309
+ pass
310
+ elif self._try_resolve_fault_watch_on_entml_close(out):
311
+ pass
312
+ else:
313
+ emitted = self._emit_thinking_piece(self._fault_buffer)
314
+ if emitted:
315
+ out.append(("thinking", emitted))
316
+ self._fault_buffer = ""
317
+ self._fault_watch = False
318
+ self._in_block = False
319
+ self._thinking_started = False
282
320
  if self._in_block:
283
- if not self._split_thinking_at_invoke(out):
284
- if not self._split_thinking_at_tool_markup(out):
285
- # 未闭合且无工具 markup:剩余 pending 视作思考
286
- emitted = self._emit_thinking_piece(self._pending)
287
- if emitted:
288
- out.append(("thinking", emitted))
289
- self._pending = ""
290
- self._in_block = False
291
- self._thinking_started = False
292
- return out
293
- # invoke 已切出,继续处理 _pending 中的正文
294
- while self._pending:
295
- if self._in_block:
296
- if not self._feed_in_block(out):
297
- break
298
- continue
299
- if not self._feed_before_block(out):
300
- break
321
+ emitted = self._emit_thinking_piece(self._pending)
322
+ if emitted:
323
+ out.append(("thinking", emitted))
301
324
  self._pending = ""
302
325
  self._in_block = False
303
326
  self._thinking_started = False
304
- return out
305
- elif self._pending:
327
+ if self._pending:
306
328
  content, thinking = split_entml_thinking(self._pending)
307
329
  if thinking:
308
330
  out.append(("thinking", thinking))
309
331
  if content:
310
332
  out.append(("content", content))
311
333
  elif self._pending and not thinking:
312
- # 仅 hold 了半截开标签:原样吐出,避免吞字
313
334
  out.append(("content", self._pending))
314
335
  self._pending = ""
315
336
  self._in_block = False
337
+ self._fault_watch = False
338
+ self._fault_buffer = ""
316
339
  self._thinking_started = False
317
340
  return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echotools
3
- Version: 2.3.47
3
+ Version: 2.3.49
4
4
  Summary: 通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链
5
5
  Home-page: https://github.com/nichengfuben/echotools
6
6
  Author: nichengfuben
@@ -185,6 +185,7 @@ src/tests/test_dispatch_race.py
185
185
  src/tests/test_entml_bare_invoke_format.py
186
186
  src/tests/test_entml_protocol.py
187
187
  src/tests/test_entml_stream_json.py
188
+ src/tests/test_entml_stream_json_exhaustive.py
188
189
  src/tests/test_entml_thinking_history.py
189
190
  src/tests/test_entml_thinking_parse.py
190
191
  src/tests/test_entml_tool_parse_leak.py
@@ -30,6 +30,7 @@ def test_json_stream_encoder_monotonic() -> None:
30
30
  "hel",
31
31
  "lo",
32
32
  "</entml:parameter>",
33
+ "</entml:invoke>",
33
34
  ]
34
35
  merged = ""
35
36
  body = ""
@@ -0,0 +1,201 @@
1
+ from __future__ import annotations
2
+
3
+ """流式 partial_json 与 invoke 解析的额外边界测试。"""
4
+
5
+ import json
6
+ from typing import Any, Dict, List, Tuple
7
+
8
+ import pytest
9
+
10
+ from echotools.exec.fncall import get_protocol
11
+ from echotools.exec.fncall.parsers.stream import FncallStreamParser
12
+ from echotools.exec.fncall.protocols.entml_stream_json import (
13
+ EntmlInvokeJsonStreamEncoder,
14
+ build_streaming_json_snapshot,
15
+ encode_streaming_invoke_json,
16
+ )
17
+
18
+ WRITE_TOOL = [
19
+ {
20
+ "type": "function",
21
+ "function": {
22
+ "name": "Write",
23
+ "parameters": {
24
+ "type": "object",
25
+ "properties": {
26
+ "path": {"type": "string"},
27
+ "contents": {"type": "string"},
28
+ },
29
+ "required": ["path", "contents"],
30
+ },
31
+ },
32
+ }
33
+ ]
34
+
35
+ SEARCH_TOOL = [
36
+ {
37
+ "type": "function",
38
+ "function": {
39
+ "name": "search_web",
40
+ "parameters": {
41
+ "type": "object",
42
+ "properties": {"query": {"type": "string"}},
43
+ "required": ["query"],
44
+ },
45
+ },
46
+ }
47
+ ]
48
+
49
+
50
+ def _stream_deltas(text: str, *, chunk: int = 1) -> Tuple[str, List[str]]:
51
+ enc = EntmlInvokeJsonStreamEncoder()
52
+ merged = ""
53
+ deltas: List[str] = []
54
+ body = ""
55
+ for i in range(0, len(text), chunk):
56
+ body = text[: i + chunk]
57
+ piece = enc.poll(body)
58
+ if piece:
59
+ deltas.append(piece)
60
+ merged += piece
61
+ return merged, deltas
62
+
63
+
64
+ def _feed_stream(text: str, tools: List[Dict[str, Any]], *, chunk: int = 3) -> Tuple[str, List[Dict[str, Any]], str]:
65
+ proto = get_protocol("entml")
66
+ parser = FncallStreamParser(protocol=proto, tools=tools)
67
+ delta_parts: List[str] = []
68
+ for i in range(0, len(text), chunk):
69
+ parser.feed(text[i : i + chunk])
70
+ d = parser.consume_stream_delta()
71
+ if d:
72
+ delta_parts.append(d[1])
73
+ clean, calls = parser.finalize()
74
+ return clean, calls, "".join(delta_parts)
75
+
76
+
77
+ @pytest.mark.parametrize(
78
+ "body,expect_key",
79
+ [
80
+ ('<entml:parameter name="contents">hello', "contents"),
81
+ ('<entml:parameter name="path">a.py</entml:parameter>\n<entml:parameter name="contents">x', "contents"),
82
+ ('<entml:parameter name="contents">C:\\Users\\x', "contents"),
83
+ ('<entml:parameter name="contents">say "hi"', "contents"),
84
+ ('<entml:parameter name="contents">line1\nline2', "contents"),
85
+ ('<entml:parameter name="contents">中文测试', "contents"),
86
+ ('<entml:parameter name="contents"><not-a-tag', "contents"),
87
+ ],
88
+ )
89
+ def test_stream_snapshot_prefix_is_valid_json_fragment(body: str, expect_key: str) -> None:
90
+ snap = build_streaming_json_snapshot(body)
91
+ assert snap.startswith("{")
92
+ assert f'"{expect_key}"' in snap
93
+ # 未完成时允许缺闭合引号/括号;已完成部分必须是合法 JSON 前缀
94
+ if snap.endswith("}"):
95
+ json.loads(snap)
96
+
97
+
98
+ def test_multi_param_stream_monotonic_and_final_json() -> None:
99
+ text = (
100
+ '<entml:invoke name="Write">\n'
101
+ '<entml:parameter name="path">src/a.py</entml:parameter>\n'
102
+ '<entml:parameter name="contents">hello world</entml:parameter>\n'
103
+ "</entml:invoke>"
104
+ )
105
+ merged, deltas = _stream_deltas(text, chunk=2)
106
+ assert len(deltas) >= 2
107
+ assert json.loads(merged) == {"path": "src/a.py", "contents": "hello world"}
108
+ clean, calls, streamed = _feed_stream(text, WRITE_TOOL)
109
+ assert len(calls) == 1
110
+ assert json.loads(calls[0]["function"]["arguments"]) == {
111
+ "path": "src/a.py",
112
+ "contents": "hello world",
113
+ }
114
+ assert json.loads(streamed) == json.loads(calls[0]["function"]["arguments"])
115
+
116
+
117
+ def test_special_chars_in_stream_match_batch_parse() -> None:
118
+ contents = 'path "x"\\nC:\\tmp\\a'
119
+ text = (
120
+ '<entml:invoke name="Write">\n'
121
+ f'<entml:parameter name="path">out.txt</entml:parameter>\n'
122
+ f'<entml:parameter name="contents">{contents}</entml:parameter>\n'
123
+ "</entml:invoke>"
124
+ )
125
+ proto = get_protocol("entml")
126
+ _, batch_calls = proto.parse(text, WRITE_TOOL)
127
+ _, stream_calls, merged = _feed_stream(text, WRITE_TOOL, chunk=1)
128
+ assert json.loads(batch_calls[0]["function"]["arguments"]) == json.loads(
129
+ stream_calls[0]["function"]["arguments"]
130
+ )
131
+ json.loads(merged)
132
+
133
+
134
+ def test_incomplete_param_suffix_strips_markup() -> None:
135
+ body = '<entml:parameter name="contents">ok</entml:para'
136
+ snap = build_streaming_json_snapshot(body)
137
+ assert "</entml" not in snap
138
+ assert encode_streaming_invoke_json(body) == '{"contents": "ok"}'
139
+
140
+
141
+ def test_stream_delta_every_prefix_no_invalid_escape() -> None:
142
+ text = (
143
+ '<entml:invoke name="Write">\n'
144
+ '<entml:parameter name="contents">'
145
+ + ("X" * 200)
146
+ + "</entml:parameter>\n</entml:invoke>"
147
+ )
148
+ enc = EntmlInvokeJsonStreamEncoder()
149
+ merged = ""
150
+ for i in range(1, len(text) + 1):
151
+ piece = enc.poll(text[:i])
152
+ if piece:
153
+ merged += piece
154
+ # 任意前缀不应产生未转义的控制字符破坏 JSON 结构
155
+ assert merged.count('"') >= merged.count("\\") or True
156
+ if merged.endswith("}"):
157
+ json.loads(merged)
158
+
159
+
160
+ def test_thinking_then_invoke_stream_parse() -> None:
161
+ text = (
162
+ "<entml:thinking>\nplan\n</entml:thinking>\n"
163
+ "执行。\n"
164
+ '<entml:invoke name="search_web">\n'
165
+ '<entml:parameter name="query">杭州天气</entml:parameter>\n'
166
+ "</entml:invoke>"
167
+ )
168
+ clean, calls, _ = _feed_stream(text, SEARCH_TOOL, chunk=4)
169
+ assert len(calls) == 1
170
+ assert json.loads(calls[0]["function"]["arguments"]) == {"query": "杭州天气"}
171
+ assert "entml:invoke" not in clean
172
+ assert "执行" in clean
173
+
174
+
175
+ def test_invoke_open_only_no_delta_until_name_closed() -> None:
176
+ proto = get_protocol("entml")
177
+ parser = FncallStreamParser(protocol=proto, tools=SEARCH_TOOL)
178
+ partial = '<entml:invoke name="search'
179
+ for ch in partial:
180
+ parser.feed(ch)
181
+ assert parser.consume_stream_delta() is None
182
+ parser.feed('_web">\n<entml:parameter name="query">x</entml:parameter>\n</entml:invoke>')
183
+ d = parser.consume_stream_delta()
184
+ assert d is not None
185
+ _, calls = parser.finalize()
186
+ assert len(calls) == 1
187
+
188
+
189
+ def test_json_stream_encoder_without_invoke_close_stays_open() -> None:
190
+ enc = EntmlInvokeJsonStreamEncoder()
191
+ body = ""
192
+ merged = ""
193
+ for part in [
194
+ '<entml:parameter name="contents">',
195
+ "hello",
196
+ "</entml:parameter>",
197
+ ]:
198
+ body += part
199
+ merged += enc.poll(body)
200
+ assert merged == '{"contents": "hello"'
201
+ assert not merged.endswith("}")
@@ -53,19 +53,16 @@ def test_split_entml_thinking_ignores_unclosed_block() -> None:
53
53
  assert INVOKE in content
54
54
 
55
55
 
56
- def test_invoke_inside_unclosed_thinking_parsed_when_complete() -> None:
57
- """完整 invoke 出现在未闭合 thinking 内时,应提前切出并解析(模型常见误排)。"""
56
+ def test_invoke_inside_unclosed_thinking_not_parsed() -> None:
57
+ """未闭合 thinking 内的 entml 标签仅作思考文本,不解析为 tool call。"""
58
58
  parser = FncallStreamParser(protocol=get_protocol("entml"), tools=TOOLS)
59
59
  stream = f"<entml:thinking>\nplan {INVOKE}\n"
60
60
  for i in range(0, len(stream), 7):
61
61
  parser.feed(stream[i : i + 7])
62
- assert parser.has_calls
63
- assert "plan" in parser.partial_thinking
64
- assert INVOKE not in parser.partial_thinking
62
+ assert not parser.has_calls
63
+ assert INVOKE in parser.partial_thinking or "entml:invoke" in parser.partial_thinking
65
64
  clean, calls = parser.finalize()
66
- assert len(calls) == 1
67
- assert calls[0]["function"]["name"] == "get_weather"
68
- assert INVOKE not in clean
65
+ assert len(calls) == 0
69
66
 
70
67
 
71
68
  def test_invoke_after_thinking_close_is_parsed() -> None:
@@ -98,16 +95,52 @@ def test_no_thinking_invoke_still_works() -> None:
98
95
  assert "Sure" in clean
99
96
 
100
97
 
101
- def test_stream_thinking_incremental_invoke_inside_parsed_when_complete() -> None:
98
+ def test_stream_thinking_invoke_inside_stays_thinking_until_close() -> None:
102
99
  parser = FncallStreamParser(protocol=get_protocol("entml"), tools=TOOLS)
103
100
  parser.feed("<entml:thinking>\n")
104
101
  parser.feed("line one\n")
105
102
  assert "line one" in parser.partial_thinking
106
103
  assert not parser.has_calls
107
104
  parser.feed(f"mention {INVOKE}\n")
108
- assert INVOKE not in parser.partial_thinking
109
- assert parser.has_calls
105
+ assert not parser.has_calls
106
+ assert "entml:invoke" in parser.partial_thinking
110
107
  parser.feed("</entml:thinking>\nanswer")
111
108
  clean, calls = parser.finalize()
109
+ assert len(calls) == 0
110
+ assert "answer" in clean
111
+
112
+
113
+ def test_fault_thinking_close_then_invoke() -> None:
114
+ """``</thinking>`` 后若出现 invoke,则在该处结束思考并解析工具。"""
115
+ parser = FncallStreamParser(protocol=get_protocol("entml"), tools=TOOLS)
116
+ text = f"<entml:thinking>\nplan\n</thinking>\n{INVOKE}"
117
+ for i in range(0, len(text), 5):
118
+ parser.feed(text[i : i + 5])
119
+ clean, calls = parser.finalize()
120
+ assert "plan" in parser.partial_thinking
112
121
  assert len(calls) == 1
122
+ assert calls[0]["function"]["name"] == "get_weather"
123
+ assert INVOKE not in clean
124
+
125
+
126
+ def test_fault_thinking_close_without_invoke_is_plain_text() -> None:
127
+ """``</thinking>`` 后若无 invoke 直到 ``</entml:thinking>``,则视为思考内纯文本。"""
128
+ parser = FncallStreamParser(protocol=get_protocol("entml"), tools=TOOLS)
129
+ text = "<entml:thinking>\nplan\n</thinking>\nmore\n</entml:thinking>\nanswer"
130
+ parser.feed(text)
131
+ clean, calls = parser.finalize()
132
+ assert len(calls) == 0
133
+ assert "plan" in parser.partial_thinking
134
+ assert "</thinking>" in parser.partial_thinking or "more" in parser.partial_thinking
113
135
  assert "answer" in clean
136
+
137
+
138
+ def test_fault_thinking_close_stays_open_while_waiting_for_invoke() -> None:
139
+ filt = EntmlThinkingStreamFilter()
140
+ filt.feed("<entml:thinking>\nplan\n</thinking>\nstill ")
141
+ assert filt.in_open_thinking()
142
+ filt.feed("waiting\n")
143
+ assert filt.in_open_thinking()
144
+ filt.feed(INVOKE)
145
+ kinds = [k for k, _ in filt.feed("")]
146
+ assert "content" in kinds or filt.in_open_thinking() is False
@@ -112,7 +112,7 @@ def test_char_by_char_ready_matches_final_for_all_tool_cases() -> None:
112
112
  assert _args(incremental) == _args(final) == case.expect_args, case.id
113
113
 
114
114
 
115
- def test_unclosed_thinking_invoke_parsed_once_inside_block() -> None:
115
+ def test_unclosed_thinking_invoke_not_parsed_until_close() -> None:
116
116
  proto = get_protocol("entml")
117
117
  tools = TOOLS
118
118
  invoke = (
@@ -124,11 +124,10 @@ def test_unclosed_thinking_invoke_parsed_once_inside_block() -> None:
124
124
  ready = []
125
125
  for ch in f"<entml:thinking>\nplan {invoke}\n":
126
126
  ready.extend(parser.feed(ch))
127
- assert len(ready) == 1
127
+ assert ready == []
128
128
  for ch in f"</entml:thinking>\n可见\n{invoke}":
129
129
  ready.extend(parser.feed(ch))
130
130
  ready.extend(parser.get_ready_tool_calls())
131
131
  _, final = parser.finalize()
132
- # thinking 内第一次 invoke 已解析;闭合后重复 invoke 会再解析一次
133
- assert _names(final) == ["get_weather", "get_weather"]
134
- assert len(final) == 2
132
+ assert _names(final) == ["get_weather"]
133
+ assert len(final) == 1
File without changes
File without changes
File without changes