Skip to content

Commit ca66e88

Browse files
committed
team-mode: centralize member session coordination
1 parent cdc086a commit ca66e88

18 files changed

Lines changed: 344 additions & 335 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { TeamModeConfig } from "../../config/schema/team-mode"
2+
3+
export function buildTeammateCommunicationAddendum(config: TeamModeConfig): string {
4+
const delegateTaskGuidance = config.member_delegate_task_budget > 0
5+
? `- delegate-task: You may use this for bounded side work when needed. You have a budget of ${config.member_delegate_task_budget} calls for this team run.`
6+
: "- delegate-task: Do not call this. Member delegate budget is disabled for this team run."
7+
8+
return `
9+
# Team Communication
10+
11+
You are running as a team member. Your text responses are NOT visible to other team members or the lead.
12+
13+
IMPORTANT: For ALL team_* tool calls, use the TeamRunId shown above as the \`teamRunId\` parameter. Do NOT use the team name.
14+
15+
Do not call lead-only lifecycle tools such as \`team_shutdown_request\`, \`team_delete\`, \`team_approve_shutdown\`, or \`team_reject_shutdown\`.
16+
17+
Use these tools instead:
18+
- team_send_message: Send results, blockers, or completion updates to the lead. Use \`to: "lead"\` for the lead, \`to: "<name>"\` for a specific member. Include \`summary\` and \`references\` when they help the lead triage quickly.
19+
- team_task_update: Update your task status. Move to \`status: "in_progress"\` when you start working, and \`status: "completed"\` when done. \`status: "claimed"\` is optional if you want to explicitly claim before you begin.
20+
- team_task_list: See all team tasks and their status.
21+
- team_task_get: Get details of a specific task.
22+
${delegateTaskGuidance}
23+
24+
When you finish your assigned work, ALWAYS:
25+
1. Send your results to lead via team_send_message
26+
2. Mark your task as completed via team_task_update
27+
3. Send a completion message to lead so the lead can decide whether to request shutdown
28+
`
29+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { TeamModeConfig } from "../../config/schema/team-mode"
2+
import { log } from "../../shared/logger"
3+
import { lookupTeamSession } from "./team-session-registry"
4+
import { listActiveTeams, loadRuntimeState } from "./team-state-store/store"
5+
6+
export type ResolvedMemberSession = {
7+
teamRunId: string
8+
memberName: string
9+
}
10+
11+
export async function findResolvedMemberSession(
12+
sessionID: string,
13+
config: TeamModeConfig,
14+
logContext: string,
15+
): Promise<ResolvedMemberSession | null> {
16+
const registryEntry = lookupTeamSession(sessionID)
17+
if (registryEntry?.role === "member") {
18+
try {
19+
const runtimeState = await loadRuntimeState(registryEntry.teamRunId, config)
20+
const memberEntry = runtimeState.members.find(
21+
(member) => member.name === registryEntry.memberName
22+
&& (member.sessionId === undefined || member.sessionId === sessionID),
23+
)
24+
25+
if (memberEntry !== undefined) {
26+
return {
27+
teamRunId: runtimeState.teamRunId,
28+
memberName: memberEntry.name,
29+
}
30+
}
31+
} catch (error) {
32+
log(`${logContext} registry lookup failed`, {
33+
event: `${logContext}-registry-error`,
34+
teamRunId: registryEntry.teamRunId,
35+
sessionID,
36+
error: error instanceof Error ? error.message : String(error),
37+
})
38+
}
39+
}
40+
41+
const activeTeams = await listActiveTeams(config)
42+
for (const activeTeam of activeTeams) {
43+
try {
44+
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config)
45+
const memberEntry = runtimeState.members.find((member) => member.sessionId === sessionID)
46+
if (memberEntry !== undefined) {
47+
return {
48+
teamRunId: runtimeState.teamRunId,
49+
memberName: memberEntry.name,
50+
}
51+
}
52+
} catch (error) {
53+
log(`${logContext} skipped runtime`, {
54+
event: `${logContext}-runtime-error`,
55+
teamRunId: activeTeam.teamRunId,
56+
sessionID,
57+
error: error instanceof Error ? error.message : String(error),
58+
})
59+
}
60+
}
61+
62+
return null
63+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
2+
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
3+
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
4+
import type { RuntimeStateMember } from "./types"
5+
6+
type PromptGenerationModel = {
7+
reasoningEffort?: string
8+
temperature?: number
9+
top_p?: number
10+
maxTokens?: number
11+
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
12+
}
13+
14+
export type TeamMemberPromptBody = {
15+
parts: Array<{ type: "text"; text: string }>
16+
agent?: string
17+
model?: { providerID: string; modelID: string }
18+
variant?: string
19+
temperature?: number
20+
topP?: number
21+
maxOutputTokens?: number
22+
options?: Record<string, unknown>
23+
}
24+
25+
function buildPromptGenerationParams(model: PromptGenerationModel | undefined): Omit<TeamMemberPromptBody, "parts" | "agent" | "model" | "variant"> {
26+
if (!model) {
27+
return {}
28+
}
29+
30+
const promptOptions: Record<string, unknown> = {
31+
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
32+
...(model.thinking ? { thinking: model.thinking } : {}),
33+
}
34+
35+
return {
36+
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
37+
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
38+
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
39+
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
40+
}
41+
}
42+
43+
export function applyMemberSessionRouting(sessionID: string, member: RuntimeStateMember): void {
44+
if (member.category) {
45+
SessionCategoryRegistry.register(sessionID, member.category)
46+
}
47+
48+
applySessionPromptParams(sessionID, member.model)
49+
}
50+
51+
export function buildMemberPromptBody(member: RuntimeStateMember, text: string): TeamMemberPromptBody {
52+
const normalizedAgent = member.subagent_type ? stripAgentListSortPrefix(member.subagent_type) : undefined
53+
const model = member.model
54+
? {
55+
providerID: member.model.providerID,
56+
modelID: member.model.modelID,
57+
}
58+
: undefined
59+
60+
return {
61+
...(normalizedAgent ? { agent: normalizedAgent } : {}),
62+
...(model ? { model } : {}),
63+
...(member.model?.variant ? { variant: member.model.variant } : {}),
64+
...buildPromptGenerationParams(member.model),
65+
parts: [{ type: "text", text }],
66+
}
67+
}

src/features/team-mode/team-runtime/create.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="bun-types" />
22

3-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
3+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
44
import { access, mkdtemp, readdir, rm } from "node:fs/promises"
55
import { tmpdir } from "node:os"
66
import path from "node:path"
@@ -90,7 +90,7 @@ describe("createTeamRun", () => {
9090
resolveMemberMock.mockClear()
9191
})
9292

93-
afterEach(async () => {
93+
afterAll(async () => {
9494
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => rm(directoryPath, { recursive: true, force: true })))
9595
})
9696

@@ -152,6 +152,9 @@ describe("createTeamRun", () => {
152152
// then
153153
expect(firstPrompt).toContain("Do not call lead-only lifecycle tools")
154154
expect(firstPrompt).not.toContain("3. Request shutdown via `team_shutdown_request`")
155+
expect(firstPrompt).toContain("Include `summary` and `references`")
156+
expect(firstPrompt).toContain("Move to `status: \"in_progress\"` when you start working")
157+
expect(firstPrompt).toContain("delegate-task: Do not call this")
155158
expect(firstPrompt).toContain("lead can decide whether to request shutdown")
156159
})
157160

src/features/team-mode/team-runtime/create.ts

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { registerTeamSession } from "../team-session-registry"
1313
import type { RuntimeState, TeamSpec } from "../types"
1414
import { activateTeamLayout } from "./activate-team-layout"
1515
import { cleanupTeamRunResources } from "./cleanup-team-run-resources"
16+
import { buildTeammateCommunicationAddendum } from "../member-guidance"
1617
import { resolveMember } from "./resolve-member"
1718
import { shouldReuseCallerLeadSession } from "../resolve-caller-team-lead"
1819
import { sweepStaleTeamSessions } from "../team-layout-tmux/sweep-stale-team-sessions"
@@ -94,32 +95,17 @@ async function waitForTaskSessionId(bgMgr: BackgroundManager, task: BackgroundTa
9495
return sessionId
9596
}
9697

97-
const TEAMMATE_COMMUNICATION_ADDENDUM = `
98-
# Team Communication
99-
100-
You are running as a team member. Your text responses are NOT visible to other team members or the lead.
101-
102-
IMPORTANT: For ALL team_* tool calls, use the TeamRunId shown above as the \`teamRunId\` parameter. Do NOT use the team name.
103-
104-
Do not call lead-only lifecycle tools such as \`team_shutdown_request\`, \`team_delete\`, \`team_approve_shutdown\`, or \`team_reject_shutdown\`.
105-
106-
Use these tools instead:
107-
- team_send_message: Send results, blockers, or completion updates to the lead. Use \`to: "lead"\` for the lead, \`to: "<name>"\` for a specific member.
108-
- team_task_update: Update your task status. Use \`status: "claimed"\` when starting, \`status: "in_progress"\` while working, \`status: "completed"\` when done.
109-
- team_task_list: See all team tasks and their status.
110-
- team_task_get: Get details of a specific task.
111-
112-
When you finish your assigned work, ALWAYS:
113-
1. Send your results to lead via team_send_message
114-
2. Mark your task as completed via team_task_update
115-
3. Send a completion message to lead so the lead can decide whether to request shutdown
116-
`
117-
118-
function buildMemberPrompt(spec: TeamSpec, member: TeamSpec["members"][number], teamRunId: string, worktreePath?: string): string {
98+
function buildMemberPrompt(
99+
spec: TeamSpec,
100+
member: TeamSpec["members"][number],
101+
teamRunId: string,
102+
config: TeamModeConfig,
103+
worktreePath?: string,
104+
): string {
119105
const promptLines = [`Team: ${spec.name}`, `TeamRunId: ${teamRunId}`, `Member: ${member.name}`]
120106
if (worktreePath) promptLines.push(`Worktree: ${worktreePath}`)
121107
if (member.prompt) promptLines.push(member.prompt)
122-
promptLines.push(TEAMMATE_COMMUNICATION_ADDENDUM)
108+
promptLines.push(buildTeammateCommunicationAddendum(config))
123109
return promptLines.join("\n")
124110
}
125111

@@ -202,7 +188,7 @@ export async function createTeamRun(
202188
const resolvedMember = await resolveMember(member, ctx, categoryExamples, spec.leadAgentId)
203189
const task = await bgMgr.launch({
204190
description: `Create team member ${spec.name}/${member.name}`,
205-
prompt: buildMemberPrompt(spec, member, runtimeState.teamRunId, resource.worktreePath),
191+
prompt: buildMemberPrompt(spec, member, runtimeState.teamRunId, config, resource.worktreePath),
206192
agent: resolvedMember.agentToUse,
207193
parentSessionID: leadSessionId,
208194
parentMessageID: options?.parentMessageID ?? `team-create:${runtimeState.teamRunId}:${member.name}`,

src/features/team-mode/team-runtime/status.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,9 @@ describe("aggregateStatus", () => {
100100
// then
101101
expect(result.teamName).toBe("team-alpha")
102102
expect(result.members).toEqual([
103-
expect.objectContaining({ name: "lead", unreadMessages: 0 }),
104-
expect.objectContaining({ name: "member-1", unreadMessages: 2 }),
105-
expect.objectContaining({ name: "member-2", unreadMessages: 0 }),
103+
expect.objectContaining({ name: "lead", unreadMessages: 0, delegateTaskCallsUsed: 0, delegateTaskBudgetRemaining: undefined }),
104+
expect.objectContaining({ name: "member-1", unreadMessages: 2, delegateTaskCallsUsed: 0, delegateTaskBudgetRemaining: 0 }),
105+
expect.objectContaining({ name: "member-2", unreadMessages: 0, delegateTaskCallsUsed: 0, delegateTaskBudgetRemaining: 0 }),
106106
])
107107
expect(result.tasks).toEqual({ pending: 4, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 4 })
108108
})

src/features/team-mode/team-runtime/status.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface TeamStatus {
2323
worktreePath?: string
2424
unreadMessages: number
2525
paneId?: string
26+
delegateTaskCallsUsed: number
27+
delegateTaskBudgetRemaining?: number
2628
}>
2729
tasks: {
2830
pending: number
@@ -141,6 +143,10 @@ export async function aggregateStatus(
141143
worktreePath: member.worktreePath,
142144
unreadMessages,
143145
paneId: member.tmuxPaneId,
146+
delegateTaskCallsUsed: member.delegateTaskCallsUsed ?? 0,
147+
delegateTaskBudgetRemaining: member.agentType === "leader"
148+
? undefined
149+
: Math.max(0, config.member_delegate_task_budget - (member.delegateTaskCallsUsed ?? 0)),
144150
})),
145151
tasks: countTasks(tasks),
146152
shutdownRequests: runtimeState.shutdownRequests,

src/features/team-mode/team-state-store/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export async function createRuntimeState(
113113
status: "pending",
114114
color: member.color,
115115
worktreePath: member.worktreePath,
116+
delegateTaskCallsUsed: 0,
116117
lastInjectedTurnMarker: undefined,
117118
pendingInjectedMessageIds: [],
118119
})),

src/features/team-mode/tools/messaging.ts

Lines changed: 9 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@ import { randomUUID } from "node:crypto"
33
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
44

55
import type { TeamModeConfig } from "../../../config/schema/team-mode"
6-
import { stripAgentListSortPrefix } from "../../../shared/agent-display-names"
76
import { log } from "../../../shared/logger"
8-
import { applySessionPromptParams } from "../../../shared/session-prompt-params-helpers"
9-
import { SessionCategoryRegistry } from "../../../shared/session-category-registry"
7+
import { applyMemberSessionRouting, buildMemberPromptBody } from "../member-session-routing"
108
import { lookupTeamSession } from "../team-session-registry"
119
import { loadRuntimeState } from "../team-state-store/store"
1210
import { buildEnvelope } from "../team-mailbox/poll"
@@ -43,30 +41,6 @@ type TeamRuntimeDetails = {
4341
activeMembers: string[]
4442
}
4543

46-
function buildPromptGenerationParams(model: {
47-
reasoningEffort?: string
48-
temperature?: number
49-
top_p?: number
50-
maxTokens?: number
51-
thinking?: { type: "enabled" | "disabled"; budgetTokens?: number }
52-
} | undefined): Record<string, unknown> {
53-
if (!model) {
54-
return {}
55-
}
56-
57-
const promptOptions: Record<string, unknown> = {
58-
...(model.reasoningEffort ? { reasoningEffort: model.reasoningEffort } : {}),
59-
...(model.thinking ? { thinking: model.thinking } : {}),
60-
}
61-
62-
return {
63-
...(model.temperature !== undefined ? { temperature: model.temperature } : {}),
64-
...(model.top_p !== undefined ? { topP: model.top_p } : {}),
65-
...(model.maxTokens !== undefined ? { maxOutputTokens: model.maxTokens } : {}),
66-
...(Object.keys(promptOptions).length > 0 ? { options: promptOptions } : {}),
67-
}
68-
}
69-
7044
async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<TeamRuntimeDetails> {
7145
const registryEntry = lookupTeamSession(sessionID)
7246
if (registryEntry?.teamRunId === teamRunId) {
@@ -126,7 +100,12 @@ async function deliverLive(
126100
if (reservation === null) continue
127101

128102
const recipientMember = runtimeState.members.find((entry) => entry.name === recipientName)
129-
const recipientSessionId = recipientMember?.sessionId
103+
if (!recipientMember) {
104+
await releaseDeliveryReservation(reservation).catch(() => {})
105+
continue
106+
}
107+
108+
const recipientSessionId = recipientMember.sessionId
130109
if (!recipientSessionId) {
131110
log("[team-mailbox] live delivery unavailable, falling back to inbox injection", {
132111
reason: "missing-session-id",
@@ -147,28 +126,12 @@ async function deliverLive(
147126
continue
148127
}
149128

150-
const recipientAgent = recipientMember?.subagent_type
151-
const recipientModel = recipientMember?.model
152-
? { providerID: recipientMember.model.providerID, modelID: recipientMember.model.modelID }
153-
: undefined
154-
const recipientVariant = recipientMember?.model?.variant
155-
const normalizedRecipientAgent = recipientAgent ? stripAgentListSortPrefix(recipientAgent) : undefined
156-
157-
if (recipientMember?.category) {
158-
SessionCategoryRegistry.register(recipientSessionId, recipientMember.category)
159-
}
160-
applySessionPromptParams(recipientSessionId, recipientMember?.model)
129+
applyMemberSessionRouting(recipientSessionId, recipientMember)
161130

162131
try {
163132
await client.session.promptAsync({
164133
path: { id: recipientSessionId },
165-
body: {
166-
...(normalizedRecipientAgent ? { agent: normalizedRecipientAgent } : {}),
167-
...(recipientModel ? { model: recipientModel } : {}),
168-
...(recipientVariant ? { variant: recipientVariant } : {}),
169-
...buildPromptGenerationParams(recipientMember?.model),
170-
parts: [{ type: "text", text: envelope }],
171-
},
134+
body: buildMemberPromptBody(recipientMember, envelope),
172135
})
173136
await commitDeliveryReservation(reservation)
174137
log("[team-mailbox] live delivery committed", {

src/features/team-mode/tools/query.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe("query tools", () => {
6464
teamName: "team-alpha",
6565
status: "active",
6666
createdAt: 1,
67-
members: [],
67+
members: [{ name: "worker", unreadMessages: 0, delegateTaskCallsUsed: 0, delegateTaskBudgetRemaining: 0 }],
6868
tasks: { pending: 0, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 0 },
6969
shutdownRequests: [],
7070
concurrency: { runningOnSameModel: 0, queuedOnSameModel: 0 },

0 commit comments

Comments
 (0)