Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/druks/chat/channels/github/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def send_reply(
Druks wrote."""
asked = await session.get(Message, reply.reply_to)
source_id = await conversation.get_answered_source_id(session, asked)
reply.source_id = await cls.post(conversation.thread_id, reply.body, source_id=source_id)
reply.source_id = await cls.post(conversation.thread_id, reply.answer, source_id=source_id)
await session.commit()

@classmethod
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/chat/channels/slack/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ async def send_reply(
posted = [
await client.post_markdown(
room or conversation.user_id,
reply.body[start : start + REPLY_PIECE_CHARACTERS],
reply.answer[start : start + REPLY_PIECE_CHARACTERS],
thread_ts=thread_ts,
)
for start in range(0, len(reply.body), REPLY_PIECE_CHARACTERS)
for start in range(0, len(reply.answer), REPLY_PIECE_CHARACTERS)
]
reply.source_id = f"{posted[0]['channel']}:{posted[0]['ts']}"
await session.commit()
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/chat/channels/whatsapp/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ async def send_reply(
"""Send a reply to the person who wrote. Druks records the reply's WhatsApp id
before the send, so the copy that WAHA reports back is known as Druks's own."""
is_self_chat = conversation.user_id == conversation.connection.identity["user_id"]
text = f"[Druks] {reply.body}" if is_self_chat else reply.body
text = f"[Druks] {reply.answer}" if is_self_chat else reply.answer
client = await Waha.get_client(session, conversation.connection)
reply.source_id = await client.new_message_id()
await session.commit()
Expand Down
8 changes: 8 additions & 0 deletions backend/druks/chat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from druks.models import Base
from druks.secrets.models import VaultSecret

from .constants import DELIVERY_FAILED_MESSAGE
from .enums import ConversationSource, MessageRole, MessageState

if TYPE_CHECKING:
Expand Down Expand Up @@ -46,6 +47,13 @@ class Message(Base, Uuid7Pk):
created_at: Mapped[datetime] = mapped_column(default=Base.utc_now)
delivered_at: Mapped[datetime | None]

@property
def answer(self) -> str:
"""The text after the agent's last tool call. The text before it is the agent's
notes."""
start = max((call["textOffset"] for call in self.tool_calls), default=0)
return self.body[start:] or DELIVERY_FAILED_MESSAGE

@classmethod
async def get_for_source_id(cls, session: AsyncSession, source_id: str) -> "Message | None":
return await session.scalar(select(cls).where(cls.source_id == source_id))
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/chat/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ async def finish_turn(
await reset_live_stream(conversation.id)
if conversation.connection:
# A person who took the chat over during the turn answers it instead.
if state == MessageState.REPLIED and body and not await conversation.is_held(session):
if state == MessageState.REPLIED and not await conversation.is_held(session):
await conversation.channel.send_reply(session, conversation, reply)
elif not conversation.title:
harness = get_harness(status["harness"])
Expand Down
14 changes: 13 additions & 1 deletion backend/tests/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from druks.accounts.models import Account, PersonalAccessToken
from druks.chat import service, sockets
from druks.chat.bridge import Bridge
from druks.chat.constants import CHAT_KEY_NAME, CONVERSATION_HEADER
from druks.chat.constants import CHAT_KEY_NAME, CONVERSATION_HEADER, DELIVERY_FAILED_MESSAGE
from druks.chat.enums import ConversationSource, MessageRole, MessageState
from druks.chat.exceptions import (
ChatBridgeError,
Expand Down Expand Up @@ -403,6 +403,18 @@ async def download(**values):
assert host.upload_file.await_count == 0


@pytest.mark.parametrize(
("offsets", "answer"), [([14, 28], "Done."), ([14, 28, 33], DELIVERY_FAILED_MESSAGE)]
)
def test_a_reply_answers_with_the_text_after_its_last_tool_call(offsets, answer):
reply = Message(
body="Still running.Still running.Done.",
tool_calls=[{"textOffset": offset} for offset in offsets],
)

assert reply.answer == answer


def channel(answer: bytes) -> tuple[asyncio.StreamReader, MagicMock]:
"""A loopback channel that sends one answer and ends."""
reader = asyncio.StreamReader()
Expand Down
6 changes: 5 additions & 1 deletion docs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ again and reads the saved messages and available live events.
Agent text streams into the conversation. A tool row shows its ACP title
and status. Select the row to read its input and output.
Tool rows have no timestamps. Saved replies retain their tool details and
their position between text segments.
their position between text segments. Slack, GitHub, and WhatsApp get only the
text after the agent's last tool call. The text before it holds the agent's
notes. Only the Chat page shows them. When the agent writes nothing after its
last tool call, the channel gets "The reply is unavailable. Send the message
again to retry."

An agent plan shows the latest complete plan. A plan update replaces the
previous list. Tool updates instead change only the fields that the adapter
Expand Down
Loading