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.
- {echotools-2.3.39 → echotools-2.3.42}/PKG-INFO +1 -1
- {echotools-2.3.39 → echotools-2.3.42}/pyproject.toml +1 -1
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/stream.py +50 -4
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/history.py +39 -0
- echotools-2.3.42/src/echotools/exec/fncall/prompt/history_format.py +116 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/inject.py +6 -5
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/prompt_helpers.py +1 -1
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml.py +29 -6
- echotools-2.3.42/src/echotools/exec/fncall/protocols/entml_think/__init__.py +31 -0
- 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
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/PKG-INFO +1 -1
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/SOURCES.txt +5 -4
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_protocol.py +30 -3
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_thinking_history.py +1 -1
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_thinking_parse.py +7 -1
- echotools-2.3.42/src/tests/test_fncall_stream_thinking.py +110 -0
- echotools-2.3.39/src/echotools/exec/fncall/prompt/history_format.py +0 -89
- echotools-2.3.39/src/echotools/exec/fncall/protocols/entml_sanitize.py +0 -24
- {echotools-2.3.39 → echotools-2.3.42}/README.md +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/docs/README.md +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/setup.cfg +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/list_cache.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/cache/memory_cache.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/base.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center_notify.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/center_template.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/loader.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/config/merge.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/base.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/classify.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/common.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/errors/http.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/ids/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/ids/generator.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/io_utils.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/io/printstream.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/logger/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/logger/manager.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/retry/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/base/retry/retry.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/compat.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/candidate.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/dispatcher.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/proxy_selector.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/race.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/selector.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/dispatch/usage.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/xml_params.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/parsers/xml_parser.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/prompt/templates.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_invoke.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_patterns.py +0 -0
- /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
- /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
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_tools.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/protocols/entml_values.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/registry.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/coercion.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/loop_detect.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/normalization.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/schema_render.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/fncall/shared/xml_helpers.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/keys.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/manager.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/updater.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/lifecycle/updater_pull.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/process/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/process/port.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/protocol/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/protocol/base.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/conpty.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/core.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/proc.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/unix.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/local/win.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/sanitize.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/base.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/session/client.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/ssh.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/exec/terminal/tmux.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/charmap.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/spinner.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/console/ui.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/bus.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/events/event.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/spinner.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/spinner/spinner_color.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/context.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/span.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/tracing/tracer.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/translate.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/watcher/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/watcher/file_watcher.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/application.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/broker.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/app.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/filezone.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/motionkit.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sendbutton.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sortablelist.css +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/sortablelist.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/textinput.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/components/voiceinput.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter-500.woff2 +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter-600.woff2 +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/inter400.woff2 +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/fonts/tailwind.css +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/inputbox.css +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/inputbox.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/style.css +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/input_box/voicegif.js +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/middleware.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/stats.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/media/web/utils.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/files/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/files/file_util.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/network/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/network/http_utils.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/base.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/discovery.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/registry.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/plugin/registry_ops.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/proxy/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/proxy/manager.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/runtime/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/runtime/collector.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/scheduler/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/scheduler/scheduler.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/sdk/__init__.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/plat/sdk/facade.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools/version.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/dependency_links.txt +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/requires.txt +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/echotools.egg-info/top_level.txt +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/bench_import.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/conftest.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_cache_retry.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_completion.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_config.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_config_extended.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_console.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_coverage_core.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch_extended.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_dispatch_race.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_entml_tools.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_errors.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_errors_common.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_events.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_events_event.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_facade.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_fncall.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_ids.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_imports.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_io.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_keys.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_lifecycle.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_logger.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_merge.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_plugin.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_printstream.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_protocols.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_proxy.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_proxy_selector.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_runtime.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_scheduler.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_tracing.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_usage.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_web.py +0 -0
- {echotools-2.3.39 → echotools-2.3.42}/src/tests/test_web_broker.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
26
|
-
from echotools.exec.fncall.protocols.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
346
|
+
from echotools.exec.fncall.protocols.entml import strip_entml_from_content
|
|
320
347
|
|
|
321
348
|
raw = (
|
|
322
349
|
"请查天气\n"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
from echotools.exec.fncall.protocols.
|
|
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 = []
|