Skip to content
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,12 @@ Narrate what you are doing as you go. Do not work silently and present a result
* Keep validation errors user-facing and predictable.
* For PATCH requests, nullable fields are acceptable when omitted values should remain unchanged.

## Buddy app guide

* `onboarding/service/AppGuide.kt` is the buddy's map of the frontend: every page, where it is, its tabs and their deep links, and who may open it. It is hand-written and mirrors `sprintstart-frontend/src/auth/accessPolicy.ts` and the UI's own labels.
* When a frontend page, tab, tab URL parameter or quoted label changes, update `AppGuide.kt` (and the route list in `AppGuideTest`) in the same piece of work, and the frontend's `src/features/buddy/appGuideRoutes.ts` with it.
* A how-to's `link` may only use a URL parameter the frontend actually reads.

## Do not

* Do not add new production dependencies without explaining why and confirming it with me.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,15 @@ class BuddyController(
request.content,
request.capabilitiesEnabled,
request.filters,
request.currentPage,
)
} else {
buddyTeamService.sendMessageForMe(
jwt.subject,
teamProjectId,
request.content,
request.capabilitiesEnabled,
request.currentPage,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,11 @@ data class SendBuddyMessageRequest(
*/
val teamProjectId: UUID? = null,
val filters: BuddySessionFilters? = null,
/**
* The app path the sender was on (`/team-management`, `/team/…`), so "where is this on the page
* I'm on?" can be answered. Only ever matched against the app guide, never echoed; absent from
* older clients. Not validated: an odd value must cost the guide a line, never the hire their
* message.
*/
val currentPage: String? = null,
)

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package com.sprintstart.sprintstartbackend.onboarding.service

import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto
import com.sprintstart.sprintstartbackend.user.external.UserApi
import com.sprintstart.sprintstartbackend.user.external.enums.Role
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.springframework.stereotype.Component
import java.util.UUID

/**
* The buddy's knowledge of the app itself: which pages the caller can open, what is on them, how to
* do things there, and who to ask for what they cannot do.
*
* Before this the buddy had one place to look for "how do I create a project?" — `search_docs` —
* and that corpus is the *project's* material, which has never heard of Sprintstart's pages. It
* either said nothing useful or described a screen that does not exist.
*
* Mounted for everybody, in both the hire's buddy and team mode: everybody uses the app, and what
* changes with the reader is what the guide says, not whether it exists.
*
* ### Read for the caller, never for somebody else
*
* The guide is filtered by the caller's own permission group and, for a PM, by the projects they
* actually manage — the same two facts the frontend's route guard decides on. A page the caller
* cannot open is never offered as a place to go; what happens there is listed as somebody else's
* job, with who that somebody is. Telling a PM "go to Access Management → New Project" sends them to
* a page their sidebar does not have.
*
* This is guidance, not authorisation: every page still enforces its own access, so a guide that
* drifted from the frontend would mislead, never let anybody in.
*/
@Component
class BuddyAppGuideTools(
private val userApi: UserApi,
) {
/**
* The guide for [userId], naming the page they are on when [currentPage] is known.
*
* @param currentPage The path the caller's browser was on when they spoke (query and fragment
* are ignored). Whatever the client sent, so it is only ever matched against the guide, never
* echoed back verbatim.
*/
fun guideFor(userId: UUID, currentPage: String?): String {
val role = userApi.getPermissionGroup(userId) ?: Role.USER
val managed = if (role == Role.PM) managedProjectNames(userId) else emptyList()
val reader = Reader(role, managesAProject = role != Role.PM || managed.isNotEmpty())

val openable = AppGuide.pages.filter { reader.opens(it) }
val elsewhere = AppGuide.pages.flatMap { page ->
page.howTos
.filter { !reader.opens(page) || (it.doers != null && role !in it.doers) }
.map { page to it }
}

return buildString {
appendLine(whoIsAsking(role, managed))
here(currentPage)?.let { appendLine(it) }
appendLine()
appendLine(
"Pages they can open (link each as [Name](path); where a how-to has its own link, " +
"use that one — it opens the right tab):",
)
openable.forEach { page -> appendPage(page, role) }
if (elsewhere.isNotEmpty()) {
appendLine()
appendLine("Only somebody else can do these — say who to ask, never send them to the page:")
elsewhere.forEach { (page, howTo) ->
val who = describeDoers(howTo.doers ?: page.roles)
appendLine("- ${howTo.task}: $who, on ${page.name} (${howTo.steps})")
}
}
if (reader.blockedFromManagerPages()) {
appendLine()
appendLine(
"They hold the PM role but manage no project, so the $MANAGER_PAGES stay hidden. An admin " +
"makes somebody a project's manager in Access Management → Projects.",
)
}
appendLine()
appendLine("Anywhere in the app:")
appendLine("- ${AppGuide.PROJECT_SWITCHER}")
append("- ${AppGuide.BUDDY_EVERYWHERE}")
}
}

private fun StringBuilder.appendPage(page: AppGuidePage, role: Role) {
val link = if (page.needsId) "${page.path} — fill the id in from another tool" else page.path
appendLine("- ${page.name} ($link) — ${page.whereToFind}")
appendLine(" ${page.purpose}")
page.howTos
.filter { it.doers == null || role in it.doers }
.forEach { howTo ->
val link = howTo.link?.let { " (link: $it)" }.orEmpty()
appendLine(" · to ${howTo.task}: ${howTo.steps}$link")
}
}

/** The names of the caller's projects that they manage — the PM-only gate on manager pages. */
private fun managedProjectNames(userId: UUID): List<String> {
val authId = userApi.getAuthIdByUserId(userId).orElse(null) ?: return emptyList()
return userApi
.getUsersByIds(listOf(userId))
.firstOrNull()
?.projects
.orEmpty()
.filter { userApi.canManageProject(authId, it.projectId) }
.map { it.name }
}

private fun whoIsAsking(role: Role, managed: List<String>): String {
val base = "Who is asking: ${ROLE_NAMES.getValue(role)}."
return when {
role != Role.PM -> base
managed.isEmpty() -> "$base They manage no project yet."
// The frontend gates the manager pages on the *selected* project, not on managing any
// one; without this a PM looking at a project they only belong to is sent to a sidebar
// entry that is not there.
else -> "$base They manage: ${managed.joinToString(", ")}. The $MANAGER_PAGES show only " +
"while one of these is the selected project."
}
}

/** The page the caller is looking at, or null when nothing usable was sent. */
private fun here(currentPage: String?): String? {
val pathname = currentPage
?.substringBefore('?')
?.substringBefore('#')
?.takeIf { it.startsWith("/") && it.length <= MAX_PATH_LENGTH }
?: return null
val page = AppGuide.pageAt(pathname)
?: return "They are on a page this guide does not describe."
return "They are looking at: ${page.name} (${page.path}). \"This page\" or \"here\" means this one."
}

private fun describeDoers(doers: Set<Role>): String {
val names = DOER_ORDER.filter { it in doers }.map { DOER_NAMES.getValue(it) }
if (names.size <= 1) return names.joinToString()
return names.dropLast(1).joinToString(", ") + " or " + names.last()
}

/** The two facts the frontend's route guard decides a page on. */
private class Reader(
val role: Role,
val managesAProject: Boolean,
) {
fun opens(page: AppGuidePage): Boolean =
role in page.roles && (!page.managerScoped || role != Role.PM || managesAProject)

fun blockedFromManagerPages(): Boolean = role == Role.PM && !managesAProject
}

companion object {
const val GET_APP_GUIDE = "get_app_guide"

// A path is a few segments and an id; anything longer is not a route of this app.
private const val MAX_PATH_LENGTH = 200

// The pages `AppGuidePage.managerScoped` marks, as somebody would name them.
private const val MANAGER_PAGES = "manager pages (PM Dashboard and its tabs, Data Ingestion, Blueprints)"

private val ROLE_NAMES = mapOf(
Role.USER to "a team member",
Role.PM to "a project manager (PM)",
Role.HR to "HR",
Role.ADMIN to "an admin",
)

// Who does it, named the way somebody would go and find them. A PM does manager work for the
// projects they manage, so "the project's manager" is the person to chase, not "a PM".
private val DOER_ORDER = listOf(Role.PM, Role.HR, Role.ADMIN, Role.USER)
private val DOER_NAMES = mapOf(
Role.PM to "the project's manager",
Role.HR to "HR",
Role.ADMIN to "an admin",
Role.USER to "anybody",
)

val GET_APP_GUIDE_SPEC = BuddyToolSpecDto(
name = GET_APP_GUIDE,
description = "How the Sprintstart app itself works for the person you are talking to: the " +
"pages they can open, where each one is (the sidebar entry or the card that leads " +
"there), what it is for and how to do things on it — create a project role, choose a " +
"member's role, connect a source, write arrival steps, set a GitHub username — plus " +
"who to ask for what they cannot do themselves, and which page they are looking at " +
"right now. Call it for any 'where do I…', 'how do I … here', 'where is …' or " +
"'what is this page' question about the app. `search_docs` describes the project the " +
"team works on, not this app, so never answer these from it. Takes no arguments — it " +
"always reads the caller.",
parameters = buildJsonObject {
put("type", "object")
put("properties", buildJsonObject { })
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ class BuddyService(
content: String,
capabilitiesEnabled: Boolean = true,
filters: BuddySessionFilters?,
currentPage: String? = null,
): Flow<BuddyStreamEvent> {
val userId = resolveUserId(authId)
if (sessionId == null) {
Expand Down Expand Up @@ -373,7 +374,7 @@ class BuddyService(
next.add(
BuddyAgentMessageDto(
role = "tool",
content = runToolCall(call, userId),
content = runToolCall(call, userId, currentPage),
toolCallId = call.id,
),
)
Expand Down Expand Up @@ -463,6 +464,7 @@ class BuddyService(
private suspend fun FlowCollector<BuddyStreamEvent>.runToolCall(
call: BuddyToolCallDto,
userId: UUID,
currentPage: String?,
): String =
if (buddyActionService.isAction(call.name)) {
val outcome = buddyActionService.propose(call, userId)
Expand Down Expand Up @@ -503,7 +505,7 @@ class BuddyService(
outcome.toolResult
} else {
emit(BuddyStreamEvent(type = "tool_use", name = call.name, kind = "tool"))
buddyToolExecutor.execute(call, userId)
buddyToolExecutor.execute(call, userId, currentPage)
}

private fun resolveUserId(authId: String): UUID =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ class BuddySuggestionService(
label = "Who's on my team?",
question = "Who else is on my project?",
),
// Last, because it is about the app rather than the hire's onboarding. "This page" is
// the one the chip was pressed on: the client sends it with the message.
BuddyAppGuideTools.GET_APP_GUIDE to BuddySuggestionResponse(
label = "What can I do here?",
question = "What can I do on this page?",
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ class BuddyTeamService(
projectId: UUID,
content: String,
capabilitiesEnabled: Boolean = true,
currentPage: String? = null,
): Flow<BuddyStreamEvent> {
val userId = authorize(authId, projectId)
val session = getOrCreateSession(userId, projectId)
Expand All @@ -154,7 +155,12 @@ class BuddyTeamService(
BuddyTeamMessage(session = session, role = BuddyMessageRole.USER, content = content),
)

val context = TeamToolContext(userId = userId, authId = authId, projectId = projectId)
val context = TeamToolContext(
userId = userId,
authId = authId,
projectId = projectId,
currentPage = currentPage,
)

return flow {
var messages = history + BuddyAgentMessageDto(role = "user", content = content)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class BuddyTeamTools(
private val projectMembershipApi: ProjectMembershipApi,
private val areaToolsProvider: ObjectProvider<TeamAreaTools>,
private val buddyProposalService: BuddyProposalService,
private val buddyAppGuideTools: BuddyAppGuideTools,
) {
// Resolved lazily: an area's tools may themselves depend on services that depend on this one.
private val areaTools: Map<TeamArea, TeamAreaTools> by lazy {
Expand All @@ -63,6 +64,9 @@ class BuddyTeamTools(
add(GET_TEAM_ATTENTION_SPEC)
add(FIND_MEMBER_SPEC)
add(GET_MEMBER_PROGRESS_SPEC)
// A read about the app, not about the team, so outside the areas: "where do I set up
// roles?" has to be answerable before anything is opened.
add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC)
if (openableAreas().isNotEmpty()) {
add(openAreaSpec())
}
Expand Down Expand Up @@ -124,6 +128,7 @@ class BuddyTeamTools(
GET_TEAM_ATTENTION -> teamAttention(context.projectId)
FIND_MEMBER -> findMember(context.projectId, call.stringArg("query"))
GET_MEMBER_PROGRESS -> memberProgress(context.projectId, call.stringArg("member_id"))
BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(context.userId, context.currentPage)
else -> areaTools.values.firstOrNull { it.handles(call.name) }?.execute(call, context)
?: "Unknown tool: ${call.name}."
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class BuddyToolExecutor(
private val arrivalStepService: ArrivalStepService,
private val competencyPlacementService: CompetencyPlacementService,
private val buddyPathTools: BuddyPathTools,
private val buddyAppGuideTools: BuddyAppGuideTools,
) {
/**
* The backend tools the AI reasoner is told it may call, for this hire.
Expand Down Expand Up @@ -91,6 +92,8 @@ class BuddyToolExecutor(
add(GET_SUGGESTED_TASKS_SPEC)
add(SEARCH_CANONICAL_ANSWERS_SPEC)
add(GET_TEAMMATES_SPEC)
// Always mounted: everybody uses the app, and the guide says what this caller can open.
add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC)
addAll(buddyBoardTools.toolSpecs())
}

Expand Down Expand Up @@ -138,8 +141,12 @@ class BuddyToolExecutor(
return "Never placed on (a short chat would settle these, and you may offer one): $named"
}

/** Executes [call] on behalf of [userId], returning a plain-text result for the model. */
fun execute(call: BuddyToolCallDto, userId: UUID): String =
/**
* Executes [call] on behalf of [userId], returning a plain-text result for the model.
*
* @param currentPage The app path the caller was on when they spoke, for the app guide.
*/
fun execute(call: BuddyToolCallDto, userId: UUID, currentPage: String? = null): String =
when {
buddyBoardTools.handles(call.name) -> buddyBoardTools.execute(call, userId)
buddyPathTools.handles(call.name) -> buddyPathTools.execute(userId)
Expand All @@ -152,6 +159,7 @@ class BuddyToolExecutor(
GET_SUGGESTED_TASKS -> getSuggestedTasks(userId)
SEARCH_CANONICAL_ANSWERS -> searchCanonicalAnswers(userId, call.stringArg("query"))
GET_TEAMMATES -> getTeammates(userId)
BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(userId, currentPage)
else -> "Unknown tool: ${call.name}."
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ data class TeamToolContext(
val userId: UUID,
val authId: String,
val projectId: UUID,
/** The app path the manager was on when they spoke, for the app guide; null when not sent. */
val currentPage: String? = null,
)

/**
Expand Down
Loading
Loading