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
10 changes: 10 additions & 0 deletions src/api/routes/buddy.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ def buddy_agent(
Executes ``search_docs`` locally (retrieval + citations) and returns as soon as it
either has a final answer or needs a backend-only tool run. The backend carries the
``messages`` list back verbatim, each pending tool's result appended as a ``tool``.

``capabilities_enabled`` and ``team_mode`` pick the persona. Both are read on every
hop rather than only the first: the persona is rebuilt each time, so a resume hop
that omitted one would finish the turn in the other mode.
"""
messages = [_to_message(m) for m in body.messages]
backend_tools = [_to_toolspec(t) for t in body.backend_tools]
Expand All @@ -101,6 +105,8 @@ def buddy_agent(
contribution_verb_past=body.vocabulary.contribution_verb_past,
),
project_ids=frozenset(body.project_ids),
capabilities_enabled=body.capabilities_enabled,
team_mode=body.team_mode,
)
except LLMUnavailableError as exc:
raise HTTPException(
Expand Down Expand Up @@ -194,6 +200,9 @@ def buddy_open_stream(
Emits ``token`` events carrying the greeting as it arrives and one terminal
``done`` carrying the whole greeting and any suggested action. Degrades to a plain
welcome rather than erroring: opening the buddy must never fail the page.

With ``team_mode`` the reader is a project's manager and ``state`` is their team's
attention list, so both the prompt and that plain welcome address a manager.
"""

def event_stream() -> Iterator[str]:
Expand All @@ -203,6 +212,7 @@ def event_stream() -> Iterator[str]:
recent=[_to_message(m) for m in body.recent],
state=body.state,
llm=llm,
team_mode=body.team_mode,
):
yield sse_event(event)
except LLMUnavailableError as exc:
Expand Down
33 changes: 32 additions & 1 deletion src/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,27 @@ class BuddyAgentRequest(BaseModel):
"to no projects (admitting no material)."
),
)
capabilities_enabled: bool = Field(
default=True,
description=(
"False when the reader asked the corpus rather than the mentor: the "
"persona then says it is answering from the project's material only "
"and offers to do nothing. Send it on every hop — the persona is "
"rebuilt on each one, so a hop that drops it changes mode mid-turn."
),
)
team_mode: bool = Field(
default=False,
description=(
"True when the reader is a project's manager asking about that "
"project's team, rather than a hire asking about their own "
"onboarding. The persona then addresses a manager, states team "
"members' situations as facts rather than judgments, and offers "
"changes for the manager to confirm instead of claiming to have made "
"them. Send it on every hop, for the same reason as "
"`capabilities_enabled`."
),
)


class BuddyAgentResponse(BaseModel):
Expand Down Expand Up @@ -1437,7 +1458,17 @@ class BuddyOpenRequest(BaseModel):
default="",
description=(
"A plain-text snapshot of the hire's current state (pull requests, tasks, "
"competencies) for the greeting to ground itself in."
"competencies) for the greeting to ground itself in. In team mode this is "
"the team's attention list instead."
),
)
team_mode: bool = Field(
default=False,
description=(
"True when a project's manager is opening a team conversation. The "
"greeting then addresses a manager about their team — `state` is the "
"team's attention list, not the reader's own onboarding — and the "
"suggested next step is a question about the team."
),
)

Expand Down
37 changes: 34 additions & 3 deletions src/onboarding/buddy_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,15 @@ def _persona_prompt(
summary: str | None,
tool_names: Collection[str],
vocabulary: Vocabulary,
capabilities_enabled: bool,
team_mode: bool,
) -> str:
persona = build_persona(tool_names, vocabulary)
persona = build_persona(
tool_names,
vocabulary,
capabilities_enabled=capabilities_enabled,
team_mode=team_mode,
)
if not summary:
return persona
return persona + _SUMMARY_HEADER + summary
Expand All @@ -111,7 +118,12 @@ def _ensure_persona(
summary: str | None,
tool_names: Collection[str],
vocabulary: Vocabulary,
capabilities_enabled: bool,
team_mode: bool,
) -> list[Message]:
# The system message a resume carries is replaced, not kept: only its summary
# survives. So both modes have to arrive on every hop -- a hop that dropped one
# would rebuild the default persona mid-turn and answer a manager as a hire.
effective_summary = summary
rest = messages
if messages and messages[0]["role"] == "system":
Expand All @@ -120,7 +132,13 @@ def _ensure_persona(
rest = messages[1:]
persona_msg = Message(
role="system",
content=_persona_prompt(effective_summary, tool_names, vocabulary),
content=_persona_prompt(
effective_summary,
tool_names,
vocabulary,
capabilities_enabled,
team_mode,
),
)
return [persona_msg, *rest]

Expand Down Expand Up @@ -191,6 +209,8 @@ def run_agent_turn(
prior_summary: str | None = None,
vocabulary: Vocabulary = DEFAULT_VOCABULARY,
project_ids: frozenset[str] | None = None,
capabilities_enabled: bool = True,
team_mode: bool = False,
) -> AgentTurnResult:
"""Runs one agent turn: executes ``search_docs`` locally, pauses on backend tools.

Expand All @@ -201,6 +221,10 @@ def run_agent_turn(

``prior_summary`` stands in for everything older than ``messages``.

``capabilities_enabled`` and ``team_mode`` choose the persona and must be passed
on every hop, because the persona is rebuilt on every hop: a resume that lost
either would answer the rest of the turn as the default mentor.

This turn does not fold anything, and used not to be able to say that. A
``summarize_upto`` argument asked it to compact the oldest window messages
*before* the model began composing a reply, and because the caller's cursor
Expand All @@ -219,7 +243,14 @@ def run_agent_turn(
# The persona describes exactly the tools this hire was mounted, never a fixed
# catalogue: the backend decides what a given role can even have, and a mentor
# told about a tool it does not have will offer the hire something impossible.
work = _ensure_persona(window, summary, {SEARCH_DOCS, *backend_names}, vocabulary)
work = _ensure_persona(
window,
summary,
{SEARCH_DOCS, *backend_names},
vocabulary,
capabilities_enabled,
team_mode,
)
resolved_exclusions = exclusions if exclusions is not None else SourceExclusions()
citations: list[Citation] = []
seen_chunk_ids: set[str] = set()
Expand Down
70 changes: 66 additions & 4 deletions src/onboarding/buddy_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
generated ahead of it — strict JSON cannot be streamed as prose, which is why this
uses markers. A marker arrives one chunk at a time, so a partial one must be
held back as a candidate, never matched early or emitted.

A visit can also be opened by a project's manager in team mode, where the reader is
responsible for the people the state describes rather than being one of them. That
swaps the system prompt and the fallback greeting; the two-part format and
everything that parses it are shared.
"""

import json
Expand All @@ -28,6 +33,14 @@

_FALLBACK_GREETING = "Welcome back! How can I help with your onboarding today?"

# A manager opening team mode is not coming back to their own onboarding, so the
# hire's welcome is wrong in the one place it is guaranteed to be read: the model
# is unavailable and this is the whole greeting.
_TEAM_FALLBACK_GREETING = (
"You're in team mode. Ask me who needs your attention, or how somebody on the "
"team is getting on."
)


def _format_recent(recent: list[Message]) -> str:
lines = [
Expand Down Expand Up @@ -66,12 +79,48 @@ def _format_recent(recent: list[Message]) -> str:
"you only read from it."
)

# The same two-part shape, deliberately: the marker, the held-back suffix and the
# done payload are parsed by one code path, so a team greeting that formatted itself
# differently would be a second parser to keep in step. Only the reader changes.
_TEAM_STREAM_SYSTEM = (
"You are a perceptive onboarding buddy greeting a project's manager as they "
"open the chat in team mode. They manage one project and are asking about that "
"project's team -- they are not being onboarded themselves. You keep a private, "
"durable memory note about your conversations with this manager, and you speak "
"to them directly.\n"
"You are given: your MEMORY of these conversations (may be empty on the first "
"visit), the RECENT conversation since you last updated that memory (may be "
"empty), and STATE: the team's current attention list -- who is waiting on "
"somebody else and who has stalled. STATE is about the team, never about the "
"manager reading you.\n"
"Write your reply in exactly two parts, in this order, with nothing before the "
"first part:\n"
"PART 1 -- the greeting, as plain prose with no label and no quotes: a short, "
"first-person opener (2-4 sentences) that greets the manager and says the one "
"thing most worth their attention right now, grounded only in the state and "
"the memory. Be specific, not generic. Never invent facts that are not in the "
"memory or the state. Never welcome them as a new hire, never describe their "
"own onboarding, and never suggest what they should work on. Describe a "
"person's situation as a fact about that situation, never as a judgment of the "
"person -- work waiting on a review is the reviewer's move, not a failing of "
"whoever is waiting on it. The manager reads this as you type it, so it must "
"come first.\n"
f"PART 2 -- the line {_ACTION_MARKER} on its own, then ONE suggested next step "
'as JSON {"label": short button text, "question": the message to send when the '
"manager clicks it}, or the word none when nothing fits. The question is one a "
'manager would ask about their team (for example "Who is blocked right now?" '
'or "How is the newest joiner getting on?"), never one about their own work.\n'
"Do not rewrite or restate your memory note. It is maintained separately; here "
"you only read from it."
)


def stream_session(
memory: str | None,
recent: list[Message],
state: str,
llm: LLMClient,
team_mode: bool = False,
) -> Iterator[dict[str, object]]:
"""Stream the greeting as the model writes it.

Expand All @@ -93,15 +142,28 @@ def stream_session(
An unavailable model yields the plain welcome -- opening a visit must never fail
the page.

### Team mode

``team_mode`` swaps the system prompt and the fallback, and nothing else. The
reader is a project's manager and ``state`` is their team's attention list, so a
hire's greeting would welcome them back to an onboarding that is not theirs and
describe their team's stalls as their own. The marker, the held-back suffix and
the ``done`` payload are the same code either way -- a second format here would
be a second parser to keep in step with this one.

@param memory: The mentor's durable note, or None on a first visit. Read from,
never rewritten here -- see the module docstring.
@param recent: The window since the memory was last updated.
@param state: A snapshot of the hire's current state.
@param state: A snapshot of the hire's current state, or in team mode the team's
attention list.
@param team_mode: True when a project's manager is opening a team conversation.
@return: ``token`` events carrying the greeting as it arrives, then one terminal
``done`` carrying the whole greeting and any action.
"""
system = _TEAM_STREAM_SYSTEM if team_mode else _STREAM_SYSTEM
fallback = _TEAM_FALLBACK_GREETING if team_mode else _FALLBACK_GREETING
prompt = [
Message(role="system", content=_STREAM_SYSTEM),
Message(role="system", content=system),
Message(
role="user",
content=(
Expand Down Expand Up @@ -155,7 +217,7 @@ def stream_session(
greeting += emit
yield {"type": "token", "content": emit}
except LLMUnavailableError:
yield {"type": "done", "greeting": _FALLBACK_GREETING, "action": None}
yield {"type": "done", "greeting": fallback, "action": None}
return

# Whatever is still held back was never a marker after all, so it is prose.
Expand All @@ -169,7 +231,7 @@ def stream_session(
# Byte-identical to the concatenated tokens, deliberately. The client renders
# the tokens and the caller persists this; if they differed, the message a hire
# watched arrive would not be the one they see after a reload.
"greeting": greeting if greeting.strip() else _FALLBACK_GREETING,
"greeting": greeting if greeting.strip() else fallback,
"action": (
{"label": label, "question": question} if label and question else None
),
Expand Down
Loading
Loading