From 5e718bca7980e74635537241e2cdc8eb23edcbb2 Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Sat, 31 Oct 2026 15:20:46 +0800 Subject: [PATCH] fix(agent): avoid duplicate replies and enable LocalAgent cards --- .../LocalAgent/components/runner/default.yaml | 3 ++ plugins/Runner/LocalAgent/manifest.yaml | 2 +- plugins/Runner/LocalAgent/pyproject.toml | 2 +- src/langbot/pkg/agent/runner/orchestrator.py | 27 +++++++++--- .../agent/test_orchestrator_integration.py | 43 ++++++++++++++++--- 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/plugins/Runner/LocalAgent/components/runner/default.yaml b/plugins/Runner/LocalAgent/components/runner/default.yaml index cdf045c50..46a44619d 100644 --- a/plugins/Runner/LocalAgent/components/runner/default.yaml +++ b/plugins/Runner/LocalAgent/components/runner/default.yaml @@ -310,6 +310,7 @@ spec: skill_authoring: true interrupt: true steering: true + interactions: true permissions: models: - count_tokens @@ -324,6 +325,8 @@ spec: - retrieve history: - page + interactions: + - request execution: python: path: default.py diff --git a/plugins/Runner/LocalAgent/manifest.yaml b/plugins/Runner/LocalAgent/manifest.yaml index 39b90e3d1..6c07f2482 100644 --- a/plugins/Runner/LocalAgent/manifest.yaml +++ b/plugins/Runner/LocalAgent/manifest.yaml @@ -3,7 +3,7 @@ kind: Plugin metadata: author: langbot-team name: LocalAgent - version: 0.2.4 + version: 0.2.5 repository: https://github.com/langbot-app/LangBot/tree/master/plugins/Runner/LocalAgent label: en_US: Local Agent diff --git a/plugins/Runner/LocalAgent/pyproject.toml b/plugins/Runner/LocalAgent/pyproject.toml index a824c660a..2cbab7e75 100644 --- a/plugins/Runner/LocalAgent/pyproject.toml +++ b/plugins/Runner/LocalAgent/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "langbot-local-agent" -version = "0.2.4" +version = "0.2.5" description = "Built-in agent with model fallback, tools, and knowledge retrieval" requires-python = ">=3.11,<4.0" dependencies = [ diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index 028640d36..d47a3a481 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -5,6 +5,7 @@ from __future__ import annotations import time import asyncio import contextlib +import re import copy import typing from uuid import uuid4 @@ -50,6 +51,14 @@ from ...telemetry.execution import close_trace as close_execution_trace from ...telemetry.execution import record as record_execution +def _visible_auto_reply_text(content: str) -> str: + visible = re.sub(r'.*?(?:|$)', '', content, flags=re.IGNORECASE | re.DOTALL) + for size in range(min(7, len(visible)), 0, -1): + if any(tag.startswith(visible[-size:].lower()) for tag in ('', '')): + return visible[:-size] + return visible + + class AgentRunOrchestrator: """Coordinate one Runner execution. @@ -266,6 +275,7 @@ class AgentRunOrchestrator: auto_reply_cumulative_text = '' auto_reply_prefix = '' auto_reply_deferred = False + explicit_event_reply_sent = False explicit_reply_text: str | None = None pending_explicit_replies: dict[str, str] = {} # A run reached without a platform ingress (WebUI debug, service API) @@ -410,6 +420,8 @@ class AgentRunOrchestrator: and tool_definition.api == 'send_message' and not result_data.get('error') ) + if auto_reply_deferred and result_data.get('tool_name') == 'event_reply': + explicit_event_reply_sent = True explicit_reply_text = pending_explicit_replies.pop( str(result_data.get('tool_call_id') or ''), None ) @@ -500,15 +512,17 @@ class AgentRunOrchestrator: async with binding_for(execution_query).lock: result.attachments = exported_message(execution_query, file_ids, consume=True) - if auto_reply_enabled and result is not None: + if auto_reply_enabled and not explicit_event_reply_sent and result is not None: if result_type == 'message.delta' and isinstance(result, provider_message.MessageChunk): cumulative_text = result.all_content or result.content if isinstance(cumulative_text, str): + reply_text = _visible_auto_reply_text(cumulative_text) if auto_reply_prefix and cumulative_text.startswith(auto_reply_prefix): - reply_text = cumulative_text[len(auto_reply_prefix) :] + visible_prefix = _visible_auto_reply_text(auto_reply_prefix) + if reply_text.startswith(visible_prefix): + reply_text = reply_text[len(visible_prefix) :] else: auto_reply_prefix = '' - reply_text = cumulative_text auto_reply_cumulative_text = cumulative_text auto_reply_stream_text = reply_text if reply_text.strip() and not auto_reply_deferred: @@ -523,8 +537,11 @@ class AgentRunOrchestrator: elif has_completed_message: reply_text = result.content if isinstance(reply_text, str): - if auto_reply_prefix and reply_text.startswith(auto_reply_prefix): - reply_text = reply_text[len(auto_reply_prefix) :] + reply_text = _visible_auto_reply_text(reply_text) + if auto_reply_prefix and result.content.startswith(auto_reply_prefix): + visible_prefix = _visible_auto_reply_text(auto_reply_prefix) + if reply_text.startswith(visible_prefix): + reply_text = reply_text[len(visible_prefix) :] if reply_text.strip() and ( not auto_reply_deferred or explicit_reply_text is None diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index a49dc7702..bf1ba5bea 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -1272,8 +1272,9 @@ class TestQueryEntrySessionQueryId: @pytest.mark.asyncio @pytest.mark.parametrize('event_type', ['message.received', 'interaction.submitted']) @pytest.mark.parametrize('completed_message', [False, True]) +@pytest.mark.parametrize('reasoning', [False, True]) async def test_direct_agent_streams_output_to_the_originating_platform( - clean_agent_state, event_type, completed_message + clean_agent_state, event_type, completed_message, reasoning ): from langbot.pkg.agent.runner.host_models import ( AgentBinding, @@ -1286,14 +1287,16 @@ async def test_direct_agent_streams_output_to_the_originating_platform( from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext from langbot_plugin.api.entities.builtin.runner.input import AgentInput + first_content = 'Checking the requesthello' if reasoning else 'hello' + final_content = 'Checking the requesthello world' if reasoning else 'hello world' runner_results = [ { 'type': 'message.delta', 'data': { 'chunk': { 'role': 'assistant', - 'content': 'hello', - 'all_content': 'hello', + 'content': first_content, + 'all_content': first_content, } }, }, @@ -1302,7 +1305,7 @@ async def test_direct_agent_streams_output_to_the_originating_platform( runner_results.append( { 'type': 'message.completed', - 'data': {'message': {'role': 'assistant', 'content': 'hello world'}}, + 'data': {'message': {'role': 'assistant', 'content': final_content}}, } ) runner_results.append({'type': 'run.completed', 'data': {'finish_reason': 'stop'}}) @@ -1363,7 +1366,9 @@ async def test_direct_agent_streams_output_to_the_originating_platform( ) ] - assert [item.content for item in results] == (['hello', 'hello world'] if completed_message else ['hello']) + assert [item.content for item in results] == ( + [first_content, final_content] if completed_message else [first_content] + ) if event_type == 'message.received': adapter.create_message_card.assert_awaited_once() chunks = adapter.reply_message_chunk.await_args_list @@ -1398,6 +1403,27 @@ async def test_direct_agent_delivers_text_after_card_without_repeating_explicit_ results = [] if tool_name == 'present_interactive_card': results.append({'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'Before'}}}) + else: + results.extend( + [ + { + 'type': 'message.delta', + 'data': {'chunk': {'role': 'assistant', 'content': 'Checking available tools'}}, + }, + { + 'type': 'tool.call.completed', + 'data': {'tool_call_id': 'skill-1', 'tool_name': 'list_skills', 'result': {'skills': []}}, + }, + { + 'type': 'message.delta', + 'data': {'chunk': {'role': 'assistant', 'content': 'Checking available tools'}}, + }, + ] + ) results.extend( [ { @@ -1412,6 +1438,13 @@ async def test_direct_agent_delivers_text_after_card_without_repeating_explicit_ ) if tool_name == 'present_interactive_card': results.append({'type': 'message.delta', 'data': {'chunk': {'role': 'assistant', 'content': 'BeforeAfter'}}}) + else: + results.append( + { + 'type': 'message.delta', + 'data': {'chunk': {'role': 'assistant', 'content': 'Checking available toolsAfter'}}, + } + ) results.extend( [ {'type': 'message.completed', 'data': {'message': {'role': 'assistant', 'content': 'After'}}},