echotools 2.3.48__tar.gz → 2.3.50__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 (225) hide show
  1. {echotools-2.3.48 → echotools-2.3.50}/PKG-INFO +1 -1
  2. {echotools-2.3.48 → echotools-2.3.50}/pyproject.toml +1 -1
  3. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/parsers/stream.py +32 -10
  4. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/history.py +0 -19
  5. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/history_format.py +2 -5
  6. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/prompt_helpers.py +0 -18
  7. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_think/parse.py +91 -49
  8. {echotools-2.3.48 → echotools-2.3.50}/src/echotools.egg-info/PKG-INFO +1 -1
  9. {echotools-2.3.48 → echotools-2.3.50}/src/echotools.egg-info/SOURCES.txt +2 -0
  10. echotools-2.3.50/src/tests/test_entml_agent_output_formats.py +143 -0
  11. echotools-2.3.50/src/tests/test_entml_parser_comprehensive.py +656 -0
  12. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_fncall_stream_thinking.py +79 -11
  13. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_stream_realtime_parse.py +4 -5
  14. {echotools-2.3.48 → echotools-2.3.50}/README.md +0 -0
  15. {echotools-2.3.48 → echotools-2.3.50}/docs/README.md +0 -0
  16. {echotools-2.3.48 → echotools-2.3.50}/setup.cfg +0 -0
  17. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/__init__.py +0 -0
  18. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/cache/__init__.py +0 -0
  19. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/cache/list_cache.py +0 -0
  20. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/cache/memory_cache.py +0 -0
  21. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/__init__.py +0 -0
  22. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/base.py +0 -0
  23. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/center.py +0 -0
  24. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/center_notify.py +0 -0
  25. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/center_template.py +0 -0
  26. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/loader.py +0 -0
  27. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/config/merge.py +0 -0
  28. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/errors/__init__.py +0 -0
  29. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/errors/base.py +0 -0
  30. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/errors/classify.py +0 -0
  31. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/errors/common.py +0 -0
  32. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/errors/http.py +0 -0
  33. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/ids/__init__.py +0 -0
  34. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/ids/generator.py +0 -0
  35. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/io/__init__.py +0 -0
  36. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/io/io_utils.py +0 -0
  37. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/io/printstream.py +0 -0
  38. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/logger/__init__.py +0 -0
  39. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/logger/manager.py +0 -0
  40. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/retry/__init__.py +0 -0
  41. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/base/retry/retry.py +0 -0
  42. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/compat.py +0 -0
  43. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/__init__.py +0 -0
  44. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/candidate.py +0 -0
  45. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/dispatcher.py +0 -0
  46. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/proxy_selector.py +0 -0
  47. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/race.py +0 -0
  48. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/selector.py +0 -0
  49. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/dispatch/usage.py +0 -0
  50. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/__init__.py +0 -0
  51. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/parsers/__init__.py +0 -0
  52. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/parsers/xml_params.py +0 -0
  53. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/parsers/xml_parser.py +0 -0
  54. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/__init__.py +0 -0
  55. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/inject.py +0 -0
  56. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/prompt/templates.py +0 -0
  57. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/__init__.py +0 -0
  58. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml.py +0 -0
  59. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_invoke.py +0 -0
  60. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_patterns.py +0 -0
  61. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_stream_json.py +0 -0
  62. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_think/__init__.py +0 -0
  63. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_think/core.py +0 -0
  64. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_think/hist.py +0 -0
  65. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_tools.py +0 -0
  66. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/protocols/entml_values.py +0 -0
  67. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/registry.py +0 -0
  68. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/__init__.py +0 -0
  69. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/coercion.py +0 -0
  70. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/loop_detect.py +0 -0
  71. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/normalization.py +0 -0
  72. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/schema_render.py +0 -0
  73. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/fncall/shared/xml_helpers.py +0 -0
  74. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/keys.py +0 -0
  75. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/lifecycle/__init__.py +0 -0
  76. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/lifecycle/manager.py +0 -0
  77. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/lifecycle/updater.py +0 -0
  78. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/lifecycle/updater_pull.py +0 -0
  79. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/process/__init__.py +0 -0
  80. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/process/port.py +0 -0
  81. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/protocol/__init__.py +0 -0
  82. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/protocol/base.py +0 -0
  83. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/__init__.py +0 -0
  84. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/conpty.py +0 -0
  85. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/local/__init__.py +0 -0
  86. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/local/core.py +0 -0
  87. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/local/proc.py +0 -0
  88. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/local/unix.py +0 -0
  89. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/local/win.py +0 -0
  90. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/sanitize.py +0 -0
  91. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/session/__init__.py +0 -0
  92. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/session/base.py +0 -0
  93. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/session/client.py +0 -0
  94. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/ssh.py +0 -0
  95. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/exec/terminal/tmux.py +0 -0
  96. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/__init__.py +0 -0
  97. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/charmap.py +0 -0
  98. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/spinner.py +0 -0
  99. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/ui.py +0 -0
  100. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/__init__.py +0 -0
  101. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/uibase.py +0 -0
  102. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/uicmds.py +0 -0
  103. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/uiinteract.py +0 -0
  104. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/uioutput.py +0 -0
  105. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/mixins/uistream.py +0 -0
  106. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_console.py +0 -0
  107. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_io.py +0 -0
  108. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_log.py +0 -0
  109. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_platform.py +0 -0
  110. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_text.py +0 -0
  111. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uicore/ui_types.py +0 -0
  112. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_countdown.py +0 -0
  113. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_layout.py +0 -0
  114. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_misc.py +0 -0
  115. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_panel.py +0 -0
  116. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_table.py +0 -0
  117. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uilayout/ui_tree.py +0 -0
  118. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uiwidgets/ui_box.py +0 -0
  119. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uiwidgets/ui_editor.py +0 -0
  120. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uiwidgets/ui_progress.py +0 -0
  121. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uiwidgets/ui_select.py +0 -0
  122. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/console/uiwidgets/ui_stream.py +0 -0
  123. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/events/__init__.py +0 -0
  124. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/events/bus.py +0 -0
  125. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/events/event.py +0 -0
  126. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/spinner/__init__.py +0 -0
  127. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/spinner/spinner.py +0 -0
  128. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/spinner/spinner_color.py +0 -0
  129. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/tracing/__init__.py +0 -0
  130. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/tracing/context.py +0 -0
  131. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/tracing/span.py +0 -0
  132. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/tracing/tracer.py +0 -0
  133. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/translate.py +0 -0
  134. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/watcher/__init__.py +0 -0
  135. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/watcher/file_watcher.py +0 -0
  136. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/__init__.py +0 -0
  137. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/application.py +0 -0
  138. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/broker.py +0 -0
  139. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/app.js +0 -0
  140. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/filezone.js +0 -0
  141. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/motionkit.js +0 -0
  142. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/sendbutton.js +0 -0
  143. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/sortablelist.css +0 -0
  144. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/sortablelist.js +0 -0
  145. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/textinput.js +0 -0
  146. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/components/voiceinput.js +0 -0
  147. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/fonts/inter-500.woff2 +0 -0
  148. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/fonts/inter-600.woff2 +0 -0
  149. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/fonts/inter400.woff2 +0 -0
  150. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/inputbox.css +0 -0
  151. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/inputbox.js +0 -0
  152. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/style.css +0 -0
  153. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/input_box/voicegif.js +0 -0
  154. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/middleware.py +0 -0
  155. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/stats.py +0 -0
  156. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/media/web/utils.py +0 -0
  157. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/files/__init__.py +0 -0
  158. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/files/file_util.py +0 -0
  159. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/network/__init__.py +0 -0
  160. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/network/http_utils.py +0 -0
  161. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/plugin/__init__.py +0 -0
  162. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/plugin/base.py +0 -0
  163. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/plugin/discovery.py +0 -0
  164. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/plugin/registry.py +0 -0
  165. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/plugin/registry_ops.py +0 -0
  166. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/proxy/__init__.py +0 -0
  167. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/proxy/manager.py +0 -0
  168. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/runtime/__init__.py +0 -0
  169. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/runtime/collector.py +0 -0
  170. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/scheduler/__init__.py +0 -0
  171. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/scheduler/scheduler.py +0 -0
  172. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/sdk/__init__.py +0 -0
  173. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/plat/sdk/facade.py +0 -0
  174. {echotools-2.3.48 → echotools-2.3.50}/src/echotools/version.py +0 -0
  175. {echotools-2.3.48 → echotools-2.3.50}/src/echotools.egg-info/dependency_links.txt +0 -0
  176. {echotools-2.3.48 → echotools-2.3.50}/src/echotools.egg-info/requires.txt +0 -0
  177. {echotools-2.3.48 → echotools-2.3.50}/src/echotools.egg-info/top_level.txt +0 -0
  178. {echotools-2.3.48 → echotools-2.3.50}/src/tests/bench_import.py +0 -0
  179. {echotools-2.3.48 → echotools-2.3.50}/src/tests/conftest.py +0 -0
  180. {echotools-2.3.48 → echotools-2.3.50}/src/tests/fixtures/__init__.py +0 -0
  181. {echotools-2.3.48 → echotools-2.3.50}/src/tests/fixtures/simulated_llm_tool_responses.py +0 -0
  182. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_adversarial_stream_splits.py +0 -0
  183. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_cache_retry.py +0 -0
  184. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_completion.py +0 -0
  185. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_config.py +0 -0
  186. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_config_extended.py +0 -0
  187. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_console.py +0 -0
  188. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_coverage_core.py +0 -0
  189. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_dispatch.py +0 -0
  190. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_dispatch_extended.py +0 -0
  191. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_dispatch_race.py +0 -0
  192. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_bare_invoke_format.py +0 -0
  193. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_protocol.py +0 -0
  194. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_stream_json.py +0 -0
  195. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_stream_json_exhaustive.py +0 -0
  196. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_thinking_history.py +0 -0
  197. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_thinking_parse.py +0 -0
  198. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_tool_parse_leak.py +0 -0
  199. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_tool_pipeline.py +0 -0
  200. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_entml_tools.py +0 -0
  201. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_errors.py +0 -0
  202. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_errors_common.py +0 -0
  203. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_events.py +0 -0
  204. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_events_event.py +0 -0
  205. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_facade.py +0 -0
  206. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_fncall.py +0 -0
  207. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_ids.py +0 -0
  208. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_imports.py +0 -0
  209. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_io.py +0 -0
  210. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_keys.py +0 -0
  211. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_lifecycle.py +0 -0
  212. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_logger.py +0 -0
  213. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_merge.py +0 -0
  214. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_plugin.py +0 -0
  215. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_printstream.py +0 -0
  216. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_protocols.py +0 -0
  217. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_proxy.py +0 -0
  218. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_proxy_selector.py +0 -0
  219. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_runtime.py +0 -0
  220. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_scheduler.py +0 -0
  221. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_simulated_llm_tool_parse.py +0 -0
  222. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_tracing.py +0 -0
  223. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_usage.py +0 -0
  224. {echotools-2.3.48 → echotools-2.3.50}/src/tests/test_web.py +0 -0
  225. {echotools-2.3.48 → echotools-2.3.50}/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.48
3
+ Version: 2.3.50
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.48"
7
+ version = "2.3.50"
8
8
  description = "通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链"
9
9
  readme = "docs/README.md"
10
10
  requires-python = ">=3.8"
@@ -148,6 +148,23 @@ class FncallStreamParser:
148
148
  return fn(text)
149
149
  return text
150
150
 
151
+ def _drain_thinking_holdback_to_fncall(self) -> None:
152
+ """invoke 已开始后,thinking 过滤器里对工具标签的 holdback 应并入 fncall 缓冲。"""
153
+ if self._thinking_filter is None:
154
+ return
155
+ pending = self._thinking_filter._pending
156
+ if pending:
157
+ self._fncall_buf += pending
158
+ self._thinking_filter._pending = ""
159
+
160
+ def _begin_function_calls(self, buffer_from: str, *, pos: int) -> None:
161
+ self._fncall_buf = buffer_from[pos:]
162
+ self._drain_thinking_holdback_to_fncall()
163
+ self._detected = True
164
+ self._state = self.IN_FUNCTION_CALLS
165
+ if self._is_call_closed():
166
+ self._state = self.DONE
167
+
151
168
  def _feed_content_waiting(self, text: str) -> None:
152
169
  """thinking 已闭合后,对可见正文做 invoke 检测(不再经过 thinking 过滤器)。"""
153
170
  if not text:
@@ -173,11 +190,7 @@ class FncallStreamParser:
173
190
  if pos > 0:
174
191
  self._append_content_text(text[:pos])
175
192
 
176
- self._fncall_buf = text[pos:]
177
- self._detected = True
178
- self._state = self.IN_FUNCTION_CALLS
179
- if self._is_call_closed():
180
- self._state = self.DONE
193
+ self._begin_function_calls(text, pos=pos)
181
194
 
182
195
  def _feed_waiting_thinking_plain(self, combined: str) -> None:
183
196
  """未闭合 thinking 阶段:块内一律按纯文本进 thinking,不检测 invoke。"""
@@ -187,6 +200,8 @@ class FncallStreamParser:
187
200
  self._thinking_parts.append(part)
188
201
  elif part:
189
202
  self._feed_content_waiting(part)
203
+ if self._state == self.IN_FUNCTION_CALLS:
204
+ self._drain_thinking_holdback_to_fncall()
190
205
 
191
206
  def _feed_waiting(self, chunk: str) -> None:
192
207
  """在 WAITING_FOR_TAG 状态下处理新块。"""
@@ -202,14 +217,17 @@ class FncallStreamParser:
202
217
  found, pos = self._protocol.detect_start(combined)
203
218
 
204
219
  if found:
220
+ if self._thinking_filter is not None:
221
+ from echotools.exec.fncall.protocols.entml_think.parse import (
222
+ invoke_index_inside_unclosed_thinking,
223
+ )
224
+ if invoke_index_inside_unclosed_thinking(combined, pos):
225
+ self._feed_waiting_thinking_plain(combined)
226
+ return
205
227
  if pos > 0:
206
228
  # 工具标签前的正文仍可能含 thinking,走过滤器。
207
229
  self._emit_text(combined[:pos])
208
- self._fncall_buf = combined[pos:]
209
- self._detected = True
210
- self._state = self.IN_FUNCTION_CALLS
211
- if self._is_call_closed():
212
- self._state = self.DONE
230
+ self._begin_function_calls(combined, pos=pos)
213
231
  return
214
232
 
215
233
  # 无完整工具开标签时,再处理未闭合 thinking / 思考开标签 holdback。
@@ -264,6 +282,10 @@ class FncallStreamParser:
264
282
  if self._state == self.WAITING_FOR_TAG:
265
283
  self._feed_waiting(chunk)
266
284
  else:
285
+ if self._waiting_tail:
286
+ self._fncall_buf += self._normalize_stream_chunk(self._waiting_tail)
287
+ self._waiting_tail = ""
288
+ self._drain_thinking_holdback_to_fncall()
267
289
  self._fncall_buf += self._normalize_stream_chunk(chunk)
268
290
  if self._is_call_closed():
269
291
  self._state = self.DONE
@@ -319,22 +319,3 @@ def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
319
319
  return inject_orphan_tool_results(step2)
320
320
 
321
321
 
322
- # ---------------------------------------------------------------------------
323
- # 对话历史格式化
324
- # ---------------------------------------------------------------------------
325
-
326
-
327
- def _make_assistant_dedup_key(
328
- content: Optional[str],
329
- tool_calls: List[Dict[str, Any]],
330
- ) -> Tuple[str, Tuple[Tuple[str, str], ...]]:
331
- """生成 assistant 消息的去重键。"""
332
- safe_content = content or ""
333
- tc_key: Tuple[Tuple[str, str], ...] = tuple(
334
- (
335
- (tc.get("function") or {}).get("name") or "",
336
- (tc.get("function") or {}).get("arguments") or "",
337
- )
338
- for tc in tool_calls
339
- )
340
- return (safe_content, tc_key)
@@ -41,7 +41,6 @@ def _append_assistant_part(
41
41
  index: int,
42
42
  protocol: Optional[Any],
43
43
  call_id_to_name: Dict[str, str],
44
- seen_assistant_keys: set,
45
44
  *,
46
45
  include_thinking_in_history: bool,
47
46
  ) -> int:
@@ -55,7 +54,7 @@ def _append_assistant_part(
55
54
  tool_msgs.append(messages[j])
56
55
  j += 1
57
56
  blocks = format_assistant_block_with_results(
58
- m, tool_msgs, protocol, call_id_to_name, seen_assistant_keys,
57
+ m, tool_msgs, protocol, call_id_to_name,
59
58
  include_thinking_in_history=include_thinking_in_history,
60
59
  )
61
60
  for block in blocks:
@@ -63,7 +62,7 @@ def _append_assistant_part(
63
62
  return j
64
63
 
65
64
  blocks = format_assistant_block(
66
- m, content_str, protocol, call_id_to_name, seen_assistant_keys,
65
+ m, content_str, protocol, call_id_to_name,
67
66
  include_thinking_in_history=include_thinking_in_history,
68
67
  )
69
68
  for block in blocks:
@@ -81,7 +80,6 @@ def _format_conversation_history(
81
80
  return ""
82
81
 
83
82
  call_id_to_name: Dict[str, str] = {}
84
- seen_assistant_keys = set()
85
83
  parts: List[Tuple[str, bool]] = []
86
84
  clean_fn = protocol.clean_tags if protocol and hasattr(protocol, "clean_tags") else None
87
85
 
@@ -98,7 +96,6 @@ def _format_conversation_history(
98
96
  if role == "assistant":
99
97
  i = _append_assistant_part(
100
98
  parts, messages, i, protocol, call_id_to_name,
101
- seen_assistant_keys,
102
99
  include_thinking_in_history=include_thinking_in_history,
103
100
  )
104
101
  continue
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, Dict, List, Optional, Set, Tuple
4
4
 
5
- from echotools.base.logger.manager import get_logger
6
5
  from echotools.exec.fncall.protocols.entml_think.hist import (
7
6
  extract_reasoning_text,
8
7
  format_entml_thinking_history_block,
@@ -12,15 +11,12 @@ from echotools.exec.fncall.shared.normalization import normalize_content
12
11
  from .history import (
13
12
  _TOOL_CALL_MARKER_RE,
14
13
  _convert_assistant_pseudo_calls,
15
- _make_assistant_dedup_key,
16
14
  _render_inline_call_result,
17
15
  _render_tool_call,
18
16
  _render_tool_result,
19
17
  _try_convert_user_to_tool,
20
18
  )
21
19
 
22
- logger = get_logger(__name__)
23
-
24
20
 
25
21
  def _assistant_history_content_blocks(
26
22
  m: Dict[str, Any],
@@ -139,19 +135,12 @@ def format_assistant_block(
139
135
  content_str: str,
140
136
  protocol: Optional[Any],
141
137
  call_id_to_name: Dict[str, str],
142
- seen_assistant_keys: Set[Tuple[str, Tuple[Tuple[str, str], ...]]],
143
138
  *,
144
139
  include_thinking_in_history: bool = False,
145
140
  ) -> List[str]:
146
141
  tcs: List[Dict[str, Any]] = m.get("tool_calls") or []
147
142
  _register_tool_call_names(tcs, call_id_to_name)
148
143
 
149
- dedup_key = _make_assistant_dedup_key(content_str, tcs)
150
- if dedup_key in seen_assistant_keys:
151
- logger.debug("跳过重复 assistant 消息(dedup_key 已见)")
152
- return []
153
- seen_assistant_keys.add(dedup_key)
154
-
155
144
  parts: List[str] = []
156
145
  assistant = _format_assistant_text_block(
157
146
  m, content_str, protocol, include_thinking_in_history=include_thinking_in_history
@@ -174,7 +163,6 @@ def format_assistant_block_with_results(
174
163
  tool_msgs: List[Dict[str, Any]],
175
164
  protocol: Optional[Any],
176
165
  call_id_to_name: Dict[str, str],
177
- seen_assistant_keys: Set[Tuple[str, Tuple[Tuple[str, str], ...]]],
178
166
  *,
179
167
  include_thinking_in_history: bool = False,
180
168
  ) -> List[str]:
@@ -182,12 +170,6 @@ def format_assistant_block_with_results(
182
170
  content_str = normalize_content(m.get("content", ""))
183
171
  _register_tool_call_names(tcs, call_id_to_name)
184
172
 
185
- dedup_key = _make_assistant_dedup_key(content_str, tcs)
186
- if dedup_key in seen_assistant_keys:
187
- logger.debug("跳过重复 assistant 消息(dedup_key 已见)")
188
- return []
189
- seen_assistant_keys.add(dedup_key)
190
-
191
173
  parts: List[str] = []
192
174
  assistant = _format_assistant_text_block(
193
175
  m, content_str, protocol, include_thinking_in_history=include_thinking_in_history
@@ -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",
@@ -102,6 +103,25 @@ def has_unclosed_entml_thinking(text: str) -> bool:
102
103
  return False
103
104
 
104
105
 
106
+ def invoke_index_inside_unclosed_thinking(text: str, invoke_at: int) -> bool:
107
+ """``invoke_at`` 是否落在尚未正式 ``</entml:thinking>`` 闭合的块内。
108
+
109
+ 已出现容错 ``</thinking>`` 且 invoke 在其后的段落,视为 thinking 外。
110
+ """
111
+ if invoke_at < 0:
112
+ return False
113
+ think_open = text.find(_THINKING_OPEN_PREFIX)
114
+ if think_open < 0 or invoke_at <= think_open:
115
+ return False
116
+ entml_close = text.find(_THINKING_CLOSE, think_open)
117
+ if entml_close >= 0:
118
+ return invoke_at < entml_close
119
+ fault_at = text.find(_FAULT_THINKING_CLOSE, think_open)
120
+ if fault_at >= 0:
121
+ return invoke_at < fault_at + len(_FAULT_THINKING_CLOSE)
122
+ return True
123
+
124
+
105
125
  def split_entml_thinking(text: str) -> Tuple[str, str]:
106
126
  """从文本中剥离 <entml:thinking> 块,返回 (正文, 思考链拼接)。"""
107
127
  if not text:
@@ -142,10 +162,13 @@ class EntmlThinkingStreamFilter:
142
162
  self._in_block = False
143
163
  # 当前 thinking 块是否已输出过任何内容(用于首片去前导空白)
144
164
  self._thinking_started = False
165
+ # 见到 ``</thinking>`` 后暂存,仅当后续出现 invoke 才视为闭合;否则当纯文本
166
+ self._fault_watch = False
167
+ self._fault_buffer = ""
145
168
 
146
169
  def in_open_thinking(self) -> bool:
147
- """思考块已开始且尚未收到 </entml:thinking>(含开/闭标签分片 hold)。"""
148
- if self._in_block:
170
+ """思考块已开始且尚未收到 ``</entml:thinking>``(含开/闭标签分片 hold)。"""
171
+ if self._in_block or self._fault_watch:
149
172
  return True
150
173
  open_at = self._pending.find(_THINKING_OPEN_PREFIX)
151
174
  if open_at >= 0:
@@ -157,47 +180,47 @@ class EntmlThinkingStreamFilter:
157
180
  return True
158
181
  return False
159
182
 
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:
183
+ def _try_resolve_fault_watch_on_invoke(self) -> bool:
184
+ """``</thinking>`` 之后若出现完整 invoke 开标签,则在该处结束 thinking。"""
185
+ if not self._fault_watch or not self._fault_buffer:
164
186
  return False
165
- close_at = self._pending.find(_THINKING_CLOSE)
166
- if close_at >= 0 and tool_at >= close_at:
187
+ invoke_at = find_complete_entml_invoke_open(self._fault_buffer)
188
+ if invoke_at < len(_FAULT_THINKING_CLOSE):
167
189
  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
190
  self._in_block = False
191
+ self._fault_watch = False
173
192
  self._thinking_started = False
174
- self._pending = self._pending[tool_at:]
193
+ self._pending = self._fault_buffer[len(_FAULT_THINKING_CLOSE) :]
194
+ self._fault_buffer = ""
175
195
  return True
176
196
 
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:
197
+ def _try_resolve_fault_watch_on_entml_close(self, out: List[Tuple[str, str]]) -> bool:
198
+ """未见 invoke 时出现 ``</entml:thinking>`` → ``</thinking>`` 仅为思考内纯文本。"""
199
+ if not self._fault_watch:
181
200
  return False
182
- close_at = self._pending.find(_THINKING_CLOSE)
183
- if close_at >= 0 and invoke_at >= close_at:
201
+ close_at = self._fault_buffer.find(_THINKING_CLOSE)
202
+ if close_at < 0:
184
203
  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))
204
+ emitted = self._emit_thinking_piece(self._fault_buffer[:close_at])
205
+ if emitted:
206
+ out.append(("thinking", emitted))
189
207
  self._in_block = False
208
+ self._fault_watch = False
190
209
  self._thinking_started = False
191
- self._pending = self._pending[invoke_at:]
210
+ self._pending = self._fault_buffer[close_at + len(_THINKING_CLOSE) :]
211
+ self._fault_buffer = ""
192
212
  return True
193
213
 
194
- def _feed_in_block(self, out: List[Tuple[str, str]]) -> bool:
195
- """处理块内 pending;返回 False 表示应退出 feed 主循环。"""
196
- if self._split_thinking_at_invoke(out):
214
+ def _feed_fault_watch(self, out: List[Tuple[str, str]]) -> bool:
215
+ """处理 ``</thinking>`` 容错观察期;返回 True 表示可继续主循环。"""
216
+ if self._try_resolve_fault_watch_on_invoke():
197
217
  return True
198
- if self._split_thinking_at_tool_markup(out):
218
+ if self._try_resolve_fault_watch_on_entml_close(out):
199
219
  return True
220
+ return False
200
221
 
222
+ def _feed_in_block(self, out: List[Tuple[str, str]]) -> bool:
223
+ """处理块内 pending;返回 False 表示应退出 feed 主循环。"""
201
224
  close_at = self._pending.find(_THINKING_CLOSE)
202
225
  if close_at >= 0:
203
226
  piece = self._pending[:close_at]
@@ -205,10 +228,23 @@ class EntmlThinkingStreamFilter:
205
228
  if emitted:
206
229
  out.append(("thinking", emitted))
207
230
  self._in_block = False
231
+ self._fault_watch = False
232
+ self._fault_buffer = ""
208
233
  self._thinking_started = False
209
234
  self._pending = self._pending[close_at + len(_THINKING_CLOSE) :]
210
235
  return True
211
236
 
237
+ fault_at = self._pending.find(_FAULT_THINKING_CLOSE)
238
+ if fault_at >= 0:
239
+ before = self._pending[:fault_at]
240
+ emitted = self._emit_thinking_piece(before)
241
+ if emitted:
242
+ out.append(("thinking", emitted))
243
+ self._fault_watch = True
244
+ self._fault_buffer = self._pending[fault_at:]
245
+ self._pending = ""
246
+ return False
247
+
212
248
  safe, tool_hold = _hold_ambiguous_tool_markup(self._pending)
213
249
  if tool_hold:
214
250
  if safe:
@@ -219,6 +255,8 @@ class EntmlThinkingStreamFilter:
219
255
  return False
220
256
 
221
257
  safe, hold = _hold_prefix(self._pending, _THINKING_CLOSE)
258
+ if not hold:
259
+ safe, hold = _hold_prefix(safe, _FAULT_THINKING_CLOSE)
222
260
  if safe:
223
261
  emitted = self._emit_thinking_piece(safe)
224
262
  if emitted:
@@ -257,6 +295,12 @@ class EntmlThinkingStreamFilter:
257
295
  out: List[Tuple[str, str]] = []
258
296
 
259
297
  while self._pending:
298
+ if self._fault_watch:
299
+ self._fault_buffer += self._pending
300
+ self._pending = ""
301
+ if self._feed_fault_watch(out):
302
+ continue
303
+ break
260
304
  if self._in_block:
261
305
  if not self._feed_in_block(out):
262
306
  break
@@ -279,39 +323,37 @@ class EntmlThinkingStreamFilter:
279
323
 
280
324
  def finalize(self) -> List[Tuple[str, str]]:
281
325
  out: List[Tuple[str, str]] = []
326
+ if self._fault_watch:
327
+ if self._try_resolve_fault_watch_on_invoke():
328
+ pass
329
+ elif self._try_resolve_fault_watch_on_entml_close(out):
330
+ pass
331
+ else:
332
+ emitted = self._emit_thinking_piece(self._fault_buffer)
333
+ if emitted:
334
+ out.append(("thinking", emitted))
335
+ self._fault_buffer = ""
336
+ self._fault_watch = False
337
+ self._in_block = False
338
+ self._thinking_started = False
282
339
  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
340
+ emitted = self._emit_thinking_piece(self._pending)
341
+ if emitted:
342
+ out.append(("thinking", emitted))
301
343
  self._pending = ""
302
344
  self._in_block = False
303
345
  self._thinking_started = False
304
- return out
305
- elif self._pending:
346
+ if self._pending:
306
347
  content, thinking = split_entml_thinking(self._pending)
307
348
  if thinking:
308
349
  out.append(("thinking", thinking))
309
350
  if content:
310
351
  out.append(("content", content))
311
352
  elif self._pending and not thinking:
312
- # 仅 hold 了半截开标签:原样吐出,避免吞字
313
353
  out.append(("content", self._pending))
314
354
  self._pending = ""
315
355
  self._in_block = False
356
+ self._fault_watch = False
357
+ self._fault_buffer = ""
316
358
  self._thinking_started = False
317
359
  return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echotools
3
- Version: 2.3.48
3
+ Version: 2.3.50
4
4
  Summary: 通用基础设施 SDK:配置、日志、事件、调度、插件、协议、调用链
5
5
  Home-page: https://github.com/nichengfuben/echotools
6
6
  Author: nichengfuben
@@ -182,7 +182,9 @@ src/tests/test_coverage_core.py
182
182
  src/tests/test_dispatch.py
183
183
  src/tests/test_dispatch_extended.py
184
184
  src/tests/test_dispatch_race.py
185
+ src/tests/test_entml_agent_output_formats.py
185
186
  src/tests/test_entml_bare_invoke_format.py
187
+ src/tests/test_entml_parser_comprehensive.py
186
188
  src/tests/test_entml_protocol.py
187
189
  src/tests/test_entml_stream_json.py
188
190
  src/tests/test_entml_stream_json_exhaustive.py
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ """模型/agent 常见输出格式:正确 entml:invoke vs 错误 JSON/旧格式。"""
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from echotools.exec.fncall import get_protocol
10
+ from echotools.exec.fncall.parsers.stream import FncallStreamParser
11
+
12
+ TOOLS = [
13
+ {
14
+ "type": "function",
15
+ "function": {
16
+ "name": "Read",
17
+ "description": "Read a file",
18
+ "parameters": {
19
+ "type": "object",
20
+ "properties": {"path": {"type": "string"}},
21
+ "required": ["path"],
22
+ },
23
+ },
24
+ },
25
+ {
26
+ "type": "function",
27
+ "function": {
28
+ "name": "get_time",
29
+ "description": "Get current time",
30
+ "parameters": {"type": "object", "properties": {}, "required": []},
31
+ },
32
+ },
33
+ ]
34
+
35
+ VALID_INVOKE = (
36
+ '<entml:invoke name="Read">\n'
37
+ '<entml:parameter name="path">C:/Users/Administrator/cursor_agent_simple.py</entml:parameter>\n'
38
+ "</entml:invoke>"
39
+ )
40
+
41
+ WRONG_JSON_TOOL = (
42
+ '{"name": "Read", "arguments": {"path": "C:/Users/Administrator/cursor_agent_simple.py"}}'
43
+ )
44
+
45
+ WRONG_TOOL_BLOCK = (
46
+ "<tool>\n"
47
+ '{"name": "Read", "arguments": {"path": "x.py"}}\n'
48
+ "</tool>"
49
+ )
50
+
51
+ FAKE_PARTIAL_JSON = '{"name": "Read", "arguments": {"path":'
52
+
53
+
54
+ def _parse_batch(text: str):
55
+ proto = get_protocol("entml")
56
+ clean, calls = proto.parse(text, TOOLS)
57
+ return clean, calls
58
+
59
+
60
+ def _parse_stream(text: str, *, chunk: int = 3):
61
+ proto = get_protocol("entml")
62
+ parser = FncallStreamParser(protocol=proto, tools=TOOLS)
63
+ for i in range(0, len(text), chunk):
64
+ parser.feed(text[i : i + chunk])
65
+ return parser.finalize()
66
+
67
+
68
+ @pytest.mark.parametrize("chunk", [1, 4, 17])
69
+ def test_valid_entml_invoke_batch_and_stream(chunk: int) -> None:
70
+ text = f"我先读文件。\n{VALID_INVOKE}\n完成。"
71
+ clean, calls = _parse_batch(text)
72
+ assert len(calls) == 1
73
+ assert calls[0]["function"]["name"] == "Read"
74
+ assert "cursor_agent_simple.py" in calls[0]["function"]["arguments"]
75
+ assert "entml:invoke" not in clean
76
+
77
+ clean_s, calls_s = _parse_stream(text, chunk=chunk)
78
+ assert len(calls_s) == 1
79
+ assert calls_s[0]["function"]["name"] == "Read"
80
+ assert "entml:invoke" not in clean_s
81
+
82
+
83
+ def test_wrong_json_object_not_parsed_as_tool() -> None:
84
+ text = f"调用工具:\n{WRONG_JSON_TOOL}"
85
+ clean, calls = _parse_batch(text)
86
+ assert calls == []
87
+ assert "Read" in clean or "name" in clean
88
+
89
+
90
+ def test_wrong_tool_block_not_parsed_as_entml_invoke() -> None:
91
+ text = f"回复\n{WRONG_TOOL_BLOCK}"
92
+ clean, calls = _parse_batch(text)
93
+ assert calls == []
94
+
95
+
96
+ def test_truncated_json_not_parsed() -> None:
97
+ clean, calls = _parse_batch(FAKE_PARTIAL_JSON)
98
+ assert calls == []
99
+
100
+
101
+ def test_thinking_then_valid_invoke(chunk: int = 5) -> None:
102
+ text = (
103
+ "<entml:thinking>\n需要先读脚本\n</entml:thinking>\n"
104
+ f"{VALID_INVOKE}"
105
+ )
106
+ clean, calls = _parse_stream(text, chunk=chunk)
107
+ assert len(calls) == 1
108
+ assert calls[0]["function"]["name"] == "Read"
109
+
110
+
111
+ def test_fault_thinking_close_then_invoke() -> None:
112
+ text = (
113
+ "<entml:thinking>\nplan\n</thinking>\n"
114
+ '<entml:invoke name="get_time">\n'
115
+ "</entml:invoke>"
116
+ )
117
+ clean, calls = _parse_stream(text, chunk=2)
118
+ assert len(calls) == 1
119
+ assert calls[0]["function"]["name"] == "get_time"
120
+ assert json.loads(calls[0]["function"]["arguments"]) == {}
121
+
122
+
123
+ def test_invoke_inside_unclosed_thinking_not_parsed() -> None:
124
+ text = f"<entml:thinking>\nplan {VALID_INVOKE}\n"
125
+ parser = FncallStreamParser(protocol=get_protocol("entml"), tools=TOOLS)
126
+ for i in range(0, len(text), 4):
127
+ parser.feed(text[i : i + 4])
128
+ assert not parser.has_calls
129
+ assert "cursor_agent_simple.py" in parser.partial_thinking
130
+ clean, calls = parser.finalize()
131
+ assert calls == []
132
+
133
+
134
+ def test_multiline_parameter_preserved() -> None:
135
+ body = "line1\nline2\nline3"
136
+ text = (
137
+ '<entml:invoke name="Read">\n'
138
+ f'<entml:parameter name="path">{body}</entml:parameter>\n'
139
+ "</entml:invoke>"
140
+ )
141
+ _, calls = _parse_batch(text)
142
+ args = json.loads(calls[0]["function"]["arguments"])
143
+ assert args["path"] == body