fix(agent): avoid duplicate replies and enable LocalAgent cards

This commit is contained in:
fdc310
2026-10-31 15:20:46 +08:00
parent e38091dba7
commit 5e718bca79
5 changed files with 65 additions and 12 deletions
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 = [
+22 -5
View File
@@ -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'<think>.*?(?:</think>|$)', '', 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 ('<think>', '</think>')):
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
@@ -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 = '<think>Checking the request</think>hello' if reasoning else 'hello'
final_content = '<think>Checking the request</think>hello 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': '<thi'}},
},
{
'type': 'message.delta',
'data': {'chunk': {'role': 'assistant', 'content': '<think>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': '<think>Checking available tools</think>'}},
},
]
)
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': '<think>Checking available tools</think>After'}},
}
)
results.extend(
[
{'type': 'message.completed', 'data': {'message': {'role': 'assistant', 'content': 'After'}}},