feat(team-mode): add team spec input normalizer with tests
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import { resolveCallerTeamLead } from "../resolve-caller-team-lead"
|
||||||
|
import { normalizeTeamSpecInput } from "./team-spec-input-normalizer"
|
||||||
|
|
||||||
|
describe("normalizeTeamSpecInput", () => {
|
||||||
|
test("injects the caller as lead when no lead is specified", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "alpha-team",
|
||||||
|
members: [{ kind: "category", category: "quick", prompt: "Inspect the workspace" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("\u200BSisyphus - Ultraworker"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(normalizedSpec).toMatchObject({
|
||||||
|
leadAgentId: "lead",
|
||||||
|
members: [
|
||||||
|
{ name: "lead", kind: "subagent_type", subagent_type: "sisyphus" },
|
||||||
|
{ name: "quick-1", kind: "category", category: "quick" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps an explicit leadAgentId unchanged when the caller is eligible", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "alpha-team",
|
||||||
|
leadAgentId: "captain",
|
||||||
|
members: [
|
||||||
|
{ kind: "subagent_type", name: "captain", subagent_type: "atlas" },
|
||||||
|
{ kind: "category", name: "member-1", category: "quick", prompt: "Inspect the workspace" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(normalizedSpec).toEqual(rawSpec)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers isLead over the caller when both are present", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "alpha-team",
|
||||||
|
members: [
|
||||||
|
{ kind: "subagent_type", name: "captain", subagent_type: "atlas", isLead: true },
|
||||||
|
{ kind: "category", category: "quick", prompt: "Inspect the workspace" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(normalizedSpec).toMatchObject({
|
||||||
|
leadAgentId: "captain",
|
||||||
|
members: [
|
||||||
|
{ kind: "subagent_type", name: "captain", subagent_type: "atlas" },
|
||||||
|
{ kind: "category", name: "quick-1", category: "quick" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws a clear error when the caller is not eligible and no lead is specified", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "alpha-team",
|
||||||
|
members: [{ kind: "category", category: "quick", prompt: "Inspect the workspace" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = () => normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("explore"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toThrow("Caller agent explore is not eligible as team lead; specify leadAgentId explicitly")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes natural inline names to schema-safe names", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "Project Analysis Team",
|
||||||
|
leadAgentId: "Agent Lead",
|
||||||
|
members: [
|
||||||
|
{ kind: "category", name: "Agent Lead", category: "quick", prompt: "Lead the analysis work" },
|
||||||
|
{ kind: "category", name: "Agent 1: Structure Analyst", category: "quick", prompt: "Inspect the workspace" },
|
||||||
|
{ kind: "category", name: "Agent 1 Structure Analyst", category: "quick", prompt: "Inspect related tests" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(normalizedSpec).toMatchObject({
|
||||||
|
name: "project-analysis-team",
|
||||||
|
leadAgentId: "agent-lead",
|
||||||
|
members: [
|
||||||
|
{ name: "agent-lead" },
|
||||||
|
{ name: "agent-1-structure-analyst" },
|
||||||
|
{ name: "agent-1-structure-analyst-2" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the provided default category for role-only natural members", () => {
|
||||||
|
// given
|
||||||
|
const rawSpec = {
|
||||||
|
name: "analysis-team",
|
||||||
|
members: [
|
||||||
|
{ name: "Structure Analyst", role: "Structure Analyst", capabilities: ["structure", "modules"] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const normalizedSpec = normalizeTeamSpecInput(rawSpec, {
|
||||||
|
callerTeamLead: resolveCallerTeamLead("Sisyphus - Ultraworker"),
|
||||||
|
defaultCategoryName: "analysis",
|
||||||
|
})
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(normalizedSpec).toMatchObject({
|
||||||
|
members: [
|
||||||
|
{ name: "lead", kind: "subagent_type" },
|
||||||
|
{ name: "structure-analyst", kind: "category", category: "analysis", prompt: "Role: Structure Analyst\nstructure, modules" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import type { CallerTeamLead } from "../resolve-caller-team-lead"
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
export type NormalizeTeamSpecInputOptions = {
|
||||||
|
callerTeamLead?: CallerTeamLead
|
||||||
|
defaultCategoryName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneJsonRecord(value: JsonRecord): JsonRecord {
|
||||||
|
return { ...value }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberName(value: unknown): string | undefined {
|
||||||
|
return isJsonRecord(value) && typeof value.name === "string" ? value.name : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNameStem(value: string): string {
|
||||||
|
const normalizedStem = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
|
||||||
|
return normalizedStem.length > 0 ? normalizedStem : "member"
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveMemberNameStem(member: JsonRecord): string {
|
||||||
|
if (member.kind === "category" && typeof member.category === "string") {
|
||||||
|
return normalizeNameStem(member.category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.kind === "subagent_type" && typeof member.subagent_type === "string") {
|
||||||
|
return normalizeNameStem(member.subagent_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "member"
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignGeneratedMemberNames(rawMembers: unknown[]): unknown[] {
|
||||||
|
const usedNames = new Set<string>()
|
||||||
|
|
||||||
|
return rawMembers.map((member) => {
|
||||||
|
if (!isJsonRecord(member)) {
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawName = getMemberName(member)
|
||||||
|
const stem = rawName === undefined ? deriveMemberNameStem(member) : normalizeNameStem(rawName)
|
||||||
|
let generatedName = rawName === undefined ? `${stem}-1` : stem
|
||||||
|
let suffix = rawName === undefined ? 1 : 2
|
||||||
|
while (usedNames.has(generatedName)) {
|
||||||
|
generatedName = `${stem}-${suffix}`
|
||||||
|
suffix += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usedNames.add(generatedName)
|
||||||
|
return { ...member, name: generatedName }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMemberLeadFlag(value: unknown): unknown {
|
||||||
|
if (!isJsonRecord(value) || !Object.hasOwn(value, "isLead")) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isLead: _isLead, ...memberWithoutLeadFlag } = value
|
||||||
|
return memberWithoutLeadFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMemberLeadFlag(rawMembers: unknown[]): boolean {
|
||||||
|
return rawMembers.some((member) => isJsonRecord(member) && member.isLead === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCallerLeadMember(callerAgentTypeId: string): JsonRecord {
|
||||||
|
return {
|
||||||
|
name: "lead",
|
||||||
|
kind: "subagent_type",
|
||||||
|
subagent_type: callerAgentTypeId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPromptAlias(member: JsonRecord): string | undefined {
|
||||||
|
if (typeof member.prompt === "string") {
|
||||||
|
return member.prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof member.systemPrompt === "string") {
|
||||||
|
return member.systemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof member.system_prompt === "string") {
|
||||||
|
return member.system_prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStringArray(value: unknown): string | undefined {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const strings = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||||
|
return strings.length > 0 ? strings.join(", ") : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromptFromNaturalMember(member: JsonRecord): string {
|
||||||
|
const promptAlias = getPromptAlias(member)
|
||||||
|
if (promptAlias !== undefined) {
|
||||||
|
return promptAlias
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptParts = [
|
||||||
|
typeof member.role === "string" ? `Role: ${member.role}` : undefined,
|
||||||
|
typeof member.description === "string" ? member.description : undefined,
|
||||||
|
formatStringArray(member.capabilities),
|
||||||
|
formatStringArray(member.responsibilities),
|
||||||
|
].filter((part): part is string => part !== undefined && part.trim().length > 0)
|
||||||
|
|
||||||
|
return promptParts.length > 0
|
||||||
|
? promptParts.join("\n")
|
||||||
|
: "Work on the assigned team task and report findings to the lead."
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInlineMember(member: JsonRecord, options?: NormalizeTeamSpecInputOptions): JsonRecord {
|
||||||
|
const {
|
||||||
|
capabilities: _capabilities,
|
||||||
|
description: _description,
|
||||||
|
loadSkills: _loadSkills,
|
||||||
|
load_skills: _loadSkillsSnakeCase,
|
||||||
|
responsibilities: _responsibilities,
|
||||||
|
role: _role,
|
||||||
|
systemPrompt: _systemPrompt,
|
||||||
|
system_prompt: _systemPromptSnakeCase,
|
||||||
|
...normalizedMember
|
||||||
|
} = member
|
||||||
|
|
||||||
|
const rawKind = normalizedMember.kind
|
||||||
|
|
||||||
|
if (normalizedMember.kind === undefined) {
|
||||||
|
if (typeof normalizedMember.category === "string") {
|
||||||
|
normalizedMember.kind = "category"
|
||||||
|
} else if (typeof normalizedMember.subagent_type === "string") {
|
||||||
|
normalizedMember.kind = "subagent_type"
|
||||||
|
} else if (options?.defaultCategoryName !== undefined) {
|
||||||
|
normalizedMember.kind = "category"
|
||||||
|
normalizedMember.category = options.defaultCategoryName
|
||||||
|
}
|
||||||
|
} else if (normalizedMember.kind !== "category" && normalizedMember.kind !== "subagent_type") {
|
||||||
|
if (typeof normalizedMember.category === "string") {
|
||||||
|
normalizedMember.kind = "category"
|
||||||
|
} else if (typeof normalizedMember.subagent_type === "string") {
|
||||||
|
normalizedMember.kind = "subagent_type"
|
||||||
|
} else if (typeof rawKind === "string" && rawKind !== "agent" && rawKind !== "member" && rawKind !== "worker" && rawKind !== "analyst") {
|
||||||
|
normalizedMember.kind = "category"
|
||||||
|
normalizedMember.category = rawKind
|
||||||
|
} else if (options?.defaultCategoryName !== undefined) {
|
||||||
|
normalizedMember.kind = "category"
|
||||||
|
normalizedMember.category = options.defaultCategoryName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMember.kind === "category" && normalizedMember.prompt === undefined) {
|
||||||
|
normalizedMember.prompt = buildPromptFromNaturalMember(member)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedMember
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamSpecInput(raw: unknown, options?: NormalizeTeamSpecInputOptions): unknown {
|
||||||
|
if (!isJsonRecord(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSpec = cloneJsonRecord(raw)
|
||||||
|
if (typeof normalizedSpec.name === "string") {
|
||||||
|
normalizedSpec.name = normalizeNameStem(normalizedSpec.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMembers = raw.members
|
||||||
|
const rawLead = raw.lead
|
||||||
|
let leadAgentId = typeof raw.leadAgentId === "string" ? raw.leadAgentId : undefined
|
||||||
|
const hasExplicitLead = leadAgentId !== undefined
|
||||||
|
|| isJsonRecord(rawLead)
|
||||||
|
|| (Array.isArray(rawMembers) && hasMemberLeadFlag(rawMembers))
|
||||||
|
|
||||||
|
if (Array.isArray(rawMembers)) {
|
||||||
|
let normalizedMembers = rawMembers.map((member) => isJsonRecord(member) ? normalizeInlineMember(member, options) : member)
|
||||||
|
|
||||||
|
if (isJsonRecord(rawLead)) {
|
||||||
|
const leadMember = normalizeInlineMember(rawLead, options)
|
||||||
|
if (leadMember.name === undefined) {
|
||||||
|
leadMember.name = "lead"
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadName = getMemberName(leadMember)
|
||||||
|
const alreadyPresent = leadName !== undefined && normalizedMembers.some((member) => getMemberName(member) === leadName)
|
||||||
|
if (!alreadyPresent) {
|
||||||
|
normalizedMembers = [leadMember, ...normalizedMembers]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadAgentId === undefined && leadName !== undefined) {
|
||||||
|
leadAgentId = leadName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExplicitLead) {
|
||||||
|
const callerTeamLead = options?.callerTeamLead
|
||||||
|
if (callerTeamLead?.isEligibleForTeamLead && callerTeamLead.agentTypeId !== undefined) {
|
||||||
|
normalizedMembers = [createCallerLeadMember(callerTeamLead.agentTypeId), ...normalizedMembers]
|
||||||
|
leadAgentId = "lead"
|
||||||
|
} else if (callerTeamLead?.displayName !== undefined) {
|
||||||
|
throw new Error(`Caller agent ${callerTeamLead.displayName} is not eligible as team lead; specify leadAgentId explicitly`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedMembers = assignGeneratedMemberNames(normalizedMembers)
|
||||||
|
|
||||||
|
normalizedMembers = normalizedMembers.map((member) => {
|
||||||
|
const memberName = getMemberName(member)
|
||||||
|
const isLead = isJsonRecord(member) && member.isLead === true
|
||||||
|
if (leadAgentId === undefined && isLead && memberName !== undefined) {
|
||||||
|
leadAgentId = memberName
|
||||||
|
}
|
||||||
|
return stripMemberLeadFlag(member)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (leadAgentId !== undefined && !normalizedMembers.some((member) => getMemberName(member) === leadAgentId)) {
|
||||||
|
const normalizedLeadAgentId = normalizeNameStem(leadAgentId)
|
||||||
|
if (normalizedMembers.some((member) => getMemberName(member) === normalizedLeadAgentId)) {
|
||||||
|
leadAgentId = normalizedLeadAgentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadAgentId === undefined && normalizedMembers.length === 1) {
|
||||||
|
leadAgentId = getMemberName(normalizedMembers[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedSpec.members = normalizedMembers
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadAgentId !== undefined) {
|
||||||
|
normalizedSpec.leadAgentId = leadAgentId
|
||||||
|
}
|
||||||
|
|
||||||
|
delete normalizedSpec.lead
|
||||||
|
|
||||||
|
return normalizedSpec
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user