Skip to content
Open
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
43 changes: 43 additions & 0 deletions backend/druks/contrib/chat/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Sequence
from datetime import datetime

from sqlalchemy import ForeignKey, select
Expand All @@ -7,6 +8,10 @@
from druks.contrib.chat.schemas import ConversationSummary
from druks.db import Base, StoredSubject, db_session

_PROMPT_MESSAGES = 40
_PROMPT_CHARS = 16_000
_TITLE_CHARS = 80


class Conversation(StoredSubject):
__tablename__ = "chat_conversations"
Expand Down Expand Up @@ -67,9 +72,31 @@ async def save_autonomy(self, autonomy: Autonomy) -> None:
self.autonomy = autonomy
await db_session().flush()

async def name_from_first_line(self) -> None:
"""Fill an empty title from the first user line. A title the operator
already set, or a prior call, is left alone."""
if self.title:
return
first = next(
(message for message in await self.list_messages() if message.role == Role.USER),
None,
)
if not first:
return
line = next(
(" ".join(raw.split()) for raw in first.body.splitlines() if raw.strip()),
"",
)
if line:
self.title = line[:_TITLE_CHARS]
await db_session().flush()

async def list_messages(self) -> list["Message"]:
return await Message.list_for_conversation(self.id)

async def list_prompt_messages(self) -> list["Message"]:
return Message.bound_recent(await self.list_messages())

def get_summary(self) -> ConversationSummary:
return ConversationSummary.model_validate(self)

Expand Down Expand Up @@ -115,3 +142,19 @@ async def list_for_conversation(cls, conversation_id: int) -> list["Message"]:
.order_by(cls.created_at, cls.id)
)
return list(await db_session().scalars(statement))

@classmethod
def bound_recent(cls, messages: Sequence["Message"]) -> list["Message"]:
"""Newest lines that fit the prompt. Older lines drop first. One
oversize last line still goes in — truncating it would hide the turn
the operator just sent."""
chosen: list[Message] = []
chars = 0
for message in reversed(messages):
size = len(message.body)
if chosen and (len(chosen) >= _PROMPT_MESSAGES or chars + size > _PROMPT_CHARS):
break
chosen.append(message)
chars += size
chosen.reverse()
return chosen
2 changes: 1 addition & 1 deletion backend/druks/contrib/chat/templates/talk.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
You are this operator. Act through the live Druks MCP catalog.
Act on behalf of the signed-in operator. Use the attached MCP tools to inspect and change this appliance.

Discover work with list_open_subjects. Echo parkedAt from get_gate unchanged when you answer a gate. Do not invent run ids.

Expand Down
24 changes: 18 additions & 6 deletions backend/druks/contrib/chat/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ class ChatTurn(Gate):
would offer approve/request_changes on a chat turn."""

name = "chat_turn"
text: str
stop: bool = False
action: Literal["send", "stop"]
note: str = ""


class ConfirmTool(Gate):
Expand Down Expand Up @@ -78,31 +78,38 @@ class Talk(Workflow):
async def run_multistep(self) -> None:
while True:
conversation = await self.subject
messages = await conversation.list_messages()
messages = await conversation.list_prompt_messages()
result = await Chat.reply(
autonomy=conversation.autonomy,
messages=[{"role": message.role, "body": message.body} for message in messages],
)
await self.record_message(Role.ASSISTANT, result.text)
await self.name_thread()
deferred = await self.deferred_writes()
if deferred:
decision = await ConfirmTool.wait(
input_request={
"presentation": "in_app",
"label": "Confirm the proposed action",
"controls": ["approve", "reject"],
"questions": [],
},
hold_sandbox=self.sandbox_hold,
)
if decision.action == "approve":
await self.apply_deferred_writes(deferred)
reply = await ChatTurn.wait(
input_request={"presentation": "in_app", "label": "Chat turn"},
input_request={
"presentation": "in_app",
"label": "Message",
"controls": ["send", "stop"],
"questions": [],
},
hold_sandbox=self.sandbox_hold,
)
if reply.stop:
if reply.action == "stop":
return
await self.record_message(Role.USER, reply.text)
await self.record_message(Role.USER, reply.note)

async def get_workspace_kwargs(self, host: "Host") -> dict[str, Any]:
conversation = await self.subject
Expand All @@ -120,6 +127,11 @@ async def record_message(self, role: Role, body: str) -> None:
conversation = await self.subject
await conversation.add_message(role=role, body=body)

@step
async def name_thread(self) -> None:
conversation = await self.subject
await conversation.name_from_first_line()

@step
async def deferred_writes(self) -> list[dict[str, str]]:
return await OperatorToken.take_deferred(self.workflow_id)
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/chat/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,69 @@ async def test_get_for_account_misses_another_operators_thread():
assert (await Conversation.get_for_account(mine.id, owner.id)).id == mine.id
assert await Conversation.get_for_account(theirs.id, owner.id) is None
assert await Conversation.get_for_account(mine.id, None) is None


async def test_name_from_first_line_fills_an_empty_title():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="")
await conversation.add_message(role=Role.USER, body=" Pump the tires \nmore detail")
await conversation.add_message(role=Role.ASSISTANT, body="ok")

await conversation.name_from_first_line()

assert conversation.title == "Pump the tires"


async def test_name_from_first_line_leaves_an_operator_title():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="Pump")
await conversation.add_message(role=Role.USER, body="something else")

await conversation.name_from_first_line()

assert conversation.title == "Pump"


async def test_name_from_first_line_caps_a_long_first_line():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="")
await conversation.add_message(role=Role.USER, body="x" * 100)

await conversation.name_from_first_line()

assert conversation.title == "x" * 80


async def test_name_from_first_line_is_idempotent():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="")
await conversation.add_message(role=Role.USER, body="first")
await conversation.name_from_first_line()
await conversation.add_message(role=Role.USER, body="later")

await conversation.name_from_first_line()

assert conversation.title == "first"


async def test_prompt_history_keeps_the_newest_lines():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="t")
for index in range(41):
await conversation.add_message(role=Role.USER, body=f"m{index}")

prompt = await conversation.list_prompt_messages()

assert [message.body for message in prompt] == [f"m{index}" for index in range(1, 41)]
assert [message.body for message in await conversation.list_messages()][0] == "m0"


async def test_prompt_history_drops_older_lines_that_overflow_the_char_budget():
account = await Account.get_or_create("op@example.com")
conversation = await Conversation.create(account_id=account.id, title="t")
await conversation.add_message(role=Role.USER, body="a" * 10_000)
await conversation.add_message(role=Role.ASSISTANT, body="b" * 10_000)

prompt = await conversation.list_prompt_messages()

assert [message.body[0] for message in prompt] == ["b"]
7 changes: 6 additions & 1 deletion backend/tests/chat/test_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ async def test_a_parked_turn_puts_gate_controls_on_the_thread(druks_client, druk
subject=conversation,
state="parked",
input_gate=ChatTurn.name,
input_request={"presentation": "in_app", "label": "Chat turn"},
input_request={
"presentation": "in_app",
"label": "Message",
"controls": ["send", "stop"],
"questions": [],
},
)
run.input_requested_at = datetime.now(UTC)
await druks_db.flush()
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/chat/test_prompts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from druks.prompts import render_prompt


async def test_talk_prompt_carries_autonomy_bounded_history_and_mcp_discovery():
rendered = await render_prompt(
"chat/talk.md",
autonomy="propose",
messages=[
{"role": "user", "body": "hello"},
{"role": "assistant", "body": "hi"},
],
)

assert "Act on behalf of the signed-in operator" in rendered
assert "list_open_subjects" in rendered
assert "parkedAt" in rendered
assert "Do not invent run ids" in rendered
assert "Autonomy: propose" in rendered
assert "user: hello" in rendered
assert "assistant: hi" in rendered
Loading