/ tools / TeamCreateTool / TeamCreateTool.ts
TeamCreateTool.ts
  1  import { z } from 'zod/v4'
  2  import { getSessionId } from '../../bootstrap/state.js'
  3  import { logEvent } from '../../services/analytics/index.js'
  4  import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js'
  5  import type { Tool } from '../../Tool.js'
  6  import { buildTool, type ToolDef } from '../../Tool.js'
  7  import { formatAgentId } from '../../utils/agentId.js'
  8  import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'
  9  import { getCwd } from '../../utils/cwd.js'
 10  import { lazySchema } from '../../utils/lazySchema.js'
 11  import {
 12    getDefaultMainLoopModel,
 13    parseUserSpecifiedModel,
 14  } from '../../utils/model/model.js'
 15  import { jsonStringify } from '../../utils/slowOperations.js'
 16  import { getResolvedTeammateMode } from '../../utils/swarm/backends/registry.js'
 17  import { TEAM_LEAD_NAME } from '../../utils/swarm/constants.js'
 18  import type { TeamFile } from '../../utils/swarm/teamHelpers.js'
 19  import {
 20    getTeamFilePath,
 21    readTeamFile,
 22    registerTeamForSessionCleanup,
 23    sanitizeName,
 24    writeTeamFileAsync,
 25  } from '../../utils/swarm/teamHelpers.js'
 26  import { assignTeammateColor } from '../../utils/swarm/teammateLayoutManager.js'
 27  import {
 28    ensureTasksDir,
 29    resetTaskList,
 30    setLeaderTeamName,
 31  } from '../../utils/tasks.js'
 32  import { generateWordSlug } from '../../utils/words.js'
 33  import { TEAM_CREATE_TOOL_NAME } from './constants.js'
 34  import { getPrompt } from './prompt.js'
 35  import { renderToolUseMessage } from './UI.js'
 36  
 37  const inputSchema = lazySchema(() =>
 38    z.strictObject({
 39      team_name: z.string().describe('Name for the new team to create.'),
 40      description: z.string().optional().describe('Team description/purpose.'),
 41      agent_type: z
 42        .string()
 43        .optional()
 44        .describe(
 45          'Type/role of the team lead (e.g., "researcher", "test-runner"). ' +
 46            'Used for team file and inter-agent coordination.',
 47        ),
 48    }),
 49  )
 50  type InputSchema = ReturnType<typeof inputSchema>
 51  
 52  export type Output = {
 53    team_name: string
 54    team_file_path: string
 55    lead_agent_id: string
 56  }
 57  
 58  export type Input = z.infer<InputSchema>
 59  
 60  /**
 61   * Generates a unique team name by checking if the provided name already exists.
 62   * If the name already exists, generates a new word slug.
 63   */
 64  function generateUniqueTeamName(providedName: string): string {
 65    // If the team doesn't exist, use the provided name
 66    if (!readTeamFile(providedName)) {
 67      return providedName
 68    }
 69  
 70    // Team exists, generate a new unique name
 71    return generateWordSlug()
 72  }
 73  
 74  export const TeamCreateTool: Tool<InputSchema, Output> = buildTool({
 75    name: TEAM_CREATE_TOOL_NAME,
 76    searchHint: 'create a multi-agent swarm team',
 77    maxResultSizeChars: 100_000,
 78    shouldDefer: true,
 79  
 80    userFacingName() {
 81      return ''
 82    },
 83  
 84    get inputSchema(): InputSchema {
 85      return inputSchema()
 86    },
 87  
 88    isEnabled() {
 89      return isAgentSwarmsEnabled()
 90    },
 91  
 92    toAutoClassifierInput(input) {
 93      return input.team_name
 94    },
 95  
 96    async validateInput(input, _context) {
 97      if (!input.team_name || input.team_name.trim().length === 0) {
 98        return {
 99          result: false,
100          message: 'team_name is required for TeamCreate',
101          errorCode: 9,
102        }
103      }
104      return { result: true }
105    },
106  
107    async description() {
108      return 'Create a new team for coordinating multiple agents'
109    },
110  
111    async prompt() {
112      return getPrompt()
113    },
114  
115    mapToolResultToToolResultBlockParam(data, toolUseID) {
116      return {
117        tool_use_id: toolUseID,
118        type: 'tool_result' as const,
119        content: [
120          {
121            type: 'text' as const,
122            text: jsonStringify(data),
123          },
124        ],
125      }
126    },
127  
128    async call(input, context) {
129      const { setAppState, getAppState } = context
130      const { team_name, description: _description, agent_type } = input
131  
132      // Check if already in a team - restrict to one team per leader
133      const appState = getAppState()
134      const existingTeam = appState.teamContext?.teamName
135  
136      if (existingTeam) {
137        throw new Error(
138          `Already leading team "${existingTeam}". A leader can only manage one team at a time. Use TeamDelete to end the current team before creating a new one.`,
139        )
140      }
141  
142      // If team already exists, generate a unique name instead of failing
143      const finalTeamName = generateUniqueTeamName(team_name)
144  
145      // Generate a deterministic agent ID for the team lead
146      const leadAgentId = formatAgentId(TEAM_LEAD_NAME, finalTeamName)
147      const leadAgentType = agent_type || TEAM_LEAD_NAME
148      // Get the team lead's current model from AppState (handles session model, settings, CLI override)
149      const leadModel = parseUserSpecifiedModel(
150        appState.mainLoopModelForSession ??
151          appState.mainLoopModel ??
152          getDefaultMainLoopModel(),
153      )
154  
155      const teamFilePath = getTeamFilePath(finalTeamName)
156  
157      const teamFile: TeamFile = {
158        name: finalTeamName,
159        description: _description,
160        createdAt: Date.now(),
161        leadAgentId,
162        leadSessionId: getSessionId(), // Store actual session ID for team discovery
163        members: [
164          {
165            agentId: leadAgentId,
166            name: TEAM_LEAD_NAME,
167            agentType: leadAgentType,
168            model: leadModel,
169            joinedAt: Date.now(),
170            tmuxPaneId: '',
171            cwd: getCwd(),
172            subscriptions: [],
173          },
174        ],
175      }
176  
177      await writeTeamFileAsync(finalTeamName, teamFile)
178      // Track for session-end cleanup — teams were left on disk forever
179      // unless explicitly TeamDelete'd (gh-32730).
180      registerTeamForSessionCleanup(finalTeamName)
181  
182      // Reset and create the corresponding task list directory (Team = Project = TaskList)
183      // This ensures task numbering starts fresh at 1 for each new swarm
184      const taskListId = sanitizeName(finalTeamName)
185      await resetTaskList(taskListId)
186      await ensureTasksDir(taskListId)
187  
188      // Register the team name so getTaskListId() returns it for the leader.
189      // Without this, the leader falls through to getSessionId() and writes tasks
190      // to a different directory than tmux/iTerm2 teammates expect.
191      setLeaderTeamName(sanitizeName(finalTeamName))
192  
193      // Update AppState with team context
194      setAppState(prev => ({
195        ...prev,
196        teamContext: {
197          teamName: finalTeamName,
198          teamFilePath,
199          leadAgentId,
200          teammates: {
201            [leadAgentId]: {
202              name: TEAM_LEAD_NAME,
203              agentType: leadAgentType,
204              color: assignTeammateColor(leadAgentId),
205              tmuxSessionName: '',
206              tmuxPaneId: '',
207              cwd: getCwd(),
208              spawnedAt: Date.now(),
209            },
210          },
211        },
212      }))
213  
214      logEvent('tengu_team_created', {
215        team_name:
216          finalTeamName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
217        teammate_count: 1,
218        lead_agent_type:
219          leadAgentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
220        teammate_mode:
221          getResolvedTeammateMode() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
222      })
223  
224      // Note: We intentionally don't set CLAUDE_CODE_AGENT_ID for the team lead because:
225      // 1. The lead is not a "teammate" - isTeammate() should return false for them
226      // 2. Their ID is deterministic (team-lead@teamName) and can be derived when needed
227      // 3. Setting it would cause isTeammate() to return true, breaking inbox polling
228      // Team name is stored in AppState.teamContext, not process.env
229  
230      return {
231        data: {
232          team_name: finalTeamName,
233          team_file_path: teamFilePath,
234          lead_agent_id: leadAgentId,
235        },
236      }
237    },
238  
239    renderToolUseMessage,
240  } satisfies ToolDef<InputSchema, Output>)