mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Specialists offer starting points as an ask_user (free text stays available) so a picked option briefs the reviewer; title generation adds the agent first reply as evidence with a third attempt window.
176 lines
7.4 KiB
Python
176 lines
7.4 KiB
Python
"""FB-010 — LLM auto-titles.
|
|
|
|
After a user turn completes, the manager fires ONE fire-and-forget completion on the
|
|
session's own provider/model to generate a 4-5 word title, stored in `auto_title` —
|
|
never in `title`, so a manual rename always wins. The small-talk sentinel earns exactly
|
|
one retry (with both openers) after the next user turn; every failure is swallowed."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
|
|
from coworker.providers import AssistantTurn, ModelCapabilities, ProviderClient
|
|
from coworker.server.manager import SessionManager
|
|
|
|
|
|
class TitleAwareProvider(ProviderClient):
|
|
"""Chat turns come from one queue; title calls (recognized by the titling system
|
|
prompt) are answered from another — so the fire-and-forget title call can never
|
|
steal a scripted chat turn."""
|
|
|
|
def __init__(self, chat_turns, titles):
|
|
self._chat = list(chat_turns)
|
|
self._titles = list(titles)
|
|
self.title_calls: list[list[dict]] = []
|
|
|
|
def complete(self, *, model, messages, tools=None, **settings):
|
|
if messages and "title chat sessions" in str(messages[0].get("content", "")):
|
|
self.title_calls.append([dict(m) for m in messages])
|
|
item = self._titles.pop(0)
|
|
if isinstance(item, Exception):
|
|
raise item
|
|
return AssistantTurn(text=item, finish_reason="stop")
|
|
return self._chat.pop(0)
|
|
|
|
def capabilities(self, model):
|
|
return ModelCapabilities()
|
|
|
|
|
|
def _text(text):
|
|
return AssistantTurn(text=text, finish_reason="stop")
|
|
|
|
|
|
async def _turn(mgr: SessionManager, sid: str, text: str) -> None:
|
|
"""Drive one user turn the way the server does: run, save, mark idle (the
|
|
auto-title hook), then wait for the fire-and-forget call to settle."""
|
|
engine = mgr.get_engine(sid, agent="chat")
|
|
mgr.mark_running(sid)
|
|
async for _ in engine.run(text):
|
|
pass
|
|
mgr.save(sid, engine)
|
|
mgr.mark_idle(sid)
|
|
deadline = time.time() + 5.0
|
|
while sid in mgr._autotitle_inflight and time.time() < deadline:
|
|
await asyncio.sleep(0.005)
|
|
|
|
|
|
def _mgr(tmp_path, chat_turns, titles):
|
|
provider = TitleAwareProvider(chat_turns, titles)
|
|
return SessionManager(workspace=tmp_path, provider=provider), provider
|
|
|
|
|
|
async def test_title_set_after_first_turn(tmp_path):
|
|
mgr, provider = _mgr(tmp_path, [_text("sure")], ["Japan Trip Planning Help"])
|
|
await _turn(mgr, "s1", "help me plan a trip to japan")
|
|
|
|
assert len(provider.title_calls) == 1
|
|
# The agent's first reply rides along as titling evidence (owner ruling 2026-08-24).
|
|
assert provider.title_calls[0][-1]["content"] == (
|
|
"help me plan a trip to japan\n\n[the assistant's first reply]\nsure"
|
|
)
|
|
assert mgr.session_store.title_state("s1")["auto_title"] == (
|
|
"Japan Trip Planning Help"
|
|
)
|
|
# display precedence: the auto title wins over the title_from snapshot everywhere
|
|
assert mgr.session_store.load("s1").title == "Japan Trip Planning Help"
|
|
row = next(s for s in mgr.list_sessions() if s["session_id"] == "s1")
|
|
assert row["title"] == "Japan Trip Planning Help"
|
|
|
|
|
|
async def test_small_talk_sentinel_then_retry_with_both_openers(tmp_path):
|
|
mgr, provider = _mgr(
|
|
tmp_path,
|
|
[_text("hey!"), _text("on it")],
|
|
["small-talk", "Quarterly Report Draft"],
|
|
)
|
|
await _turn(mgr, "s2", "hey")
|
|
assert mgr.session_store.title_state("s2")["auto_title"] is None
|
|
assert mgr.session_store.load("s2").title == "hey" # title_from fallback stands
|
|
|
|
await _turn(mgr, "s2", "help me draft the quarterly report")
|
|
assert len(provider.title_calls) == 2
|
|
retry_opener = provider.title_calls[1][-1]["content"]
|
|
assert "hey" in retry_opener and "quarterly report" in retry_opener
|
|
assert mgr.session_store.load("s2").title == "Quarterly Report Draft"
|
|
|
|
|
|
async def test_gives_up_after_second_sentinel(tmp_path):
|
|
mgr, provider = _mgr(
|
|
tmp_path,
|
|
[_text("hi"), _text("hello"), _text("yo")],
|
|
["small-talk", "small-talk", "Should Never Be Asked"],
|
|
)
|
|
await _turn(mgr, "s3", "hey")
|
|
await _turn(mgr, "s3", "how are you")
|
|
await _turn(mgr, "s3", "good morning")
|
|
assert len(provider.title_calls) == 2 # attempt 1 + the single retry, then give up
|
|
assert mgr.session_store.title_state("s3")["auto_title"] is None
|
|
assert mgr.session_store.load("s3").title == "hey"
|
|
|
|
|
|
async def test_manual_rename_always_wins(tmp_path):
|
|
# rename AFTER the auto title landed
|
|
mgr, _ = _mgr(tmp_path, [_text("ok")], ["Login Bug Investigation"])
|
|
await _turn(mgr, "s4", "the login page 500s")
|
|
assert mgr.session_store.load("s4").title == "Login Bug Investigation"
|
|
mgr.rename_session("s4", "Prod incident 42")
|
|
assert mgr.session_store.load("s4").title == "Prod incident 42"
|
|
# ...and a late-landing generated title can't displace it
|
|
assert mgr.session_store.set_auto_title("s4", "sneaky") is False
|
|
assert mgr.session_store.load("s4").title == "Prod incident 42"
|
|
|
|
|
|
async def test_rename_before_generation_blocks_it(tmp_path):
|
|
mgr, provider = _mgr(
|
|
tmp_path, [_text("hi"), _text("ok")], ["small-talk", "Should Never Be Asked"]
|
|
)
|
|
await _turn(mgr, "s5", "hey") # sentinel — no title yet
|
|
mgr.rename_session("s5", "My Thread")
|
|
await _turn(mgr, "s5", "help me plan the offsite")
|
|
assert len(provider.title_calls) == 1 # the retry never fired
|
|
assert mgr.session_store.load("s5").title == "My Thread"
|
|
|
|
|
|
async def test_provider_failure_is_swallowed(tmp_path):
|
|
mgr, provider = _mgr(tmp_path, [_text("ok")], [RuntimeError("model down")])
|
|
await _turn(mgr, "s6", "summarize this doc") # must not raise
|
|
assert mgr.session_store.title_state("s6")["auto_title"] is None
|
|
assert mgr.session_store.load("s6").title == "summarize this doc"
|
|
|
|
|
|
async def test_sanitizes_and_rejects_absurd_output(tmp_path):
|
|
# surrounding quotes stripped + whitespace collapsed
|
|
mgr, _ = _mgr(tmp_path, [_text("ok")], ['"Fix Login Bug"'])
|
|
await _turn(mgr, "s7", "the login page 500s")
|
|
assert mgr.session_store.load("s7").title == "Fix Login Bug"
|
|
|
|
# absurdly long output (>80 chars) is a failure, not a title
|
|
mgr2, _ = _mgr(tmp_path / "b", [_text("ok")], ["x" * 90])
|
|
await _turn(mgr2, "s8", "the login page 500s")
|
|
assert mgr2.session_store.title_state("s8")["auto_title"] is None
|
|
|
|
|
|
def test_turn_start_titles_before_the_turn_completes(tmp_path):
|
|
# Owner catch 2026-08-24: titling used to ride ONLY turn completion, so a long
|
|
# agentic turn left the session titled by its first message for the whole run. The
|
|
# turn-start hook titles on the user's words the moment they land.
|
|
async def go():
|
|
mgr, provider = _mgr(tmp_path, [_text("working on it…")], ["Scan the API for secrets"])
|
|
sid = "early1"
|
|
engine = mgr.get_engine(sid, agent="chat")
|
|
engine.messages.append({"role": "user", "content": "scan code for vuln and secrets"})
|
|
mgr.save(sid, engine)
|
|
mgr.mark_running(sid) # the turn is RUNNING — no completion in sight
|
|
mgr._maybe_autotitle(sid) # the app's turn_start hook
|
|
deadline = time.time() + 5.0
|
|
while sid in mgr._autotitle_inflight and time.time() < deadline:
|
|
await asyncio.sleep(0.005)
|
|
state = mgr.session_store.title_state(sid)
|
|
assert state["auto_title"] == "Scan the API for secrets"
|
|
# The completion hook later, same openers: skipped WITHOUT burning attempt 2.
|
|
mgr.mark_idle(sid)
|
|
assert mgr._autotitle_attempts[sid] == 1
|
|
|
|
asyncio.run(go())
|