diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index aa6c39e80..dab1377c6 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -102,10 +102,16 @@ def _schedule_deferred_subagent_cleanup(task_id: str, trace_id: str, max_polls: def _find_usage_recorder(runtime: Any) -> Any | None: """Find a callback handler with ``record_external_llm_usage_records`` in the runtime config. - LangChain may pass ``config["callbacks"]`` as either a plain list of handlers - or as a ``BaseCallbackManager`` instance (e.g. ``AsyncCallbackManager`` on - async tool runs). Callback managers are not iterable; unwrap their - ``handlers`` list before searching. + LangChain may pass ``config["callbacks"]`` in three different shapes: + + - ``None`` (no callbacks registered): no recorder. + - A plain ``list[BaseCallbackHandler]``: iterate it directly. + - A ``BaseCallbackManager`` instance (e.g. ``AsyncCallbackManager`` on async + tool runs): managers are not iterable, so we unwrap ``.handlers`` first. + + Any other shape (e.g. a single handler object accidentally passed without a + list wrapper) cannot be iterated safely; treat it as "no recorder" rather + than raise. """ if runtime is None: return None @@ -117,6 +123,8 @@ def _find_usage_recorder(runtime: Any) -> Any | None: callbacks = callbacks.handlers if not callbacks: return None + if not isinstance(callbacks, list): + return None for cb in callbacks: if hasattr(cb, "record_external_llm_usage_records"): return cb diff --git a/backend/tests/test_task_tool_usage_recorder.py b/backend/tests/test_task_tool_usage_recorder.py index 4decf1ef9..d7b4ea3b5 100644 --- a/backend/tests/test_task_tool_usage_recorder.py +++ b/backend/tests/test_task_tool_usage_recorder.py @@ -63,3 +63,29 @@ def test_find_usage_recorder_handles_empty_manager(): manager = AsyncCallbackManager(handlers=[]) runtime = _make_runtime(manager) assert _find_usage_recorder(runtime) is None + + +def test_find_usage_recorder_returns_none_for_none_runtime(): + assert _find_usage_recorder(None) is None + + +def test_find_usage_recorder_returns_none_when_callbacks_is_none(): + runtime = _make_runtime(None) + assert _find_usage_recorder(runtime) is None + + +def test_find_usage_recorder_returns_none_for_single_handler_object(): + """A single handler instance (not wrapped in a list or manager) should not crash. + + LangChain's contract is that ``config["callbacks"]`` is a list-or-manager, + but we treat any other shape defensively rather than letting a ``for`` loop + blow up at runtime. + """ + runtime = _make_runtime(_RecorderHandler()) + assert _find_usage_recorder(runtime) is None + + +def test_find_usage_recorder_returns_none_when_config_not_dict(): + """Defensive: a runtime without a dict-shaped config should not raise.""" + runtime = SimpleNamespace(config="not-a-dict") + assert _find_usage_recorder(runtime) is None